diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..2eff8e310 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "submodules/ably-common"] + path = submodules/ably-common + url = https://github.com/ably/ably-common.git diff --git a/specifications/REST-OBJECTS-PROPOSAL.md b/specifications/REST-OBJECTS-PROPOSAL.md new file mode 100644 index 000000000..108a8d422 --- /dev/null +++ b/specifications/REST-OBJECTS-PROPOSAL.md @@ -0,0 +1,461 @@ +# REST Objects API Specification — Proposal + +## Overview + +This document proposes additions to the [Objects Features](./objects-features.md) specification to cover the **REST Objects API**. This API allows clients to read and modify LiveObjects state via REST, without maintaining a realtime connection. + +The REST Objects API is accessed via `RestChannel#object`, which returns a `RestObject` instance. It provides three operations: + +- **`get`** — read object state (compact or full) +- **`publish`** — modify objects (create, set, remove, increment) +- **`generateObjectId`** — pre-compute object IDs for batch operations + +The ably-js implementation shipped in [PR #2109](https://github.com/ably/ably-js/pull/2109). This proposal derives its assertions from that implementation and its test suite. + +--- + +## 1. Types Required + +### 1.1 Types that need new spec definitions + +These types are new to the REST Objects API and don't exist in the current specification: + +| Type | Purpose | +|------|---------| +| `RestObject` | Main class; accessed via `RestChannel#object` | +| `RestObjectGetParams` | Parameters for `RestObject#get` | +| `RestObjectGetCompactResult` | Response type when `compact` is true (default) | +| `RestObjectGetFullResult` | Response type when `compact` is false | +| `RestLiveMap` | Full-mode representation of a map object | +| `RestLiveCounter` | Full-mode representation of a counter object | +| `RestObjectData` | Decoded leaf value in full-mode responses | +| `RestObjectOperation` | Union of all publish operation types | +| `PublishObjectData` | User-facing value type for publish operations | +| `RestObjectPublishResult` | Response from `publish` | +| `RestObjectGenerateIdResult` | Response from `generateObjectId` | + +### 1.2 Existing types referenced (no changes needed) + +| Type | Spec location | Usage | +|------|--------------|-------| +| `ObjectData` | [OD1-OD5](./features.md#OD1) | Wire-format leaf values; `RestObjectData` is the decoded form | +| `ObjectsMapSemantics` | [objects-features.md IDL](#idl) | Map semantics enum (`LWW`) | +| `ObjectOperationAction` | [OOP2](./features.md#OOP2) | Operation action enum | +| `ErrorInfo` | [features.md](./features.md) | Error reporting | + +### 1.3 Spec points in features.md that need amending + +| Spec point | Change needed | +|-----------|--------------| +| `RSL` (RestChannel) | Add `RSL16` for `RestChannel#object` attribute (parallels `RSL3` for presence, `RSL10` for annotations) | +| `PC5` (Objects plugin) | Extend to cover REST channels (currently only references realtime channels) | + +--- + +## 2. Proposed Spec Point Prefix + +Following the convention: +- `RSP` = RestPresence +- `RSAN` = RestAnnotations +- `RTL27` = RealtimeChannel#objects + +Proposed prefix: **`RSO`** (Rest Object) + +--- + +## 3. Proposed Specification + +### 3.1 Amendments to features.md + +#### RestChannel (additions) + +``` +- (RSL16) `RestChannel#object` attribute: + - (RSL16a) Returns the `RestObject` object for this channel (RSO1) + - (RSL16b) It is a programmer error to access this property without first + providing the `Objects` plugin (PC5) in the client options. This programmer + error should be handled in an idiomatic fashion; if this means accessing + the property should throw an error, then the error should be an `ErrorInfo` + with `statusCode` 400 and `code` 40019. +``` + +#### Plugin (amendment to PC5) + +``` +- (PC5) A plugin provided with the `PluginType` enum key value of `Objects` + should provide the RealtimeObjects feature functionality for realtime + channels (RTL27) and the RestObject feature functionality for REST + channels (RSL16). ... +``` + +### 3.2 Additions to objects-features.md + +--- + +### RestObject {#rest-object} + +- `(RSO1)` `RestObject` is associated with a single channel and is accessible through `RestChannel#object` + - `(RSO1a)` The `Objects` plugin ([PC5](../features#PC5)) must be provided in the client options for `RestObject` to be available + +#### RestObject#get + +- `(RSO2)` `RestObject#get` function: + - `(RSO2a)` Expects an optional `RestObjectGetParams` argument with the following attributes: + - `(RSO2a1)` `objectId` string (optional) — the object ID to fetch. If omitted, fetches the channel's root object + - `(RSO2a2)` `path` string (optional) — a dot-separated path to navigate within the target object. When used with `objectId`, the path is relative to that object; otherwise relative to the root + - `(RSO2a3)` `compact` boolean (optional, default `true`) — controls the response format. When `true`, returns a compact representation; when `false`, returns the full representation with object metadata + - `(RSO2b)` Makes an HTTP GET request to `/channels/{channelName}/object` if no `objectId` is provided, or to `/channels/{channelName}/object/{objectId}` if one is provided + - `(RSO2b1)` The `path` parameter, if provided, is included as a query parameter + - `(RSO2b2)` The `compact` parameter, if provided, is included as a query parameter + - `(RSO2c)` When `compact` is `true` (the default), returns a `RestObjectGetCompactResult`: + - `(RSO2c1)` A `LiveCounter` is represented as a `Number` + - `(RSO2c2)` A `LiveMap` is represented as a JSON object (`Dict`) whose keys are the non-tombstoned map entries. Each value is recursively compacted: `LiveCounter`s become numbers, nested `LiveMap`s become nested objects, and leaf values are returned as their native types (`String`, `Number`, `Boolean`) + - `(RSO2c3)` A `bytes` leaf value is returned as a base64-encoded string when using the JSON protocol, or as a `Binary` when using the binary (MessagePack) protocol + - `(RSO2c4)` A JSON-encoded leaf value (`JsonObject` or `JsonArray`) is returned as its JSON string representation (not parsed) + - `(RSO2c5)` A cyclic object reference is represented as `{ objectId: String }` to avoid infinite structures + - `(RSO2d)` When `compact` is `false`, returns a `RestObjectGetFullResult`: + - `(RSO2d1)` If the target resolves to a `LiveMap`, the result is a `RestLiveMap` containing: + - `(RSO2d1a)` `objectId` string — the object's ID. The root object has the ID `"root"` + - `(RSO2d1b)` `map` object containing: + - `(RSO2d1b1)` `semantics` `ObjectsMapSemantics` string — the map's conflict resolution semantics (e.g. `"lww"`) + - `(RSO2d1b2)` `entries` `Dict` — the map's non-tombstoned entries, where each entry contains a `data` field. If the entry's value is a leaf, `data` is a `RestObjectData`; if it references another object, `data` is a `RestLiveObject` (nested recursively) + - `(RSO2d2)` If the target resolves to a `LiveCounter`, the result is a `RestLiveCounter` containing: + - `(RSO2d2a)` `objectId` string — the object's ID + - `(RSO2d2b)` `counter` object containing: + - `(RSO2d2b1)` `data` object containing `number` `Number` — the counter's current value + - `(RSO2d3)` If the target resolves to a leaf value, the result is a `RestObjectData` object containing exactly one of the following fields: + - `(RSO2d3a)` `string` string + - `(RSO2d3b)` `number` number + - `(RSO2d3c)` `boolean` boolean + - `(RSO2d3d)` `bytes` `Binary` — decoded from base64 (JSON protocol) or received as binary (MessagePack protocol) + - `(RSO2d3e)` `json` `JsonObject | JsonArray` — decoded (parsed) from the JSON string representation + - `(RSO2e)` If the `objectId` does not exist, the library should indicate an error + - `(RSO2f)` If the `path` does not resolve, the library should indicate an error + +#### RestObject#publish + +- `(RSO3)` `RestObject#publish` function: + - `(RSO3a)` Expects a `RestObjectOperation` or an array of `RestObjectOperation` objects + - `(RSO3b)` Makes an HTTP POST request to `/channels/{channelName}/object` with the operation(s) encoded in the request body + - `(RSO3b1)` `ObjectData` values within the operations are encoded per [OD4](../features#OD4) before being sent to the server + - `(RSO3c)` On success, returns a `RestObjectPublishResult` containing: + - `(RSO3c1)` `messageId` string — the ID of the message containing the published operations + - `(RSO3c2)` `channel` string — the channel name + - `(RSO3c3)` `objectIds` array of string — the object IDs affected by the operation(s) + - `(RSO3d)` A `RestObjectOperation` targets an object either by `objectId` string or by `path` string (dot-separated notation), but not both: + - `(RSO3d1)` `objectId` string (optional) — the ID of the object to operate on + - `(RSO3d2)` `path` string (optional) — a dot-separated path relative to the root object. A `*` wildcard segment matches all entries of the parent map at that level + - `(RSO3d3)` For `mapCreate` and `counterCreate` operations, both `objectId` and `path` may be omitted (creates a standalone/orphaned object) + - `(RSO3d4)` `id` string (optional) — an idempotency key. If two operations with the same `id` are published, only the first is applied + - `(RSO3e)` A `RestObjectOperation` contains exactly one of the following operation-specific fields: + - `(RSO3e1)` `mapCreate` — creates a new `LiveMap`: + - `(RSO3e1a)` `semantics` `ObjectsMapSemantics` string — the conflict resolution semantics (e.g. `"lww"`) + - `(RSO3e1b)` `entries` `Dict` — the initial entries for the map. Each entry's `data` is a `PublishObjectData` + - `(RSO3e1c)` When a `path` is provided, the server creates the map and sets the terminal key on the parent map to reference the new map + - `(RSO3e1d)` When no `path` or `objectId` is provided, the map is created as a standalone object. Its `objectId` is returned in `RestObjectPublishResult.objectIds` + - `(RSO3e2)` `counterCreate` — creates a new `LiveCounter`: + - `(RSO3e2a)` `count` `Number` — the initial count value + - `(RSO3e2b)` When a `path` is provided, the server creates the counter and sets the terminal key on the parent map to reference the new counter + - `(RSO3e2c)` When no `path` or `objectId` is provided, the counter is created as a standalone object + - `(RSO3e3)` `mapSet` — sets a key on a map: + - `(RSO3e3a)` `key` string — the key to set + - `(RSO3e3b)` `value` `PublishObjectData` — the value to set. May be a primitive value or an `objectId` reference + - `(RSO3e4)` `mapRemove` — removes a key from a map: + - `(RSO3e4a)` `key` string — the key to remove + - `(RSO3e5)` `counterInc` — increments (or decrements) a counter: + - `(RSO3e5a)` `number` `Number` — the amount to increment by (negative to decrement) + - `(RSO3e6)` `mapCreateWithObjectId` — creates a map with a pre-computed object ID: + - `(RSO3e6a)` `initialValue` string — the JSON-encoded initial value (as returned by `generateObjectId`) + - `(RSO3e6b)` `nonce` string — the nonce used to generate the object ID (as returned by `generateObjectId`) + - `(RSO3e6c)` The operation must also provide `objectId` — the pre-computed ID + - `(RSO3e7)` `counterCreateWithObjectId` — creates a counter with a pre-computed object ID: + - `(RSO3e7a)` `initialValue` string — the JSON-encoded initial value (as returned by `generateObjectId`) + - `(RSO3e7b)` `nonce` string — the nonce used to generate the object ID (as returned by `generateObjectId`) + - `(RSO3e7c)` The operation must also provide `objectId` — the pre-computed ID + - `(RSO3f)` When an array of operations is provided, all operations are published atomically in a single request + +#### RestObject#generateObjectId + +- `(RSO4)` `RestObject#generateObjectId` function: + - `(RSO4a)` Expects a `RestObjectCreateBody` argument containing exactly one of: + - `(RSO4a1)` `mapCreate` — with `semantics` and `entries` as in [RSO3e1](#RSO3e1) + - `(RSO4a2)` `counterCreate` — with `count` as in [RSO3e2](#RSO3e2) + - `(RSO4b)` If neither `mapCreate` nor `counterCreate` is provided, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003 + - `(RSO4c)` Generates a deterministic object ID using the following procedure: + - `(RSO4c1)` Encodes the provided create body to a JSON string (the `initialValue`) + - `(RSO4c2)` Generates a unique random nonce string + - `(RSO4c3)` Obtains the current server time (as per [RTO16](../objects-features#RTO16)) + - `(RSO4c4)` Creates an `objectId` per [RTO14](../objects-features#RTO14), using `"map"` or `"counter"` as the type + - `(RSO4d)` Returns a `RestObjectGenerateIdResult` containing: + - `(RSO4d1)` `objectId` string — the generated object ID, matching the format `{type}:{base64url(SHA-256(initialValue:nonce))}@{msTimestamp}` + - `(RSO4d2)` `nonce` string — the random nonce used in generation + - `(RSO4d3)` `initialValue` string — the JSON-encoded initial value + - `(RSO4e)` Each call generates a different `objectId` and `nonce` for the same input, because the nonce is random + - `(RSO4f)` The `initialValue` is deterministic for the same input (same create body produces same `initialValue`) + - `(RSO4g)` The returned values can be used directly with `mapCreateWithObjectId` or `counterCreateWithObjectId` in a subsequent `publish` call + +#### PublishObjectData + +- `(RSO5)` `PublishObjectData` represents a user-provided value for publish operations. It is a discriminated type where exactly one field is set: + - `(RSO5a)` `string` string — a string value + - `(RSO5b)` `number` `Number` — a numeric value + - `(RSO5c)` `boolean` boolean — a boolean value + - `(RSO5d)` `bytes` `Binary` — a binary value + - `(RSO5e)` `json` `JsonObject | JsonArray` — a JSON-encodable value + - `(RSO5f)` `objectId` string — a reference to another object by its object ID + +--- + +### 3.3 Proposed IDL additions (for objects-features.md IDL section) + +``` +class RestObject: // RSO* + get(RestObjectGetParams?) => io (RestObjectGetCompactResult | RestObjectGetFullResult) // RSO2 + publish(RestObjectOperation | [RestObjectOperation]) => io RestObjectPublishResult // RSO3 + generateObjectId(RestObjectCreateBody) => io RestObjectGenerateIdResult // RSO4 + +interface RestObjectGetParams: // RSO2a + objectId: String? // RSO2a1 + path: String? // RSO2a2 + compact: Boolean? // RSO2a3, default true + +// RestObjectGetCompactResult is not a class — it is a recursive JSON value: +// Number (for counters), Dict (for maps), String, Number, Boolean, +// String|Binary (for bytes), String (for JSON). See RSO2c. + +interface RestLiveMap: // RSO2d1 + objectId: String // RSO2d1a + map: { semantics: ObjectsMapSemantics, entries: Dict } // RSO2d1b + +interface RestLiveCounter: // RSO2d2 + objectId: String // RSO2d2a + counter: { data: { number: Number } } // RSO2d2b + +// RestLiveObject = RestLiveMap | RestLiveCounter + +interface RestObjectData: // RSO2d3 + string: String? // RSO2d3a + number: Number? // RSO2d3b + boolean: Boolean? // RSO2d3c + bytes: Binary? // RSO2d3d + json: (JsonObject | JsonArray)? // RSO2d3e + +interface RestObjectOperation: // RSO3d, RSO3e + objectId: String? // RSO3d1 + path: String? // RSO3d2 + id: String? // RSO3d4 + mapCreate: { semantics: ObjectsMapSemantics, entries: Dict }? // RSO3e1 + counterCreate: { count: Number }? // RSO3e2 + mapSet: { key: String, value: PublishObjectData }? // RSO3e3 + mapRemove: { key: String }? // RSO3e4 + counterInc: { number: Number }? // RSO3e5 + mapCreateWithObjectId: { initialValue: String, nonce: String }? // RSO3e6 + counterCreateWithObjectId: { initialValue: String, nonce: String }? // RSO3e7 + +interface PublishObjectData: // RSO5 + string: String? // RSO5a + number: Number? // RSO5b + boolean: Boolean? // RSO5c + bytes: Binary? // RSO5d + json: (JsonObject | JsonArray)? // RSO5e + objectId: String? // RSO5f + +interface RestObjectPublishResult: // RSO3c + messageId: String // RSO3c1 + channel: String // RSO3c2 + objectIds: [String] // RSO3c3 + +interface RestObjectGenerateIdResult: // RSO4d + objectId: String // RSO4d1 + nonce: String // RSO4d2 + initialValue: String // RSO4d3 +``` + +--- + +## 4. Behavioral Assertions (derived from ably-js test suite) + +These are the testable assertions implied by the specification above, mapped to the test scenarios in `ably-js/test/rest/liveobjects.test.js`: + +### 4.1 Plugin requirement + +| Assertion | Spec | +|-----------|------| +| Accessing `channel.object` without the Objects plugin throws an error | RSL16b | +| Accessing `channel.object` with the Objects plugin returns a `RestObject` instance | RSL16a, RSO1 | + +### 4.2 RestObject#get — compact mode (default) + +| Assertion | Spec | +|-----------|------| +| Returns root object by default (no params) | RSO2a, RSO2b | +| Compact is the default format | RSO2a3 | +| Counters appear as numbers | RSO2c1 | +| Maps appear as plain objects | RSO2c2 | +| Empty maps appear as `{}` | RSO2c2 | +| Path parameter navigates to nested objects | RSO2a2 | +| All primitive data types returned correctly (string, number, boolean) | RSO2c2 | +| Bytes returned as base64 string (JSON) or binary (MessagePack) | RSO2c3 | +| JSON object values returned as JSON string (not parsed) | RSO2c4 | +| JSON array values returned as JSON string (not parsed) | RSO2c4 | + +### 4.3 RestObject#get — full mode + +| Assertion | Spec | +|-----------|------| +| Map result includes `objectId` and `map.semantics` = `"lww"` | RSO2d1a, RSO2d1b1 | +| Map result includes entries with typed data | RSO2d1b2 | +| Counter result includes `objectId` and `counter.data.number` | RSO2d2a, RSO2d2b1 | +| Root object has `objectId` = `"root"` | RSO2d1a | +| String leaf returns `{ string: ... }` | RSO2d3a | +| Number leaf returns `{ number: ... }` | RSO2d3b | +| Boolean leaf returns `{ boolean: ... }` | RSO2d3c | +| Bytes leaf returns `{ bytes: Binary }` (decoded) | RSO2d3d | +| JSON object leaf returns `{ json: parsed object }` | RSO2d3e | +| JSON array leaf returns `{ json: parsed array }` | RSO2d3e | + +### 4.4 RestObject#get — objectId parameter + +| Assertion | Spec | +|-----------|------| +| Fetch specific object by ID | RSO2a1, RSO2b | +| Combine objectId + path | RSO2a1, RSO2a2 | +| Non-existent objectId throws error | RSO2e | +| Non-existent path throws error | RSO2f | + +### 4.5 RestObject#publish — mapSet + +| Assertion | Spec | +|-----------|------| +| mapSet via objectId with all data types (string, number, boolean, bytes, json) | RSO3e3, RSO5 | +| mapSet via path | RSO3d2, RSO3e3 | +| mapSet via wildcard path (`*`) sets key on all children | RSO3d2, RSO3e3 | +| mapSet with objectId reference | RSO5f | +| Publish result contains messageId, channel, objectIds | RSO3c | + +### 4.6 RestObject#publish — mapRemove + +| Assertion | Spec | +|-----------|------| +| mapRemove via objectId | RSO3d1, RSO3e4 | +| mapRemove via path | RSO3d2, RSO3e4 | +| mapRemove via wildcard path | RSO3d2, RSO3e4 | +| Removed key no longer present in subsequent get | RSO3e4 | + +### 4.7 RestObject#publish — counterInc + +| Assertion | Spec | +|-----------|------| +| counterInc via objectId | RSO3d1, RSO3e5 | +| counterInc via path | RSO3d2, RSO3e5 | +| counterInc via wildcard path | RSO3d2, RSO3e5 | +| Counter value reflects increment | RSO3e5 | + +### 4.8 RestObject#publish — mapCreate + +| Assertion | Spec | +|-----------|------| +| mapCreate without path creates standalone object | RSO3e1d | +| mapCreate with path creates map and links to parent | RSO3e1c | +| mapCreate with all data types in entries | RSO3e1b, RSO5 | +| mapCreate with objectId reference entries | RSO5f | +| Created object retrievable by returned objectId | RSO3c3 | + +### 4.9 RestObject#publish — counterCreate + +| Assertion | Spec | +|-----------|------| +| counterCreate without path creates standalone counter | RSO3e2c | +| counterCreate with path creates counter and links to parent | RSO3e2b | + +### 4.10 RestObject#publish — pre-computed IDs + +| Assertion | Spec | +|-----------|------| +| mapCreateWithObjectId creates map with specified ID | RSO3e6 | +| counterCreateWithObjectId creates counter with specified ID | RSO3e7 | +| Returned objectIds array contains the pre-computed ID | RSO3c3, RSO3e6c | + +### 4.11 RestObject#publish — idempotency + +| Assertion | Spec | +|-----------|------| +| Duplicate publish with same `id` applied only once | RSO3d4 | + +### 4.12 RestObject#generateObjectId + +| Assertion | Spec | +|-----------|------| +| mapCreate body returns objectId matching `/^map:/` | RSO4d1 | +| counterCreate body returns objectId matching `/^counter:/` | RSO4d1 | +| Returns nonce (string) | RSO4d2 | +| Returns initialValue (valid JSON string) | RSO4d3 | +| Different calls for same payload produce different objectId and nonce | RSO4e | +| Same payload produces same initialValue | RSO4f | +| Missing mapCreate and counterCreate throws error (code 40003, statusCode 400) | RSO4b | +| Generated map ID works with mapCreateWithObjectId in publish | RSO4g | +| Generated counter ID works with counterCreateWithObjectId in publish | RSO4g | + +--- + +## 5. Open Questions + +1. **`RestObjectData` vs `ObjectData`**: The full-mode GET response returns decoded data (`bytes` as `Binary`, `json` as parsed object), which differs from the wire-format `ObjectData` ([OD2](../features#OD2)) where `bytes` is base64 and JSON is stored in `string` with `encoding: "json"`. Should `RestObjectData` be defined as "the result of decoding an `ObjectData` per [OD5](../features#OD5)"? Or is it a distinct type? + +2. **Wildcard path semantics**: The `*` wildcard in paths is a server-side feature. The spec should clarify whether the SDK validates path syntax or passes it through to the server opaquely. + +3. **Batch atomicity**: The tests imply that an array of operations is sent in a single HTTP request. Should the spec state whether the server applies them atomically (all-or-nothing), or is this a server-side concern outside the SDK spec? + +4. **Error codes**: The ably-js tests only explicitly check one error code (40003 for missing create body in `generateObjectId`). The spec should define error codes for other failure cases (non-existent objectId, non-existent path) — or state that these are server-defined errors passed through by the SDK. + +5. **Channel modes**: The realtime Objects API requires `OBJECT_SUBSCRIBE` and `OBJECT_PUBLISH` channel modes. Do these apply to the REST API, or are REST operations authorized purely via API key/token capabilities? + +6. **Relationship to `RealtimeObjects`**: The realtime `createMap`/`createCounter` operations ([RTO11](../objects-features#RTO11), [RTO12](../objects-features#RTO12)) involve client-side ID generation and `publishAndApply`. The REST `mapCreate`/`counterCreate` operations delegate ID generation to the server (unless using `*WithObjectId`). Should the spec explicitly call out this difference? + +7. **Discriminated unions and cross-language portability**: The proposed `RestObjectOperation` type is modeled as a discriminated union — a single type with optional `objectId`/`path`/`id` targeting fields and exactly one operation-specific field (`mapCreate`, `mapSet`, `counterInc`, etc.) set at a time. This mirrors the ably-js TypeScript types and the wire format directly. TypeScript models this naturally. Most other target languages (Dart, Java, Go, Python) do not have discriminated unions. + + The existing realtime spec avoids this problem entirely — `LiveMap#set()`, `LiveMap#remove()`, `LiveCounter#increment()`, `RealtimeObjects#createMap()` are all separate methods on separate classes. There is no "pass a polymorphic operation" pattern in the realtime public API. But the REST API needs one because `publish` supports **batch operations of mixed types** in a single request. + + **Option A — Keep the "flat" discriminated type** (as proposed). Each language adapts idiomatically: Dart uses sealed classes, Java uses a class hierarchy or builder, Go uses an interface. The spec describes the conceptual shape and leaves representation to implementations. This matches the existing `ObjectData` (OD2) pattern ("exactly one field set"), which every SDK already handles for the wire protocol. + + **Option B — Specify a class hierarchy.** An abstract `RestObjectOperation` base with concrete subtypes: + + ``` + class RestObjectOperation: // abstract base + objectId: String? // targeting + path: String? + id: String? // idempotency key + + class RestMapCreateOp extends RestObjectOperation: + semantics: ObjectsMapSemantics + entries: Dict + + class RestMapSetOp extends RestObjectOperation: + key: String + value: PublishObjectData + + class RestMapRemoveOp extends RestObjectOperation: + key: String + + class RestCounterCreateOp extends RestObjectOperation: + count: Number + + class RestCounterIncOp extends RestObjectOperation: + number: Number + + class RestMapCreateWithIdOp extends RestObjectOperation: + initialValue: String + nonce: String + + class RestCounterCreateWithIdOp extends RestObjectOperation: + initialValue: String + nonce: String + ``` + + Then `publish([RestObjectOperation])` works in every language — you pass an array of the base type, each element constructed as the appropriate subclass. This also parallels `ObjectOperation` (OOP3) which has an `action` enum and conditional fields — the class hierarchy is just a cleaner articulation of the same concept for a public API type. + + **Option B also affects response types.** `RestObjectGetFullResult` being `RestLiveMap | RestLiveCounter | RestObjectData` has the same issue in milder form — solvable with a common base type or a wrapper with a type discriminator field. + + The same concern applies more mildly to `PublishObjectData` (RSO5) — "exactly one of 6 fields" — though this matches the existing `ObjectData` (OD2) pattern that every SDK already handles. + + **Recommendation:** Option B (class hierarchy) for `RestObjectOperation` because it is user-constructed and the variants have structurally different shapes. Keep the "one-of" pattern for `PublishObjectData` and `RestObjectData` because they are leaf value containers matching the existing `ObjectData` precedent. diff --git a/submodules/ably-common b/submodules/ably-common new file mode 160000 index 000000000..bf29083e8 --- /dev/null +++ b/submodules/ably-common @@ -0,0 +1 @@ +Subproject commit bf29083e89aa2b8125830371af6e2eac46c15e10 diff --git a/uts/README.md b/uts/README.md new file mode 100644 index 000000000..50440d3f7 --- /dev/null +++ b/uts/README.md @@ -0,0 +1,104 @@ +# Universal Test Specifications (UTS) + +Portable test specifications for Ably client library implementations. Each spec defines what to test in language-neutral pseudocode, which is then translated into runnable tests for each SDK. + +## Directory Structure + +``` +uts/ +├── rest/ +│ ├── unit/ # REST unit tests (mocked HTTP) +│ │ ├── helpers/ +│ │ │ └── mock_http.md # Mock HTTP infrastructure spec +│ │ ├── auth/ # RSA — authentication +│ │ ├── channel/ # RSL — channel operations +│ │ ├── encoding/ # RSL4/RSL6 — message encoding +│ │ ├── presence/ # RSP — REST presence +│ │ ├── push/ # RSH — push admin +│ │ └── types/ # T* — type definitions +│ └── integration/ # REST integration tests (Ably sandbox) +├── realtime/ +│ ├── unit/ # Realtime unit tests (mocked WebSocket) +│ │ ├── helpers/ +│ │ │ ├── mock_websocket.md # Mock WebSocket infrastructure spec +│ │ │ └── mock_vcdiff.md # Mock VCDiff decoder spec +│ │ ├── auth/ # RSA/RTC8 — realtime auth +│ │ ├── channels/ # RTL/RTS — channels and messages +│ │ ├── client/ # RTC — realtime client +│ │ ├── connection/ # RTN — connection management +│ │ └── presence/ # RTP — realtime presence +│ └── integration/ # Realtime integration tests +│ ├── helpers/ +│ │ └── proxy.md # Proxy infrastructure spec +│ ├── proxy/ # Proxy-based fault injection tests +│ └── *.md # Direct sandbox tests +├── docs/ # Guides and reference +│ ├── writing-test-specs.md # How to write UTS specs +│ ├── writing-derived-tests.md # How to translate specs into SDK tests +│ ├── integration-testing.md # Integration testing policy +│ └── completion-status.md # Spec coverage matrix +└── README.md # This file +``` + +## Spec File Counts + +| Category | Count | Description | +|----------|-------|-------------| +| REST unit | 40 | Mocked HTTP client tests | +| REST integration | 11 | Ably sandbox tests | +| Realtime unit | 54 | Mocked WebSocket tests | +| Realtime integration (direct) | 13 | Direct sandbox tests | +| Realtime integration (proxy) | 7 | Fault injection via Go proxy | +| Helper specs | 4 | Mock infrastructure definitions | +| **Total** | **129** | | + +## Three Test Tiers + +**Unit tests** use mocked transports (MockHttpClient, MockWebSocket) to verify client-side logic: state machines, request formation, response parsing, timer behaviour, error handling. They are fast and deterministic. + +**Integration tests** (direct) run against the Ably sandbox to verify that the SDK interoperates correctly with the real service. No fault injection — these test happy-path behaviour and real error responses. + +**Integration tests** (proxy) run against the Ably sandbox through a programmable Go proxy that can inject faults: connection drops, suppressed frames, replaced responses, HTTP error injection. These test behaviour that can't be verified without controlling the network path. + +## Pseudocode Conventions + +Test specs use a consistent pseudocode syntax: + +```pseudo +# Setup +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {"result": "ok"}) +) +install_mock(mock_http) +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# Test +result = AWAIT client.operation() + +# Assertions +ASSERT result.field == "ok" +ASSERT request.url.path == "/channels/" + encode_uri_component(name) + "/messages" + +# Error testing +AWAIT client.badOperation() FAILS WITH error +ASSERT error.code == 40160 + +# State transitions +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +See [docs/writing-test-specs.md](docs/writing-test-specs.md) for the full pseudocode reference, mock patterns, and conventions. + +## Guides + +- **[Writing Test Specs](docs/writing-test-specs.md)** — How to author UTS specs: mock patterns, pseudocode conventions, proxy test structure, common mistakes +- **[Writing Derived Tests](docs/writing-derived-tests.md)** — How to translate UTS specs into SDK-specific tests, diagnose failures, and record deviations +- **[Integration Testing Policy](docs/integration-testing.md)** — When to write integration vs unit tests, proxy test design principles, test structure conventions +- **[Completion Status](docs/completion-status.md)** — Coverage matrix tracking which spec items have UTS test specs + +## Go Test Proxy + +The programmable proxy for integration testing lives in a separate repository: [ably/uts-proxy](https://github.com/ably/uts-proxy). It sits between the SDK and the Ably sandbox, transparently forwarding traffic while allowing rule-based fault injection. + +See `realtime/integration/helpers/proxy.md` for the proxy infrastructure specification used by test specs in this repository. diff --git a/uts/docs/completion-status.md b/uts/docs/completion-status.md new file mode 100644 index 000000000..da149a36e --- /dev/null +++ b/uts/docs/completion-status.md @@ -0,0 +1,457 @@ +# UTS Test Spec Completion Status + +This matrix lists all spec items from the [Ably features spec](../../specifications/features.md) and indicates which have a UTS test specification. + +**Legend:** +- **Yes** — UTS test spec exists covering this item +- **Partial** — some sub-items covered, others not +- *blank* — no UTS test spec exists + +--- + +## Specification and Protocol Versions + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| CSV1–CSV2 | Specification & protocol versions | Information only | + +## Client Library Endpoint Configuration + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| REC1 | Primary domain determination (REC1a–REC1d2) | Yes — `rest/unit/fallback.md` | +| REC2 | Fallback domains determination (REC2a–REC2c6) | Yes — `rest/unit/fallback.md` | +| REC3 | Connectivity check URL (REC3a–REC3b) | Yes — `rest/unit/fallback.md` | + +--- + +## REST Client Library + +### RestClient + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RSC1 | Constructor options (RSC1a–RSC1c) | Yes — `realtime/unit/client/realtime_client.md` | +| RSC2 | Logger default | Yes — `rest/unit/logging.md` | +| RSC3 | Log level configuration | Yes — `rest/unit/logging.md` | +| RSC4 | Custom logger | Yes — `rest/unit/logging.md` | +| RSC5 | Auth object attribute | Yes — `rest/unit/rest_client.md` | +| RSC6 | Stats function (RSC6a–RSC6b4) | Yes — `rest/unit/stats.md`, `rest/integration/time_stats.md` | +| RSC7 | HTTP request headers (RSC7a–RSC7d7) | Yes — `rest/unit/rest_client.md` | +| RSC8 | Protocol support (RSC8a–RSC8e2) | Yes — `rest/unit/rest_client.md` | +| RSC9 | Auth usage for authentication | Information only | +| RSC10 | Token error retry handling | Yes — `rest/unit/auth/token_renewal.md`, `rest/integration/auth.md` | +| RSC13 | Connection and request timeouts | Yes — `rest/unit/rest_client.md` | +| RSC15 | Host fallback behaviour (RSC15a–RSC15n) | Yes — `rest/unit/fallback.md`, `rest/integration/proxy/rest_fallback.md` | +| RSC16 | Time function | Yes — `rest/unit/time.md`, `rest/integration/time_stats.md` | +| RSC17 | ClientId attribute | Yes — `rest/unit/rest_client.md` | +| RSC18 | TLS configuration | Yes — `rest/unit/rest_client.md`, `rest/unit/time.md` | +| RSC19 | Request function (RSC19a–RSC19f1) | Yes — `rest/unit/request.md` | +| RSC20 | Deprecated exception reporting (RSC20a–RSC20f) |N/A | +| RSC21 | Push object attribute | Yes — `rest/unit/push/push_admin_publish.md` (RSH1 type assertions) | +| RSC22 | BatchPublish (RSC22a–RSC22d) | Yes — `rest/unit/batch_publish.md` | +| RSC23 | Deleted | N/A | +| RSC24 | BatchPresence | Yes — `rest/unit/batch_presence.md`, `rest/integration/batch_presence.md` | +| RSC25 | Request endpoint | Yes — `rest/unit/request_endpoint.md` | +| RSC26 | CreateWrapperSDKProxy (RSC26a–RSC26c) | | + +### Auth + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RSA1 | Basic Auth requires HTTPS | Yes — `rest/unit/auth/auth_scheme.md` | +| RSA2 | Basic Auth default | Yes — `rest/unit/auth/auth_scheme.md` | +| RSA3 | Token Auth support (RSA3a–RSA3d) | Yes — `rest/unit/auth/auth_scheme.md` | +| RSA4 | Token Auth selection logic (RSA4a–RSA4g) | Yes — `rest/unit/auth/auth_scheme.md` covers RSA4, RSA4b; `rest/unit/auth/token_renewal.md` covers RSA4b4; `realtime/unit/auth/connection_auth_test.md` covers RSA4; `realtime/unit/connection/error_reason_test.md` covers RSA4c1, RSA4d; `realtime/unit/auth/token_expiry_non_renewable_test.md` covers RSA4a, RSA4a1, RSA4a2; `realtime/unit/auth/auth_callback_errors_test.md` covers RSA4c, RSA4c1–RSA4c3, RSA4d, RSA4e, RSA4f; `realtime/integration/auth/token_renewal_test.md` covers RSA4b | +| RSA5 | TTL for tokens | Yes — `rest/unit/auth/token_request_params.md`, `rest/integration/auth.md` | +| RSA6 | Capability JSON | Yes — `rest/unit/auth/token_request_params.md`, `rest/integration/auth.md` | +| RSA7 | ClientId and authenticated clients (RSA7a–RSA7e2) | Partial — `rest/unit/auth/client_id.md` covers RSA7, RSA7a–RSA7c; `realtime/integration/auth.md` covers RSA7 | +| RSA8 | RequestToken function (RSA8a–RSA8g) | Partial — `rest/unit/auth/auth_callback.md` covers RSA8c, RSA8d; `realtime/unit/auth/connection_auth_test.md` covers RSA8d; `rest/integration/auth.md` covers RSA8; `realtime/integration/auth.md` covers RSA8 | +| RSA9 | CreateTokenRequest (RSA9a–RSA9i) | Partial — `rest/integration/auth.md` covers RSA9; `realtime/integration/auth/token_request_test.md` covers RSA9a, RSA9g | +| RSA10 | Authorize function (RSA10a–RSA10l) | Yes — `rest/unit/auth/authorize.md` | +| RSA11 | Base64 encoded API key | Yes — `rest/unit/auth/auth_scheme.md` (with RSA2) | +| RSA12 | Auth#clientId attribute (RSA12a–RSA12b) | Yes — `rest/unit/auth/client_id.md` | +| RSA14 | Error when token auth selected without token | Yes — `rest/unit/auth/token_renewal.md`, `rest/integration/auth.md` | +| RSA15 | ClientId validation (RSA15a–RSA15c) | Yes — `rest/unit/auth/client_id.md`, `realtime/integration/auth.md` (RSA15c Realtime case) | +| RSA16 | TokenDetails attribute (RSA16a–RSA16d) | Yes — `rest/unit/auth/token_details.md` | +| RSA17 | RevokeTokens (RSA17a–RSA17g) | Yes — `rest/unit/auth/revoke_tokens.md`, `rest/integration/revoke_tokens.md` | + +### Channels (REST) + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RSN1–RSN4 | REST channels collection (RSN1–RSN4c) | Yes — `rest/unit/channels_collection.md` | + +### RestChannel + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RSL1 | Publish function (RSL1a–RSL1n1) | Yes — `rest/unit/channel/publish.md`, `rest/unit/channel/publish_result.md`, `rest/integration/publish.md`, `rest/integration/mutable_messages.md` | +| RSL1k | Idempotent publishing (RSL1k1–RSL1k5) | Yes — `rest/unit/channel/idempotency.md`, `rest/integration/proxy/rest_fallback.md` (RSL1k4 pending proxy enhancement) | +| RSL2 | History function (RSL2a–RSL2b3) | Yes — `rest/unit/channel/history.md`, `rest/integration/history.md` | +| RSL3 | Presence attribute | Yes — `rest/unit/presence/rest_presence.md` (with RSP1a) | +| RSL4 | Message encoding (RSL4a–RSL4d4) | Yes — `rest/unit/encoding/message_encoding.md`, `realtime/integration/channels/channel_publish_test.md` | +| RSL5 | Message encryption (RSL5a–RSL5c) | | +| RSL6 | Message decoding (RSL6a–RSL6b) | Yes — `rest/unit/encoding/message_encoding.md`, `realtime/integration/channels/channel_publish_test.md` covers RSL6, RSL6a2 | +| RSL7 | SetOptions function | Yes — `rest/unit/channel/rest_channel_attributes.md` | +| RSL8 | Status function (RSL8a) | Yes — `rest/unit/channel/rest_channel_attributes.md` | +| RSL9 | Name attribute | Yes — `rest/unit/channel/rest_channel_attributes.md` | +| RSL10 | Annotations attribute | Yes — `rest/unit/channel/annotations.md` | +| RSL11 | GetMessage function (RSL11a–RSL11c) | Yes — `rest/unit/channel/get_message.md`, `rest/integration/mutable_messages.md` | +| RSL14 | GetMessageVersions (RSL14a–RSL14c) | Yes — `rest/unit/channel/message_versions.md`, `rest/integration/mutable_messages.md` | +| RSL15 | UpdateMessage/DeleteMessage/AppendMessage (RSL15a–RSL15f) | Yes — `rest/unit/channel/update_delete_message.md`, `rest/integration/mutable_messages.md` | + +### Plugins + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| PC1–PC5 | Plugin architecture, VCDiff, Objects | Partial — `realtime/unit/channels/channel_delta_decoding.md` covers PC3, PC3a; `realtime/integration/delta_decoding_test.md` covers PC3 | +| PT1–PT2 | PluginType enum | | +| VD1–VD2 | VCDiffDecoder | Partial — `realtime/unit/helpers/mock_vcdiff.md` references VD2a | + +### RestPresence + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RSP1 | Associated with single channel | Yes — `rest/unit/presence/rest_presence.md`, `rest/integration/presence.md` | +| RSP2 | No presence registration via REST | Information only | +| RSP3 | Get function (RSP3a–RSP3a3) | Yes — `rest/unit/presence/rest_presence.md`, `rest/integration/presence.md` | +| RSP4 | History function (RSP4a–RSP4b3) | Yes — `rest/unit/presence/rest_presence.md`, `rest/integration/presence.md` | +| RSP5 | Presence message decoding | Yes — `rest/unit/presence/rest_presence.md`, `rest/integration/presence.md` | + +### Encryption + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RSE1 | Crypto::getDefaultParams (RSE1a–RSE1e) | | +| RSE2 | Crypto::generateRandomKey (RSE2a–RSE2b) | | + +### RestAnnotations + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RSAN1–RSAN3 | Annotations publish/delete/get | Yes — `rest/unit/channel/annotations.md`, `rest/integration/mutable_messages.md` | + +### Forwards Compatibility (REST) + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RSF1 | Robustness principle | Yes — `realtime/unit/connection/forwards_compatibility_test.md` | + +--- + +## Realtime Client Library + +### RealtimeClient + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RTC1 | ClientOptions (RTC1a–RTC1f1) | Yes — `realtime/unit/client/realtime_client.md` | +| RTC2 | Connection object attribute | Yes — `realtime/unit/client/realtime_client.md` | +| RTC3 | Channels object attribute | Yes — `realtime/unit/client/realtime_client.md` | +| RTC4 | Auth object attribute (RTC4a) | Yes — `realtime/unit/client/realtime_client.md` | +| RTC5 | Stats function (RTC5a–RTC5b) | Yes — `realtime/unit/client/realtime_stats.md` (proxies to RSC6 tests) | +| RTC6 | Time function (RTC6a) | Yes — `realtime/unit/client/realtime_time.md` (proxies to RSC16 tests) | +| RTC7 | Uses configured timeouts | Yes — `realtime/unit/client/realtime_timeouts.md` | +| RTC8 | Authorize function for realtime (RTC8a–RTC8c) | Yes — `realtime/unit/auth/realtime_authorize.md`, `realtime/integration/auth.md`; `realtime/integration/proxy/auth_reauth.md` covers RTC8a | +| RTC9 | Request function | Yes — `realtime/unit/client/realtime_request.md` (proxies to RSC19 tests) | +| RTC10–RTC11 | Deleted | N/A | +| RTC12 | Same constructors as RestClient | Yes — `realtime/unit/client/realtime_client.md` | +| RTC13 | Push object attribute | Yes — `realtime/unit/client/realtime_client.md` | +| RTC14 | CreateWrapperSDKProxy (RTC14a–RTC14c) | | +| RTC15 | Connect function (RTC15a) | Yes — `realtime/unit/client/realtime_client.md` | +| RTC16 | Close function (RTC16a) | Yes — `realtime/unit/client/realtime_client.md` | +| RTC17 | ClientId attribute (RTC17a) | Yes — `realtime/unit/client/realtime_client.md` | + +### Connection + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RTN1 | Uses websocket connection | Information only | +| RTN2 | Default host and query string params (RTN2a–RTN2g) | Partial — `realtime/unit/auth/connection_auth_test.md` covers RTN2e | +| RTN3 | AutoConnect option | Yes — `realtime/unit/connection/auto_connect_test.md` | +| RTN4 | Connection event emission (RTN4a–RTN4i) | Partial — `realtime/integration/connection_lifecycle_test.md` covers RTN4b, RTN4c; `realtime/unit/connection/update_events_test.md` covers RTN4h | +| RTN5 | Concurrency test (50+ clients) | | +| RTN6 | Successful connection definition | Information only| +| RTN7 | ACK and NACK handling (RTN7a–RTN7e) | Yes — `realtime/unit/channels/channel_publish.md` covers RTN7a, RTN7b (via RTL6j tests), RTN7d, RTN7e | +| RTN8 | Connection#id attribute (RTN8a–RTN8c) | Yes — `realtime/unit/connection/connection_id_key_test.md` | +| RTN9 | Connection#key attribute (RTN9a–RTN9c) | Yes — `realtime/unit/connection/connection_id_key_test.md` | +| RTN11 | Connect function (RTN11a–RTN11f) | Partial — `realtime/integration/connection_lifecycle_test.md` covers RTN11; `realtime/unit/connection/error_reason_test.md` covers RTN11d | +| RTN12 | Close function (RTN12a–RTN12f) | Partial — `realtime/integration/connection_lifecycle_test.md` covers RTN12, RTN12a | +| RTN13 | Ping function (RTN13a–RTN13e) | Yes — `realtime/unit/connection/connection_ping_test.md` | +| RTN14 | Connection opening failures (RTN14a–RTN14g) | Yes — `realtime/unit/connection/connection_open_failures_test.md`; `realtime/integration/connection/connection_failures_test.md` covers RTN14a, RTN14g; `realtime/integration/auth/token_renewal_test.md` covers RTN14b; `realtime/integration/proxy/connection_open_failures.md` covers RTN14a–RTN14d, RTN14g | +| RTN15 | Connection failures when CONNECTED (RTN15a–RTN15j) | Yes — `realtime/unit/connection/connection_failures_test.md`; `realtime/integration/proxy/connection_resume.md` covers RTN15a, RTN15b, RTN15c6, RTN15c7, RTN15g, RTN15g2, RTN15h1, RTN15h3, RTN15j | +| RTN16 | Connection recovery (RTN16a–RTN16m1) | Yes — `realtime/unit/connection/connection_recovery_test.md` covers RTN16d, RTN16f, RTN16f1, RTN16g, RTN16g1, RTN16g2, RTN16i, RTN16j, RTN16k, RTN16l; `realtime/integration/proxy/connection_resume.md` covers RTN16d, RTN16l; `realtime/unit/connection/error_reason_test.md` covers RTN16e | +| RTN17 | Domain selection and fallback (RTN17a–RTN17j) | Yes — `realtime/unit/connection/fallback_hosts_test.md` | +| RTN19 | Transport state side effects (RTN19a–RTN19b) | Yes — `realtime/unit/channels/channel_publish.md` covers RTN19a, RTN19a2, RTN19b; `realtime/integration/proxy/connection_resume.md` covers RTN19a, RTN19a2 | +| RTN20 | OS network change handling (RTN20a–RTN20c) | Yes — `realtime/unit/connection/network_change_test.md` | +| RTN21 | ConnectionDetails override defaults | Partial — `realtime/unit/connection/update_events_test.md` covers RTN21; `realtime/integration/connection_lifecycle_test.md` covers RTN21 | +| RTN22 | Re-authentication request handling (RTN22a) | Yes — `realtime/unit/connection/server_initiated_reauth_test.md`; `realtime/integration/proxy/auth_reauth.md` covers RTN22 | +| RTN23 | Heartbeats (RTN23a–RTN23b) | Yes — `realtime/unit/connection/heartbeat_test.md` | +| RTN24 | UPDATE event on CONNECTED while connected | Yes — `realtime/unit/connection/update_events_test.md` | +| RTN25 | Connection#errorReason attribute | Yes — `realtime/unit/connection/error_reason_test.md` | +| RTN26 | Connection#whenState function (RTN26a–RTN26b) | Yes — `realtime/unit/connection/when_state_test.md` | +| RTN27 | Connection state machine (RTN27a–RTN27h) | Partial — `realtime/unit/auth/connection_auth_test.md` covers RTN27b | + +### Channels (Realtime) + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RTS1 | Channels collection accessible via RealtimeClient | Yes — `realtime/unit/channels/channels_collection.md` | +| RTS2 | Methods to check existence and iterate | Yes — `realtime/unit/channels/channels_collection.md` | +| RTS3 | Get function (RTS3a–RTS3c1) | Yes — `realtime/unit/channels/channels_collection.md` (RTS3a), `realtime/unit/channels/channel_options.md` (RTS3b, RTS3c, RTS3c1) | +| RTS4 | Release function (RTS4a) | Yes — `realtime/unit/channels/channels_collection.md` | +| RTS5 | GetDerived function (RTS5a–RTS5a2) | Yes — `realtime/unit/channels/channel_options.md` | + +### RealtimeChannel + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RTL1 | Message and presence processing | Information only | +| RTL2 | Channel event emission (RTL2a–RTL2i) | Yes — `realtime/unit/channels/channel_state_events.md` | +| RTL3 | Connection state side effects (RTL3a–RTL3e) | Yes — `realtime/unit/channels/channel_connection_state.md`; `realtime/integration/proxy/channel_faults.md` covers RTL3d | +| RTL4 | Attach function (RTL4a–RTL4m) | Yes — `realtime/unit/channels/channel_attach.md`; `realtime/integration/channels/channel_attach_test.md` covers RTL4, RTL4c; `realtime/integration/proxy/channel_faults.md` covers RTL4f | +| RTL5 | Detach function (RTL5a–RTL5l) | Yes — `realtime/unit/channels/channel_detach.md`; `realtime/integration/channels/channel_attach_test.md` covers RTL5, RTL5d; `realtime/integration/proxy/channel_faults.md` covers RTL5f | +| RTL6 | Publish function (RTL6a–RTL6k) | Yes — `realtime/unit/channels/channel_publish.md`; `realtime/integration/channels/channel_publish_test.md` covers RTL6, RTL6f | +| RTL7 | Subscribe function (RTL7a–RTL7h) | Yes — `realtime/unit/channels/channel_subscribe.md`; `realtime/integration/channels/channel_subscribe_test.md` covers RTL7, RTL7a, RTL7b, RTL7d | +| RTL8 | Unsubscribe function (RTL8a–RTL8c) | Yes — `realtime/unit/channels/channel_subscribe.md` | +| RTL9 | Presence attribute (RTL9a) | Yes — `realtime/unit/presence/realtime_presence_channel_state.md` | +| RTL10 | History function (RTL10a–RTL10d) | Yes — `realtime/unit/channels/channel_history.md` covers RTL10a, RTL10b, RTL10c (proxies to RSL2 tests); `realtime/integration/channel_history_test.md` covers RTL10d | +| RTL11 | Channel state effect on presence (RTL11a) | Yes — `realtime/unit/presence/realtime_presence_channel_state.md` | +| RTL12 | Additional ATTACHED message handling | Yes — `realtime/unit/channels/channel_additional_attached.md`; `realtime/integration/proxy/channel_faults.md` covers RTL12 | +| RTL13 | Server-initiated DETACHED handling (RTL13a–RTL13c) | Yes — `realtime/unit/channels/channel_server_initiated_detach.md` | +| RTL14 | ERROR message handling | Yes — `realtime/unit/channels/channel_error.md`; `realtime/integration/channels/channel_attach_test.md` covers RTL14; `realtime/integration/proxy/channel_faults.md` covers RTL14 | +| RTL15 | Channel#properties attribute (RTL15a–RTL15b1) | Yes — `realtime/unit/channels/channel_properties.md` | +| RTL16 | SetOptions function (RTL16a) | Yes — `realtime/unit/channels/channel_options.md` | +| RTL17 | No messages outside ATTACHED state | Yes — `realtime/unit/channels/channel_subscribe.md` | +| RTL18 | Vcdiff decoding failure recovery (RTL18a–RTL18c) | Yes — `realtime/unit/channels/channel_delta_decoding.md`, `realtime/integration/delta_decoding_test.md` | +| RTL19 | Base payload storage for vcdiff (RTL19a–RTL19c) | Yes — `realtime/unit/channels/channel_delta_decoding.md`, `realtime/integration/delta_decoding_test.md` | +| RTL20 | Last message ID storage | Yes — `realtime/unit/channels/channel_delta_decoding.md`, `realtime/integration/delta_decoding_test.md` | +| RTL21 | Message ordering in arrays | Yes — `realtime/unit/channels/channel_delta_decoding.md` | +| RTL22 | Message filtering (RTL22a–RTL22d) | Yes — `realtime/unit/channels/channel_subscribe.md` | +| RTL23 | Name attribute | Yes — `realtime/unit/channels/channel_attributes.md` | +| RTL24 | ErrorReason attribute | Yes — `realtime/unit/channels/channel_attributes.md` | +| RTL25 | WhenState function (RTL25a–RTL25b) | Yes — `realtime/unit/channels/channel_when_state_test.md` | +| RTL26 | Annotations attribute | Yes — `realtime/unit/channels/channel_annotations.md` | +| RTL27 | Objects attribute (RTL27a–RTL27b) | | +| RTL28 | GetMessage function | Yes — `realtime/unit/channels/channel_get_message.md` (proxies to RSL11 tests), `realtime/integration/mutable_messages_test.md` | +| RTL31 | GetMessageVersions function | Yes — `realtime/unit/channels/channel_message_versions.md` (proxies to RSL14 tests), `realtime/integration/mutable_messages_test.md` | +| RTL32 | UpdateMessage/DeleteMessage/AppendMessage (RTL32a–RTL32e) | Yes — `realtime/unit/channels/channel_update_delete_message.md`, `realtime/integration/mutable_messages_test.md` | + +### RealtimePresence + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RTP1 | HAS_PRESENCE flag and SYNC | Yes — `realtime/unit/presence/realtime_presence_channel_state.md` | +| RTP2 | PresenceMap maintenance (RTP2a–RTP2h2) | Yes — `realtime/unit/presence/presence_map.md`; `realtime/integration/presence/presence_sync_test.md` covers RTP2 | +| RTP4 | Large member count test | Yes — `realtime/unit/presence/realtime_presence_enter.md`, `realtime/integration/presence_lifecycle_test.md` | +| RTP5 | Channel state side effects (RTP5a–RTP5f) | Yes — `realtime/unit/presence/realtime_presence_channel_state.md` | +| RTP6 | Subscribe function (RTP6a–RTP6e) | Yes — `realtime/unit/presence/realtime_presence_subscribe.md`, `realtime/integration/presence_lifecycle_test.md` | +| RTP7 | Unsubscribe function (RTP7a–RTP7c) | Yes — `realtime/unit/presence/realtime_presence_subscribe.md` | +| RTP8 | Enter function (RTP8a–RTP8j) | Yes — `realtime/unit/presence/realtime_presence_enter.md`, `realtime/integration/presence_lifecycle_test.md` | +| RTP9 | Update function (RTP9a–RTP9e) | Yes — `realtime/unit/presence/realtime_presence_enter.md`, `realtime/integration/presence_lifecycle_test.md` | +| RTP10 | Leave function (RTP10a–RTP10e) | Yes — `realtime/unit/presence/realtime_presence_enter.md`, `realtime/integration/presence_lifecycle_test.md` | +| RTP11 | Get function (RTP11a–RTP11d) | Yes — `realtime/unit/presence/realtime_presence_get.md`, `realtime/integration/presence_lifecycle_test.md`; `realtime/integration/presence/presence_sync_test.md` covers RTP11a | +| RTP12 | History function (RTP12a–RTP12d) | Yes — `realtime/unit/presence/realtime_presence_history.md` | +| RTP13 | SyncComplete attribute | Yes — `realtime/unit/presence/realtime_presence_channel_state.md` | +| RTP14 | EnterClient function (RTP14a–RTP14d) | Yes — `realtime/unit/presence/realtime_presence_enter.md` | +| RTP15 | EnterClient/UpdateClient/LeaveClient (RTP15a–RTP15f) | Yes — `realtime/unit/presence/realtime_presence_enter.md` | +| RTP16 | Connection state conditions (RTP16a–RTP16c) | Yes — `realtime/unit/presence/realtime_presence_enter.md` | +| RTP17 | Internal PresenceMap (RTP17a–RTP17j) | Partial — `realtime/unit/presence/local_presence_map.md` covers RTP17, RTP17b, RTP17h; `realtime/unit/presence/realtime_presence_reentry.md` covers RTP17a, RTP17e, RTP17g, RTP17g1, RTP17i; `realtime/integration/proxy/presence_reentry.md` covers RTP17i, RTP17g | +| RTP18 | Server-initiated sync (RTP18a–RTP18c) | Yes — `realtime/unit/presence/presence_sync.md` | +| RTP19 | PresenceMap cleanup on sync (RTP19a) | Yes — `realtime/unit/presence/presence_sync.md`, `realtime/unit/presence/realtime_presence_channel_state.md` | + +### RealtimeAnnotations + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RTAN1–RTAN5 | Annotations publish/delete/get/subscribe/unsubscribe | Yes — `realtime/unit/channels/channel_annotations.md`, `realtime/integration/mutable_messages_test.md` | + +### EventEmitter + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RTE1–RTE6 | EventEmitter interface (on/once/off/emit) | | + +### Incremental Backoff and Jitter + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RTB1 | Retry timeout calculation (RTB1a–RTB1b) | Yes — `realtime/unit/connection/backoff_jitter_test.md` | + +### Forwards Compatibility (Realtime) + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RTF1 | Robustness principle | Yes — `realtime/unit/connection/forwards_compatibility_test.md` | + +### Wrapper SDK Proxy Client + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| WP1–WP7 | Wrapper SDK proxy client | | + +--- + +## Push Notifications + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RSH1 | Push#admin object (RSH1a–RSH1c5) | Yes — `rest/unit/push/push_admin_publish.md` (RSH1, RSH1a), `rest/unit/push/push_device_registrations.md` (RSH1b1–RSH1b5), `rest/unit/push/push_channel_subscriptions.md` (RSH1c1–RSH1c5), `rest/integration/push_admin.md` (RSH1a–RSH1c5) | +| RSH2 | Platform-specific push operations (RSH2a–RSH2e) | | +| RSH3 | Activation state machine (RSH3a–RSH3g3) | | +| RSH4–RSH5 | Event queueing and sequential handling | | +| RSH6 | Push device authentication (RSH6a–RSH6b) | | +| RSH7 | Push channels (RSH7a–RSH7e) | Yes — `rest/unit/push/push_channels.md`, `rest/integration/push_channels.md` | +| RSH8 | LocalDevice (RSH8a–RSH8k2) | | + +--- + +## Types + +### Data Types + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| TM1–TM8 | Message (TM1–TM8a1) | Partial — `rest/unit/types/message_types.md` covers TM1–TM5; `rest/unit/types/mutable_message_types.md` covers TM2j, TM2r, TM2s, TM2u, TM5, TM8; `realtime/unit/channels/message_field_population.md` covers TM2a, TM2c, TM2f (realtime field population) | +| DE1–DE2 | DeltaExtras | | +| TP1–TP5 | PresenceMessage | Yes — `rest/unit/types/presence_message_types.md` | +| OM1–OM5 | ObjectMessage | | +| OOP1–OOP5 | ObjectOperation | | +| OST1–OST3 | ObjectState | | +| OMO1–OMO3 | ObjectsMapOp | | +| OCO1–OCO3 | ObjectsCounterOp | | +| OMP1–OMP4 | ObjectsMap | | +| OCN1–OCN3 | ObjectsCounter | | +| OME1–OME3 | ObjectsMapEntry | | +| OD1–OD5 | ObjectData | | +| TAN1–TAN3 | Annotation | Yes — `rest/unit/types/mutable_message_types.md` | +| TR1–TR4 | ProtocolMessage | | +| TG1–TG7 | PaginatedResult | Yes — `rest/unit/types/paginated_result.md`, `rest/integration/pagination.md` | +| HP1–HP8 | HttpPaginatedResponse | Yes — `rest/unit/request.md` | +| TE1–TE6 | TokenRequest | Yes — `rest/unit/types/token_types.md` | +| TD1–TD7 | TokenDetails | Yes — `rest/unit/types/token_types.md` | +| TN1–TN3 | Token string | | +| AD1–AD2 | AuthDetails | | +| TS1–TS14 | Stats | | +| TI1–TI5 | ErrorInfo | Yes — `rest/unit/types/error_types.md` | +| TA1–TA5 | ConnectionStateChange | | +| TH1–TH6 | ChannelStateChange | Yes — `realtime/unit/channels/channel_state_events.md` | +| TC1–TC2 | Capability | | +| CD1–CD2 | ConnectionDetails | | +| CP1–CP2 | ChannelProperties | | +| CHD1–CHD2, CHS1–CHS2, CHO1–CHO2, CHM1–CHM2 | Channel status types | Yes — `rest/unit/channel/rest_channel_attributes.md` | +| BAR1–BAR2 | BatchResult | Partial — `rest/unit/batch_presence.md` covers BAR2 | +| BSP1–BSP2 | BatchPublishSpec | | +| BPR1–BPR2, BPF1–BPF2 | BatchPublish result types | | +| BGR1–BGR2, BGF1–BGF2 | BatchPresence result types | Yes — `rest/unit/batch_presence.md`, `rest/integration/batch_presence.md` | +| PBR1–PBR2 | PublishResult | Yes — `realtime/unit/channels/channel_publish.md` | +| UDR1–UDR2 | UpdateDeleteResult | Yes — `rest/unit/types/mutable_message_types.md` | +| TRT1–TRT2, TRS1–TRS2, TRF1–TRF2 | TokenRevocation types | Yes — `rest/unit/auth/revoke_tokens.md` | +| MFI1–MFI2 | MessageFilter | Yes — `realtime/unit/channels/channel_subscribe.md` | +| REX1–REX2 | ReferenceExtras | | + +### Option Types + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| TO1–TO3 | ClientOptions | Yes — `rest/unit/types/options_types.md` | +| TK1–TK6 | TokenParams | Yes — `rest/unit/types/token_types.md` | +| AO1–AO2 | AuthOptions | Yes — `rest/unit/types/options_types.md` | +| TB1–TB4 | ChannelOptions | Yes — `realtime/unit/channels/channel_options.md` | +| DO1–DO2 | DeriveOptions | Yes — `realtime/unit/channels/channel_options.md` | +| TZ1–TZ2 | CipherParams | | +| CO1–CO2 | CipherParamOptions | | +| WPO1–WPO2 | WrapperSDKProxyOptions | | + +### Push Notification Types + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| PCS1–PCS5 | PushChannelSubscription | | +| PCD1–PCD7 | DeviceDetails | | +| PCP1–PCP4 | DevicePushDetails | | + +### Client Library Introspection + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| CR1–CR3 | ClientInformation | | + +### Client Library Defaults + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| DF1 | Default values (DF1a–DF1b) | | + +--- + +### Proxy Integration Tests + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RTN14a | Fatal error during connection open → FAILED | Yes — `realtime/integration/proxy/connection_open_failures.md` | +| RTN14b | Token error during connection → renew and retry | Yes — `realtime/integration/proxy/connection_open_failures.md` | +| RTN14c | Connection timeout (no CONNECTED received) | Yes — `realtime/integration/proxy/connection_open_failures.md` | +| RTN14d | Retry after connection refused | Yes — `realtime/integration/proxy/connection_open_failures.md` | +| RTN14g | Connection-level ERROR during open → FAILED | Yes — `realtime/integration/proxy/connection_open_failures.md` | +| RTN15a | Unexpected disconnect triggers resume | Yes — `realtime/integration/proxy/connection_resume.md` | +| RTN15b/c6 | Resume preserves connectionId | Yes — `realtime/integration/proxy/connection_resume.md` | +| RTN15c7 | Failed resume gets new connectionId | Yes — `realtime/integration/proxy/connection_resume.md` | +| RTN15g/g2 | connectionStateTtl expiry clears resume state | Yes — `realtime/integration/proxy/connection_resume.md` | +| RTN15h1 | DISCONNECTED with token error, non-renewable → FAILED | Yes — `realtime/integration/proxy/connection_resume.md` | +| RTN15h3 | DISCONNECTED with non-token error → reconnect | Yes — `realtime/integration/proxy/connection_resume.md` | +| RTN15j | Fatal ERROR on established connection | Yes — `realtime/integration/proxy/connection_resume.md` | +| RTN16d/l | Connection recovery via proxy | Yes — `realtime/integration/proxy/connection_resume.md` | +| RTN19a/a2 | Unacked messages resent after resume | Yes — `realtime/integration/proxy/connection_resume.md` | +| RTN23a | Heartbeat starvation causes disconnect | Yes — `realtime/integration/proxy/heartbeat.md` | +| RTN23a | heartbeats=true in connection URL | Yes — `realtime/integration/proxy/heartbeat.md` | +| RTL4f | Attach timeout (server doesn't respond) | Yes — `realtime/integration/proxy/channel_faults.md` | +| RTL14 | Server responds with ERROR to ATTACH → FAILED | Yes — `realtime/integration/proxy/channel_faults.md` | +| RTL5f | Detach timeout (server doesn't respond) | Yes — `realtime/integration/proxy/channel_faults.md` | +| RTL12 | ATTACHED with resumed=false triggers reattach | Yes — `realtime/integration/proxy/channel_faults.md` | +| RTL13a | Server sends unsolicited DETACHED → reattach | Yes — `realtime/integration/proxy/channel_faults.md` | +| RTL14 | Server sends channel ERROR → FAILED | Yes — `realtime/integration/proxy/channel_faults.md` | +| RTL3d | Channels reattach after connection recovery | Yes — `realtime/integration/proxy/channel_faults.md` | +| RSC15l | Connection drop triggers fallback retry | Yes — `rest/integration/proxy/rest_fallback.md` | +| RSC15l2 | Request timeout triggers fallback retry | Yes — `rest/integration/proxy/rest_fallback.md` | +| RSC15l4 | CloudFront Server header triggers fallback | Yes — `rest/integration/proxy/rest_fallback.md` | +| RSC15l | Unreachable endpoint surfaces correct error | Yes — `rest/integration/proxy/rest_fallback.md` | +| RSC15l | HTTP 5xx with/without error body parsed correctly | Yes — `rest/integration/proxy/rest_fallback.md` | +| RSC15l | HTTP 4xx not retried, error parsed | Yes — `rest/integration/proxy/rest_fallback.md` | +| RSL1k4 | Idempotent publish retry deduplication | Pending — `rest/integration/proxy/rest_fallback.md` (needs proxy enhancement) | +| RSC10 | Token renewal on HTTP 401 | Yes — `realtime/integration/proxy/rest_faults.md` | +| RSC15m/REC2c2 | HTTP 503 error (no fallback, hosts disabled) | Yes — `realtime/integration/proxy/rest_faults.md` | +| RTL6 | End-to-end publish and history | Yes — `realtime/integration/proxy/rest_faults.md` | +| RTN22/RTC8a | Server-initiated re-authentication | Yes — `realtime/integration/proxy/auth_reauth.md` | +| RTP17i/RTP17g | Automatic presence re-entry on non-resumed reattach | Yes — `realtime/integration/proxy/presence_reentry.md` | + +## Summary + +| Area | Spec groups | With UTS spec | Coverage | +|------|-------------|---------------|----------| +| **Endpoint config** (REC) | 3 | 3 | Full | +| **REST client** (RSC) | 18 | 16 | Partial | +| **REST auth** (RSA) | 15 | 15 | Full | +| **REST channels** (RSN) | 4 | 4 | Full | +| **REST channel** (RSL) | 13 | 13 | Full | +| **REST presence** (RSP) | 5 | 4 | Mostly | +| **REST encryption** (RSE) | 2 | 0 | None | +| **REST annotations** (RSAN) | 3 | 3 | Full | +| **Realtime client** (RTC) | 14 | 14 | Full | +| **Connection** (RTN) | 23 | 19 | Partial | +| **Realtime channels** (RTS) | 5 | 5 | Full | +| **Realtime channel** (RTL) | 28 | 26 | Partial | +| **Realtime presence** (RTP) | 15 | 15 | Full | +| **Realtime annotations** (RTAN) | 5 | 5 | Full | +| **EventEmitter** (RTE) | 6 | 0 | None | +| **Backoff/jitter** (RTB) | 1 | 1 | Full | +| **Wrapper SDK** (WP) | 7 | 0 | None | +| **Push notifications** (RSH) | 8 | 1 | Partial | +| **Plugins** (PC/PT/VD) | 3 | 2 | Partial | +| **Data types** | 30 | 12 | Partial | +| **Option types** | 8 | 5 | Partial | +| **Push types** | 3 | 0 | None | +| **Introspection** (CR) | 1 | 0 | None | +| **Defaults** (DF) | 1 | 0 | None | +| **Compatibility** (RSF/RTF) | 2 | 2 | Full | diff --git a/uts/docs/integration-testing.md b/uts/docs/integration-testing.md new file mode 100644 index 000000000..cca26a715 --- /dev/null +++ b/uts/docs/integration-testing.md @@ -0,0 +1,360 @@ +# Integration Testing Policy + +This document defines the policy for integration tests in the UTS test suite. It covers what to test, how tests are organised, and the distinction between direct sandbox tests and proxy-based tests. + +## Relationship to Unit Tests + +Unit tests use mocked transports (MockWebSocket, MockHttpClient) to verify client-side logic: state machines, request formation, response parsing, timer behaviour, error handling. They are fast and deterministic. + +Integration tests verify that the SDK interoperates correctly with the real Ably service. They run against the Ably sandbox and exercise the actual network path. + +**Integration tests do not replace unit tests.** Every spec point that has an integration test should also have a unit test. The integration test adds confidence that the mocked behaviour in the unit test matches reality. + +## What to Test + +Integration tests should cover spec points where correctness depends on agreement between client and server. Not every spec point needs an integration test — only those where a unit test alone leaves meaningful doubt. + +### Selection Criteria + +Choose spec points for integration testing when they fall into one or more of these categories: + +#### 1. Request/Response Shape Interop + +The SDK constructs a request (HTTP or protocol message) and the server must accept it, or the server sends a response and the SDK must parse it correctly. + +Examples: +- Auth token obtained via `createTokenRequest` is accepted by the server (RSA9) +- WebSocket connection URL parameters are accepted (RTN2) +- Channel attach/detach protocol messages round-trip correctly (RTL4, RTL13) +- Publish with various data types round-trips through the server (RSL1, RTL6) + +#### 2. Error Response Interop + +The server rejects invalid requests with specific error codes, and the SDK must surface those errors correctly. + +Examples: +- Invalid API key produces the correct error code and state transition (RTN14b) +- Token expiry triggers renewal flow (RSA4b) +- Insufficient capability produces channel FAILED (RTL4e) + +#### 3. Data Encoding Round-Trips + +Data passes through the SDK's encoding layer, through the server, and back. The round-trip must preserve data integrity. + +Examples: +- String, binary, and JSON data types are preserved through publish/subscribe (RSL4, RSL6) +- Presence data encoding round-trips (RTP8) +- Message extras survive the round-trip + +#### 4. Stateful Protocol Sequences + +Multi-step interactions where the server's state machine and the client's state machine must agree. + +Examples: +- Connection resume after disconnect (RTN15) — proxy required +- Presence SYNC protocol (RTP2) — server-initiated, can't be mocked faithfully +- Channel reattach after server-initiated detach (RTL13) — proxy required +- Heartbeat timeout detection (RTN23) — proxy required to starve heartbeats + +### What NOT to Test + +Do not write integration tests for: +- Pure client-side logic (option parsing, state machine transitions that don't depend on server responses) +- Behaviour that is fully exercised by unit tests with high confidence (e.g. event emitter semantics, channel name validation) +- Timing-sensitive retry logic where the integration test would be flaky without the proxy +- Features that require server-side configuration not available in the sandbox + +## Directory Structure + +Integration test specs are organised to mirror the unit test structure: + +``` +realtime/ + unit/ # Unit tests (mock transport) + auth/ + connection_auth_test.md + realtime_authorize_test.md + channels/ + channel_attach_test.md + channel_publish_test.md + ... + connection/ + auto_connect_test.md + connection_failures_test.md + ... + presence/ + realtime_presence_enter_test.md + ... + integration/ # Integration tests + auth/ # Direct sandbox tests + connection_auth_test.md + realtime_authorize_test.md + channels/ + channel_attach_test.md + channel_publish_test.md + ... + connection/ + connection_lifecycle_test.md + ... + presence/ + presence_lifecycle_test.md + ... + helpers/ + proxy.md # Proxy infrastructure spec + proxy/ # Proxy-based tests (sandbox + proxy) + connection_open_failures.md + connection_resume.md + heartbeat.md + channel_faults.md + rest_faults.md + auth_reauth.md + presence_reentry.md +``` + +### Segregation Rationale + +Tests that require the proxy are segregated into `integration/proxy/` because: + +1. **Different infrastructure requirements** — proxy tests need the proxy binary running, port allocation, and proxy session lifecycle management. Direct sandbox tests need only network access to the sandbox. +2. **Different CI configuration** — proxy tests can run on a different schedule or be gated on proxy availability, without affecting direct integration tests. +3. **Different failure modes** — proxy test failures may indicate proxy bugs, port conflicts, or proxy/SDK version mismatches, not just SDK issues. +4. **Clear authoring signal** — when writing a test, the file location encodes whether the proxy is needed. No conditional skip logic inside test files. + +### Shared Spec Points + +A single spec point may have tests in multiple tiers. For example, RTN15 (connection resume): + +- `unit/connection/connection_failures_test.md` — mock transport verifies client-side state transitions and retry logic +- `integration/proxy/connection_resume.md` — proxy verifies the resume protocol works against the real server + +This is expected and correct. The unit test verifies client logic; the integration test verifies client-server agreement. + +## Test Structure Conventions + +### Sandbox Setup + +Every integration test file includes the standard sandbox provisioning: + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Proxy Setup (integration-proxy only) + +Proxy tests additionally set up a proxy session per test or group of tests. See `realtime/integration/helpers/proxy.md` for the proxy infrastructure API. + +```pseudo +BEFORE EACH TEST: + session = create_proxy_session( + endpoint: "nonprod:sandbox", + rules: [ ...initial rules... ] + ) + +AFTER EACH TEST: + session.close() +``` + +### Client Options + +Integration test clients use: + +```pseudo +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", # Direct sandbox tests + useBinaryProtocol: false, + autoConnect: false +)) +``` + +Proxy test clients use: + +```pseudo +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) +``` + +### Channel Names + +Channel names must be unique per test to avoid cross-test interference: + +```pseudo +channel_name = "test-RTL4-attach-${base64(random_bytes(6))}" +``` + +### Spec Point References + +Each test section references the spec points it covers, just like unit tests: + +```pseudo +## RTN4b - Successful connection establishment + +| Spec | Requirement | +|------|-------------| +| RTN4b | Connection transitions INITIALIZED → CONNECTING → CONNECTED | +``` + +### Timeout Strategy + +Integration tests interact with real services over real networks, so timeouts need more thought than unit tests. Apply two levels of timeout: + +**Suite timeout** — the mocha `this.timeout()` on the `describe` block. This must accommodate the sum of all tests in the suite plus setup and teardown. For suites with many tests or slow sandbox operations, 120 seconds is a reasonable default. Suites with only 1–3 fast tests can use 30–60 seconds. + +**Operation timeout** — individual operations that may hang (HTTP requests, WebSocket state waits, sandbox provisioning/teardown) should each have their own timeout, shorter than the suite timeout. This ensures a single stuck operation produces a clear error message rather than silently consuming the suite budget until mocha kills the entire suite with a generic "timeout exceeded." + +Guidelines: + +- Sandbox provisioning and teardown HTTP requests: 30 seconds (via `AbortSignal.timeout()` or equivalent). Sandbox teardown (app deletion) should be best-effort — catch and ignore timeout errors, since sandbox apps auto-expire. +- `connectAndWait`, `closeAndWait`, channel attach waits: 10–15 seconds. +- Proxy tests with `realtimeRequestTimeout` set low (e.g. 3 seconds for timeout tests): give the suite timeout at least `realtimeRequestTimeout + 12 seconds` headroom per such test. +- `pollUntil` calls: explicit timeout parameter, typically 10–30 seconds. + +The goal is: every await in the test is bounded, and the suite timeout is generous enough that it only fires if something truly unexpected happens. When a test fails, the error should say *what* timed out, not just "suite timeout exceeded." + +### Avoiding Flaky Tests + +- Use polling with timeouts instead of fixed waits (see `README.md` polling conventions) +- For token expiry tests, use short TTLs and poll for rejection +- For state transition assertions, wait for the target state event rather than asserting after a delay +- Proxy tests should use proxy event logs for verification rather than timing-dependent assertions +- When tests pass in isolation but fail in the full suite, suspect sandbox rate limiting or connection exhaustion — increase the suite timeout rather than adding retries + +## Protocol Variants + +The Ably client library spec (G1) requires that tests run with all supported protocols. Integration tests that exercise the data encoding/decoding path must run with both JSON and msgpack to verify data integrity through the full encode-transmit-decode pipeline. + +### Which tests need both protocols? + +Only tests on the **data path** need both protocols. These are tests where messages, presence data, or other payloads pass through the SDK's encoding layer, through the server, and back. Examples include publish/subscribe round-trips, history retrieval, presence data, delta decoding, and mutable message operations. + +Tests for **connection lifecycle**, **authentication**, **channel attach/detach**, and other protocol-agnostic behaviours do not need protocol variants. These tests exercise control-plane operations whose correctness does not depend on the wire encoding of message payloads. + +**Proxy tests always use JSON.** The proxy only supports text WebSocket frames, so proxy-based tests cannot use msgpack. + +### Spec file convention + +A spec file that requires protocol variant testing includes a `## Protocol Variants` section immediately after `## Test Type`: + +```markdown +## Test Type +Integration test against Ably sandbox + +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. +``` + +The `PROTOCOL` variable is available in pseudocode and is set to `"json"` or `"msgpack"` for the current run. Client options use the standard pattern: + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) +``` + +### Default behaviour + +Spec files **without** a `## Protocol Variants` section default to JSON only. No special handling is required in derived test implementations for these specs. + +### Annotated specs + +The following integration test specs are annotated with `## Protocol Variants`: + +**REST:** +- `rest/integration/publish.md` +- `rest/integration/history.md` +- `rest/integration/presence.md` +- `rest/integration/batch_presence.md` +- `rest/integration/mutable_messages.md` + +**Realtime:** +- `realtime/integration/channels/channel_publish_test.md` +- `realtime/integration/channel_history_test.md` +- `realtime/integration/presence_lifecycle_test.md` +- `realtime/integration/mutable_messages_test.md` +- `realtime/integration/delta_decoding_test.md` + +## Writing Proxy Tests + +The proxy mediates between the SDK and the real Ably server. It is not a mock server. Tests should be written to rely on actual server responses as much as possible, with the proxy intervening only where necessary to create the specific fault or error condition under test. + +The more a proxy constructs or replaces server responses, the more likely it is that the test exercises a scenario that diverges from real server behaviour. This undermines the value of integration testing over unit testing. + +### Prefer Late Fault Injection + +Wherever possible, structure tests so that the fault injected by the proxy occurs as the **final interaction** between client and server, with the test verifying the client's behaviour in response. All preceding interactions should pass through to the real server unmodified, establishing genuine client and server state. + +For example, to test that the SDK handles a connection-level ERROR correctly: +1. Let the real connection handshake complete through the proxy (real CONNECTED from server). +2. After the SDK is connected, use the proxy to inject or trigger the error condition. +3. Assert that the SDK transitions to the correct state. + +This maximises the proportion of the test that exercises real client-server interaction. + +### When Earlier Fault Injection Is Needed + +Sometimes the fault must occur at an earlier point — for example, replacing the server's response to the first CONNECTED, or suppressing an ATTACH before it reaches the server. When this is unavoidable, there are two approaches, each with a trade-off: + +**Approach A: Modify the server's response.** The proxy forwards the request to the server, receives the real response, but modifies it before forwarding to the client. The server believes the operation succeeded; the client sees an error. + +**Approach B: Handle the request without forwarding.** The proxy intercepts the request, generates a response itself, and never forwards to the server. Client and server state remain consistent (both believe the operation did not happen), but the response is entirely synthetic. + +**Prefer Approach A** (modify real server responses) when the resulting client-server state drift does not affect the validity of subsequent actions or assertions in the test. This preserves the integration testing value: the response structure, timing, and ancillary fields come from the real server, with only the specific fault injected. + +Use Approach B only when the state drift from Approach A would invalidate later parts of the test — for example, if the server's belief that a channel is attached would cause it to send unsolicited messages that interfere with subsequent assertions. + +### Example: Simulating a Rejected Attach + +To test that the SDK handles a channel attach rejection correctly, after a successful real connection: + +**Approach A (preferred):** The proxy forwards the ATTACH to the server, receives the real ATTACHED response, but replaces it with an ERROR before forwarding to the client. The server now believes the channel is attached, but the client sees FAILED. This is acceptable when the test ends here — the state drift doesn't matter because there are no subsequent server interactions that depend on consistent channel state. + +**Approach B:** The proxy intercepts the ATTACH, does not forward it, and generates an ERROR response. Client and server agree the channel is not attached. But the error response is entirely synthetic — we might as well have written a unit test. + +### Implications for Test Design + +This principle influences test structure: + +- **Keep proxy tests focused.** Each test should verify one fault condition. Avoid multi-phase tests where an early proxy intervention creates state drift that compounds through later phases. +- **Use imperative actions for late injection.** The proxy's imperative action API (`trigger_action`) is ideal for injecting faults after the SDK has reached a stable state through real server interaction. +- **Use rules for response modification.** When a rule must fire during the protocol handshake (e.g., replacing the CONNECTED response), use `times: 1` so the proxy returns to passthrough for subsequent interactions. +- **Verify via proxy event logs.** Assert against the proxy's event log to confirm that the expected real server interactions occurred, rather than relying solely on SDK state. + +## Coverage Tracking + +Integration test coverage is tracked in `completion-status.md` alongside unit test coverage. Each spec point entry indicates which tiers have coverage: + +``` +RTN4b unit:✓ integration:✓ +RTN15a unit:✓ proxy:✓ +RTL4 unit:✓ integration:✓ +``` + +## Adding New Integration Tests + +1. **Check whether an integration test adds value** — apply the selection criteria above. If the unit test already provides high confidence, skip the integration test. +2. **Choose the right tier** — if the test needs fault injection (dropped connections, delayed frames, modified responses), it goes in `integration/proxy/`. Otherwise, `integration/`. +3. **Mirror the unit test structure** — use the same category directory and a similar file name. +4. **Write the UTS spec first** — just like unit tests, the portable test spec comes before the language-specific implementation. +5. **Reference spec points** — every test section must cite the spec points it covers. diff --git a/uts/docs/writing-derived-tests.md b/uts/docs/writing-derived-tests.md new file mode 100644 index 000000000..68a22b93c --- /dev/null +++ b/uts/docs/writing-derived-tests.md @@ -0,0 +1,275 @@ +# Writing Derived Tests from UTS Specs + +This guide covers the process of translating UTS (Universal Test Specification) portable test specs into working tests for a specific language and SDK. It also covers the optional evaluation step when an existing implementation is available to run the tests against. + +## Overview + +UTS specs are the source of truth for *what* to test. They define test structure, setup, assertions, and mock patterns in language-neutral pseudocode. A derived test translates that spec into a concrete, runnable test for a specific SDK. + +The process has two phases: + +1. **Translation** — always required. Produce a test file that faithfully implements the UTS spec. +2. **Evaluation** — optional. When an existing implementation is available, run the tests and diagnose any failures. + +Not every situation has an existing implementation. Tests may be written ahead of the implementation (test-first development), or for a new SDK that doesn't yet exist. In those cases, only the translation phase applies. + +--- + +## Phase 1: Translation + +### 1. Translate the UTS spec faithfully + +Write the test as closely as possible to the UTS spec. The UTS spec defines what to test — don't second-guess it, optimise it, or skip steps on a first pass. + +- **Match the spec's structure**: one test per spec point, same assertions, same setup +- **Use the spec's naming**: test names must include the spec point (e.g. `RSL1a - publish sends POST to correct path`) +- **Include the test ID**: add a `// UTS: ` comment immediately above each test function, using the test ID from the UTS spec (see `docs/writing-test-specs.md` § Test IDs for the format) +- **Preserve the spec's intent**: if the spec says "assert X", assert X, even if it seems redundant + +### 2. Map pseudocode to language idioms + +UTS specs use generic pseudocode. You need to map this onto the SDK's actual API and the language's test framework. Common mappings: + +| UTS pseudocode | What to figure out | +|---|---| +| `Rest(options: ...)` | SDK constructor syntax | +| `ASSERT x == y` | Test framework assertion style | +| `mock_http = MockHttpClient(...)` | SDK's mock infrastructure | +| `install_mock(mock_http)` | How mocks are injected (DI, platform patching, etc.) | +| `enable_fake_timers()` | Timer control mechanism | +| `ADVANCE_TIME(ms)` | Fake timer tick method | +| `AWAIT_STATE(connection, "connected")` | State waiting helper | + +Check the SDK's existing test infrastructure and conventions before writing anything. Reuse existing helpers, mock classes, and patterns. + +### 3. Flag ambiguity + +If the UTS spec is ambiguous — unclear what value to assert, unclear what "should" means in context, unclear whether a step is required or illustrative — add a comment in the test and continue with your best interpretation. Don't block on it; flag it for review. + +``` +// NOTE: UTS spec says "assert the response contains the field" but doesn't +// specify the value. Interpreting as: field must be present and non-null. +``` + +### 4. Verify the test compiles/parses + +Before moving to evaluation (or declaring the test done in a test-first scenario), make sure the test at least compiles, parses, or passes linting. Syntax errors in the translation are not interesting failures. + +--- + +## Phase 2: Evaluation (optional) + +This phase applies when you have an existing SDK implementation to run the tests against. If you're writing tests before the implementation exists, skip to [Test-first considerations](#test-first-considerations). + +### 1. Run the test + +Run the translated test against the current SDK build. + +If it passes, you're done with that test. + +### 2. If it fails, diagnose + +A test failure has exactly three possible causes. Work through them in order: + +#### 2a. Is the UTS spec wrong? + +Compare the UTS spec's claim against the Ably features spec (`specification/specifications/features.md`). The features spec is the ultimate authority. If the UTS spec contradicts it: + +- Fix the test to match the features spec +- Add a comment explaining the UTS spec error +- Record the error in the **UTS Spec Errors** section of the deviations file + +Examples: +- UTS spec claimed RSA4b means "clientId triggers token auth" — actual RSA4b is about token renewal on error +- UTS spec claimed expired tokens must not make HTTP requests — actual spec says local expiry detection is optional + +#### 2b. Is the test translation wrong? + +Re-read the UTS spec and your test side by side. Common translation errors: + +- Wrong assertion (e.g. strict equality vs deep equality, null vs undefined/nil) +- Missing setup step (e.g. protocol format options, TLS settings) +- Wrong API mapping (SDK method name differs from spec pseudocode) +- Mock response doesn't match what the SDK expects + +If the translation is wrong, fix the test. No deviation entry needed. + +#### 2c. Is the SDK non-compliant? + +If the UTS spec is correct per the features spec, and the test accurately translates it, then the SDK has a deviation. In this case: + +- Keep the test, but adapt it to pass against the SDK's current behaviour +- Document exactly what the spec requires vs what the SDK does +- Record it in the deviations file + +### 3. Deviation test patterns + +There are two patterns for deviation tests. Both should write the **spec-correct assertions** in the test body — the test should fail when run, proving the deviation exists. + +**Env-gated skip** (preferred) — the test contains the correct spec assertion but is skipped by default. An environment variable enables it on demand: +``` +it("RSA7b - clientId from TokenDetails", function() { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + + // ... spec-correct setup and assertions ... + assert client.auth.clientId == "token-client-id" +}) +``` +This has three advantages: +- Normal test runs stay green (deviations are skipped) +- Each deviation is individually reproducible: `RUN_DEVIATIONS=1 --grep "RSA7b"` +- Issues filed against the SDK can link to a concrete reproduction command +- When the SDK is fixed, removing the skip guard is the only change needed + +Use a consistent env var name across all deviation tests in the suite (e.g. `RUN_DEVIATIONS`). + +**Adapted assertion** — when the deviation changes observable behaviour but the test can still validate something useful, assert the SDK's actual behaviour and comment the spec expectation: +``` +it("RSC1b - no credentials raises error", function() { + // DEVIATION: see deviations.md + // Spec says error code 40106, ably-js uses 40160 + assert error.code == 40160 +}) +``` +Use this pattern when the SDK does *something* (just not the right thing) and you want to assert on the actual behaviour to prevent regressions. These tests pass in normal runs. + +**Avoid the accommodate-both pattern.** Tests that accept either the spec behaviour or the SDK behaviour (e.g. try/catch that passes regardless of which path is taken) provide no signal — they pass whether the SDK is compliant or not. Every test should either assert spec behaviour (and fail if non-compliant) or assert the SDK's actual behaviour (and document the deviation). Never both. + +### 4. Decision tree + +``` +Test fails + | + +-- Does UTS spec match features spec? + | | + | NO --> Fix test, record UTS spec error in deviations file + | | + | YES + | | + | +-- Does test accurately translate UTS spec? + | | + | NO --> Fix the test + | | + | YES --> SDK deviation. Adapt test, record in deviations file +``` + +--- + +## Recording deviations + +When evaluating against an existing implementation, maintain a deviations file (e.g. `deviations.md`) as the single record of all known issues. Each entry must include: + +1. **The spec point** (e.g. RSA4b4) +2. **What the spec says** — quote or paraphrase the features spec +3. **What the SDK does** — concrete observable behaviour +4. **Root cause** (if known) — file, function, mechanism +5. **Test impact** — which test(s) are affected and how they were adapted + +Deviations are grouped into three sections: +- **Failing Tests** — SDK non-compliance where the spec-correct test is present but skipped (env-gated). These are the primary output — each maps to a potential issue to file. +- **Adapted Tests** — SDK non-compliance where the test was adapted to assert actual behaviour. The test passes but documents a genuine deviation. +- **Mock Infrastructure Limitations** — tests that can't be implemented due to missing mock capabilities (e.g. msgpack support). These are skipped stubs, not SDK deviations. + +This file is valuable output. It gives the SDK team a precise catalogue of spec gaps, each with a failing test that can be turned on once the fix lands. + +### Filing issues from deviations + +Once the test suite is complete, classify the deviations into distinct issues grouped by root cause or theme — not one issue per test. For example, five tests that all fail because `auth.clientId` isn't derived from token details are one issue, not five. + +Each issue should include: +- The spec point(s) affected +- What the spec says vs what the SDK does +- A reproduction command: `RUN_DEVIATIONS=1 --grep "" ` +- A link to the PR containing the test suite + +This makes the issues actionable: a developer can check out the branch, run the command, see the failure, and know exactly what to fix. + +--- + +## Test-first considerations + +When writing tests before an implementation exists: + +- **Write the test to match the spec exactly.** Don't preemptively accommodate likely implementation gaps — you don't know what they are yet. +- **Use the skip/pending mechanism** of your test framework liberally. Tests that can't run yet should be marked as pending, not commented out. +- **Mock infrastructure may not exist yet.** You may need to build it. Follow the mock patterns defined in the UTS spec (`rest/unit/helpers/mock_http.md`, `realtime/unit/helpers/mock_websocket.md`). +- **The deviations file is created during evaluation**, not during translation. If there's no implementation to evaluate against, there are no deviations to record yet. + +--- + +## Practical notes + +### Check the SDK's API surface + +Not everything in the UTS pseudocode maps 1:1 to every SDK. Before writing tests, verify that the API exists. If an API is missing or named differently, note it and adapt the test. + +### Required options vary by SDK + +Some SDKs have defaults that conflict with mock infrastructure. For example, an SDK may default to binary protocol (msgpack) while mocks return JSON. Check what options are needed to make mocks work. + +### Wire values vs decoded values + +SDKs often convert between wire format and developer-facing types. For example, presence actions may be integers on the wire but strings or enums in the SDK's public API. Tests asserting on decoded objects must use the SDK's representation. Tests asserting on outgoing request bodies must use the wire format. + +### Pagination and Link headers + +If the SDK parses pagination `Link` headers, check the expected URL format. Some SDKs expect relative URLs with specific prefixes (e.g. `./messages?...`). + +### Idempotent ID format + +ID generation (base64 encoding, URL-safe variants, batch behaviour) varies between SDKs. Check the SDK's implementation before asserting on generated ID formats. + +### Build pipeline and CI checks + +Run the full build pipeline, not just the tests. Many SDKs have: +- **Type checking** (e.g. `tsc`, `mypy`) — catches type errors the test runner ignores +- **Linting** (e.g. `eslint`, `prettier`) — catches formatting issues +- **Bundling** (e.g. webpack, rollup) — may use stricter settings than the test runner + +In TypeScript projects, the test runner (e.g. mocha with `tsx`) often **strips types without checking them**. The bundler (e.g. webpack with `ts-loader`) does full type checking. Both must pass. Run the CI checks locally before pushing. + +Common type errors to watch for in test files: +- `let captured = []` needs `let captured: SomeType[] = []` (noImplicitAny) +- Callback parameters need type annotations: `(req) =>` -> `(req: any) =>` +- `catch (error)` needs `catch (error: any)` for property access +- Partial mock objects need `as any` casts when passed to typed constructors +- Optional method parameters may need explicit `null` or `{}` arguments + +### Timer and platform type mismatches + +SDKs that abstract platform APIs (timers, HTTP, WebSocket) behind an interface often have type mismatches between the interface definition and the concrete platform types. For example, `setTimeout` returns `number` in browsers but `NodeJS.Timeout` in Node. When installing mock timers, you may need explicit casts: + +``` +Platform.Config.setTimeout = mockSetTimeout as unknown as typeof Platform.Config.setTimeout; +``` + +These casts are an SDK wart, not a test problem — apply them as needed and move on. + +### No real timers in unit tests + +Unit tests must not use real timers (`setTimeout`, `setInterval`, `sleep`, `delay`) to wait for asynchronous events. Real timers make tests slow, flaky, and prevent the process from exiting cleanly. + +- **For time-dependent SDK behaviour** (timeouts, retries, heartbeats): use fake timers that replace the SDK's timer API and can be advanced deterministically. +- **For waiting on async event delivery** (mock message propagation, promise settlement): yield to the event loop with a zero-delay mechanism like `setImmediate`, `process.nextTick`, or equivalent. Define a `flushAsync()` helper and use it everywhere instead of `setTimeout(resolve, N)`. +- **For "prove a negative" assertions** (confirming something did NOT happen): a single event-loop yield is sufficient — if the event hasn't fired after one pass through the macrotask queue, it won't fire from the current stimulus. + +The only acceptable use of a real timer is a **safety timeout on test execution** — a long deadline (e.g. 5 seconds) that fails the test if an expected event never arrives, preventing the test from hanging indefinitely. This is a test-level safeguard, not a delay mechanism. + +``` +// BAD: real timer delay +await new Promise(resolve => setTimeout(resolve, 50)); + +// GOOD: event-loop flush +await flushAsync(); + +// OK: safety timeout to prevent hanging +const timer = setTimeout(() => reject(new Error('Timed out')), 5000); +connection.once('connected', () => { clearTimeout(timer); resolve(); }); +``` + +### Cleanup with afterEach + +Always restore mocks in `afterEach`, not just at the end of each test. If a test throws before its cleanup code, the next test inherits dirty state. Use the SDK's mock restoration mechanism (e.g. `restoreAll()`) in an `afterEach` hook. + +The cleanup mechanism should cancel all SDK-internal timers, not just those reachable via the SDK's public API. Some SDKs have bugs where internal timers are orphaned (e.g. timer handles overwritten without cancelling the previous one). The test infrastructure should track all timer allocations and cancel any that survive `client.close()`. diff --git a/uts/docs/writing-test-specs.md b/uts/docs/writing-test-specs.md new file mode 100644 index 000000000..4abce6575 --- /dev/null +++ b/uts/docs/writing-test-specs.md @@ -0,0 +1,1153 @@ +# Writing Ably SDK Test Specifications + +This guide provides comprehensive guidance for writing portable test specifications for Ably SDK implementations. + +## Test Types + +### Unit Tests (Mocked HTTP/WebSocket) +- Use mock HTTP client to verify request formation and response parsing +- Use mock WebSocket client for Realtime connection tests +- Test client-side validation and error handling +- Token strings are opaque - any arbitrary string works for unit tests +- No network calls - fast and deterministic + +### Integration Tests (Ably Sandbox) +- Run against `https://sandbox.realtime.ably-nonprod.net` +- Provision apps via `POST /apps` with body from `ably-common/test-resources/test-app-setup.json` +- Use `endpoint: "nonprod:sandbox"` in ClientOptions +- **Protocol variants:** Data-path tests (publish, history, presence, etc.) must run with both JSON and msgpack. Add a `## Protocol Variants` section after `## Test Type` and use `useBinaryProtocol: PROTOCOL == "msgpack"` in ClientOptions. See `docs/integration-testing.md` for the full convention. Specs without this header default to JSON only. + +### Proxy Integration Tests (Ably Sandbox via Proxy) +- Run against Ably Sandbox through a programmable proxy ([ably/uts-proxy](https://github.com/ably/uts-proxy)) +- Proxy transparently forwards traffic but can inject faults via rules +- Use for testing fault behaviour: connection failures, token renewal under errors, heartbeat starvation, channel error injection +- See `realtime/integration/helpers/proxy.md` for the full proxy infrastructure spec + +## Test IDs + +Every test in the UTS suite has a unique identifier. The ID appears explicitly in the spec markdown and must be included as a comment in every derived (language-specific) implementation. + +### Format + +``` +//- +``` + +| Field | Description | +|-------|-------------| +| `category` | One of: `rest/unit`, `rest/integration`, `rest/proxy`, `realtime/unit`, `realtime/integration`, `realtime/proxy` | +| `spec-point` | The primary spec point being tested (e.g. `RSC15l2`, `RTN14a`) | +| `descriptive-name` | 2–4 hyphenated words describing the specific behaviour (e.g. `timeout-fallback`, `cloudfront-header`) | +| `n` | 0-based index disambiguating multiple tests for the same spec point within the same file | + +### Examples + +| Test ID | Meaning | +|---------|---------| +| `rest/unit/RSC15l/timeout-fallback-0` | REST unit test: first test for RSC15l covering timeout-triggered fallback | +| `rest/proxy/RSC15l4/cloudfront-fallback-0` | REST proxy test: CloudFront header triggers fallback | +| `realtime/unit/RTN14a/fatal-connect-error-0` | Realtime unit test: fatal error during connection | +| `realtime/proxy/RTN15a/disconnect-resume-0` | Realtime proxy test: unexpected disconnect triggers resume | +| `rest/integration/RSA8/request-token-0` | REST integration test: first requestToken test | + +### Placement in spec markdown + +Add a `**Test ID**` line immediately after the test heading: + +```markdown +## RSC15l2 - Request timeout triggers fallback via proxy + +**Test ID**: `rest/proxy/RSC15l2/timeout-fallback-0` + +| Spec | Requirement | +|------|-------------| +| RSC15l2 | Request timeout triggers fallback | +``` + +### Placement in derived tests + +Add a `// UTS: ` comment immediately above the test function: + +```typescript +// UTS: rest/proxy/RSC15l2/timeout-fallback-0 +it('RSC15l2 - request timeout triggers fallback', async function () { +``` + +```dart +// UTS: rest/proxy/RSC15l2/timeout-fallback-0 +test('RSC15l2 - request timeout triggers fallback', () async { +``` + +### Naming guidelines + +- The descriptive name should make the test's purpose clear without needing to look up the spec point +- Use the most specific spec point when a test covers multiple (e.g. `RSC15l2` not `RSC15l`) +- For tests not tied to a specific spec point, use the closest applicable one +- Keep names consistent within a file — similar tests should have parallel names + +--- + +## Mock Infrastructure Patterns + +### HTTP Mock Infrastructure + +**Reference the canonical specification:** +```markdown +## Mock HTTP Infrastructure + +See `rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. +``` + +**Key interfaces:** +```pseudo +interface MockHttpClient: + await_connection_attempt(timeout?: Duration): Future + await_request(timeout?: Duration): Future + reset() + +interface PendingConnection: + host: String + port: Int + tls: Boolean + respond_with_success() + respond_with_refused() + respond_with_timeout() + respond_with_dns_error() + +interface PendingRequest: + url: URL + method: String + headers: Map + body: Bytes + respond_with(status: Int, body: Any, headers?: Map) + respond_with_delay(delay: Duration, status: Int, body: Any) + respond_with_timeout() +``` + +### Handler-Based Pattern (Simple Tests) + +Use for tests with predetermined responses: + +```pseudo +captured_request = null + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_request = req + req.respond_with(200, {"time": 1234567890000}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Handler-Based with State (Complex Tests) + +Use for tests needing different responses based on request count or conditions: + +```pseudo +request_count = 0 +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + request_count++ + IF request_count == 1: + req.respond_with(401, {"error": {"code": 40142}}) + ELSE: + req.respond_with(200, {"result": "success"}) + } +) +install_mock(mock_http) +``` + +### Await-Based Pattern (Advanced Control) + +Use when test needs to coordinate responses with test execution state. + +**Important:** The await pattern has a subtle timing requirement - when awaiting multiple sequential connection attempts, you must set up the await for the next attempt BEFORE responding to the current one: + +```pseudo +# Correct pattern for sequential awaits +first_conn = AWAIT mock_ws.await_connection_attempt() +second_future = mock_ws.await_connection_attempt() # Set up BEFORE responding +first_conn.respond_with_error(...) # This triggers retry +second_conn = AWAIT second_future +``` + +This avoids race conditions where the retry happens before the await is set up. + +### When to Use Each Pattern + +**Handler pattern** (recommended for most tests): +- Response is predetermined based on request count or content +- Simple "first attempt fails, second succeeds" scenarios +- No need to coordinate with external test state +- More universally safe across different language runtimes + +**Await pattern** (for advanced scenarios only): +- Need to inspect connection/request details before deciding how to respond +- Test logic depends on external state not known at setup time +- Complex coordination between multiple async operations + +Example using await pattern: + +```pseudo +mock_http = MockHttpClient() +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "...")) + +# Start operation +request_future = client.time() + +# Wait for and handle connection +connection = AWAIT mock_http.await_connection_attempt() +connection.respond_with_success() + +# Wait for and handle request +request = AWAIT mock_http.await_request() +ASSERT request.headers["X-Ably-Version"] IS NOT null +request.respond_with(200, {"time": 1234567890000}) + +# Complete operation +result = AWAIT request_future +``` + +### WebSocket Mock Infrastructure + +For Realtime tests, reference the WebSocket mock: + +```markdown +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. +``` + +**Key interfaces:** +```pseudo +interface MockWebSocket: + events: List # Unified timeline + await_connection_attempt(timeout?: Duration): Future + await_request(timeout?: Duration): Future + send_to_client(message: ProtocolMessage) + send_to_client_and_close(message: ProtocolMessage) # Send then close + simulate_disconnect() # Close without message + reset() + +interface PendingConnection: + host: String + port: Int + tls: Boolean + respond_with_success() + respond_with_refused() + respond_with_timeout() + respond_with_dns_error() +``` + +### WebSocket Connection Closing Semantics + +When simulating server behavior, use the correct method based on the scenario: + +| Scenario | Method | Description | +|----------|--------|-------------| +| Server sends DISCONNECTED | `send_to_client_and_close()` | Server sends message then closes connection | +| Server sends ERROR (connection-level) | `send_to_client_and_close()` | ERROR without channel = fatal, closes connection | +| Server sends ERROR (channel-level) | `send_to_client()` | ERROR with channel = attachment failure, connection stays open | +| Server sends CONNECTED, HEARTBEAT, ACK, MESSAGE | `send_to_client()` | Normal messages, connection stays open | +| Unexpected transport failure | `simulate_disconnect()` | Connection drops without server message | + +**Key rule:** Whenever the server sends DISCONNECTED, or ERROR without a specified channel, it will be accompanied by the server closing the WebSocket connection. An ERROR with a specified channel is an attachment failure and doesn't end the connection. + +```pseudo +# Server-initiated disconnection (e.g., token expired) +mock_ws.active_connection.send_to_client_and_close(ProtocolMessage( + action: DISCONNECTED, + error: ErrorInfo(code: 40142, message: "Token expired") +)) + +# Connection-level error (fatal) +mock_ws.active_connection.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: 40101, message: "Invalid credentials") +)) + +# Channel attachment error (non-fatal, connection stays open) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ERROR, + channel: "private-channel", + error: ErrorInfo(code: 40160, message: "Not permitted") +)) + +# Normal message (connection stays open) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key" +)) + +# Unexpected disconnect (no message, just closes) +mock_ws.active_connection.simulate_disconnect() +``` + +## Proxy Integration Tests + +For detailed proxy infrastructure documentation, see `realtime/integration/helpers/proxy.md`. + +### When to Use Proxy Tests + +| Test type | When to use | +|-----------|-------------| +| **Unit test** (mock HTTP/WebSocket) | Client-side logic, state machines, request formation, error parsing. Fast, deterministic. | +| **Direct sandbox integration** | Happy-path behaviour: connect, publish, subscribe. No fault injection needed. | +| **Proxy integration test** | Fault behaviour against real backend: connection failures, resume, heartbeat starvation, token renewal under network errors, channel error injection. | + +### Proxy Test Structure + +```markdown +# Feature Name Proxy Integration Tests + +Spec points: `RTN14a`, `RTN14b`, ... + +## Test Type +Proxy integration test against Ably Sandbox endpoint + +## Proxy Infrastructure +See `realtime/integration/helpers/proxy.md` for proxy infrastructure specification. + +## Corresponding Unit Tests +- `realtime/unit/connection/connection_failures_test.md` — RTN15a, RTN15b + +## Sandbox Setup +[standard app provisioning — same as direct sandbox tests] + +--- + +## RTN14a - Test name + +**Test ID**: `realtime/proxy/RTN14a/fatal-connect-error-0` + +| Spec | Requirement | +|------|-------------| +| RTN14a | ... | + +**Corresponding unit test:** `connection_open_failures_test.md` RTN14a + +Tests that [behaviour] when the proxy injects [fault]. + +### Setup + +```pseudo +session = create_proxy_session( + target: TargetConfig(realtimeHost: "sandbox.realtime.ably-nonprod.net", restHost: "sandbox.realtime.ably-nonprod.net"), + rules: [{ + "match": { ... }, + "action": { ... }, + "times": 1, + "comment": "description" + }] +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +state_changes = [] +client.connection.on(change => state_changes.append(change.current)) + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 15 seconds +``` + +### Assertions + +```pseudo +ASSERT client.connection.state == ConnectionState.failed +ASSERT client.connection.errorReason.code == 40005 +``` +``` + +### Common Proxy Rule Patterns + +**Replace server response with error:** +```json +{ + "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, + "action": { + "type": "replace", + "message": { "action": 9, "error": { "code": 40005, "statusCode": 400, "message": "Error" } } + }, + "times": 1 +} +``` + +**Refuse connection (one-shot):** +```json +{ + "match": { "type": "ws_connect", "count": 1 }, + "action": { "type": "refuse_connection" }, + "times": 1 +} +``` + +**Suppress frame (cause timeout):** +```json +{ + "match": { "type": "ws_frame_to_server", "action": "ATTACH" }, + "action": { "type": "suppress" } +} +``` + +**Temporal trigger (timed fault injection):** +```json +{ + "match": { "type": "delay_after_ws_connect", "delayMs": 2000 }, + "action": { "type": "suppress_onwards" }, + "times": 1 +} +``` + +**Inject message to client:** +```json +{ + "match": { "type": "delay_after_ws_connect", "delayMs": 1000 }, + "action": { + "type": "inject_to_client_and_close", + "message": { "action": 6, "error": { "code": 40142, "statusCode": 401, "message": "Token expired" } } + }, + "times": 1 +} +``` + +**HTTP fault (return custom response):** +```json +{ + "match": { "type": "http_request", "pathContains": "/channels/" }, + "action": { + "type": "http_respond", + "status": 401, + "body": { "error": { "code": 40142, "statusCode": 401, "message": "Token expired" } } + }, + "times": 1 +} +``` + +### Proxy Test Conventions + +1. Each test references the spec point AND the corresponding unit test +2. Tests use `create_proxy_session()` with rules, then connect SDK through the proxy +3. Tests use `AWAIT_STATE` for state assertions and record state changes for sequence verification +4. Tests verify behaviour via SDK state AND proxy event log where useful +5. All tests use `useBinaryProtocol: false` (SDK doesn't implement msgpack) +6. All tests use `endpoint: "localhost"` which auto-disables fallback hosts (REC2c2) +7. Timeouts are generous (10-30s) since real network is involved +8. Each test file provisions a sandbox app in `BEFORE ALL TESTS` and cleans up in `AFTER ALL TESTS` +9. Each test creates its own proxy session and cleans it up after +10. Use imperative actions (`session.trigger_action()`) when you need to disconnect at a specific point in the test flow, rather than timing-based rules +11. Use `add_rules()` to add rules dynamically during a test (e.g., after channel attach succeeds, add a rule to suppress DETACH) + +### Proxy Event Log Assertions + +```pseudo +# Verify resume was attempted on reconnection +log = session.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +ASSERT ws_connects.length >= 2 +ASSERT ws_connects[1].queryParams["resume"] IS NOT null + +# Verify heartbeats=true in connection URL +ASSERT ws_connects[0].queryParams["heartbeats"] == "true" + +# Verify specific frames were sent +frames = log.filter(e => e.type == "ws_frame" AND e.direction == "client_to_server") +attach_frames = frames.filter(f => f.message.action == 10) # ATTACH = 10 +ASSERT attach_frames.length == 1 +``` + +## Spec Requirement Summaries + +**Every test must include a spec requirement summary immediately after the heading.** + +### Single Spec Format + +```markdown +## RSC7e - X-Ably-Version header + +**Spec requirement:** All REST requests must include the `X-Ably-Version` header with the spec version. + +Tests that all REST requests include the `X-Ably-Version` header. +``` + +### Multiple Specs Format (Use Table) + +```markdown +## RSC7d, RSC7d1, RSC7d2 - Ably-Agent header + +| Spec | Requirement | +|------|-------------| +| RSC7d | All requests must include Ably-Agent header | +| RSC7d1 | Header format: space-separated key/value pairs | +| RSC7d2 | Must include library name and version | + +Tests that all REST requests include the `Ably-Agent` header with correct format. +``` + +## Pseudocode Conventions + +### URI Path Component Encoding + +Use `encode_uri_component()` for any variable path segment or query parameter in URL assertions. This is defined in the UTS README. Always use exact equality (`==`) for path assertions, not `CONTAINS`. + +```pseudo +# Correct — exact path with encoded variable +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages" + +# Wrong — loose match, misses encoding bugs +ASSERT request.url.path CONTAINS "/channels/" +``` + +### Serialization and Deserialization + +Use `toJson()` and `fromJson()` as the portable pseudocode names for serializing to and deserializing from wire format. These are language-agnostic — implementations will map them to the appropriate mechanism (e.g., `toMap()`/`fromMap()` in Dart, `toJSON()`/`fromJSON()` in JavaScript, `to_dict()`/`from_dict()` in Python). + +```pseudo +# Serializing to wire format +json_data = message.toJson() +ASSERT json_data["action"] == 1 +ASSERT json_data["serial"] == "s1" + +# Deserializing from wire format +msg = Message.fromJson({ + "serial": "msg-serial-1", + "name": "test", + "data": "hello" +}) +ASSERT msg.serial == "msg-serial-1" +``` + +**Do NOT use language-specific names:** +```pseudo +# BAD - Dart-specific +map = message.toMap() +msg = Message.fromMap({...}) + +# BAD - Python-specific +d = message.to_dict() + +# GOOD - portable +json_data = message.toJson() +msg = Message.fromJson({...}) +``` + +### Type Assertions + +Type assertions verify object types/interfaces. Implementation varies by language: + +- **Strongly typed** (Dart, Swift, Kotlin, TypeScript): Use native type checks +- **Weakly typed** (JavaScript, Python, Ruby): Verify expected methods/properties exist + +```pseudo +# Pseudocode +ASSERT client.connection IS Connection + +# JavaScript - check interface compliance +assert(typeof client.connection.connect === 'function'); +assert(typeof client.connection.close === 'function'); + +# Dart - native type check +expect(client.connection, isA()); +``` + +### State Transitions + +State transitions may be synchronous or asynchronous. Use `AWAIT_STATE`: + +```pseudo +# If already in state, proceed immediately +# Otherwise wait for state change event until condition is met +AWAIT_STATE client.connection.state == ConnectionState.connecting +``` + +This means implementations should: +- Check if condition is already true -> proceed +- Otherwise wait for state change events with timeout +- Fail if timeout expires + +## Timer Mocking + +Tests verifying timeout behavior should use timer mocking where practical to avoid slow tests. + +**Approaches (in order of preference):** + +1. **Mock/fake timers** (JavaScript Jest, Python freezegun) + ```pseudo + enable_fake_timers() + request_future = client.time() + ADVANCE_TIME(1000) # Instantly trigger timeout + AWAIT request_future # Should fail with timeout + ``` + +2. **Dependency injection** (Go, Swift, Kotlin) + - Library accepts clock interface in tests + - Test provides controllable implementation + +3. **Short timeouts** (fallback if mocking unavailable) + ```pseudo + client = Rest(options: ClientOptions(httpRequestTimeout: 50)) + ``` + +4. **Actual delays** (last resort) + +Use `ADVANCE_TIME(milliseconds)` in pseudocode to indicate time progression. + +## Sandbox App Management + +Create apps **once** per test run, **explicitly delete** when complete: + +```pseudo +BEFORE ALL TESTS: + app_config = POST https://sandbox.realtime.ably-nonprod.net/apps + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +## Unique Channel Names + +Construct channel names with: +1. **Descriptive part** - test name or spec ID +2. **Random part** - base64-encoded random bytes (e.g., 6 bytes = 48 bits) + +Example: `test-RSL1-publish-${base64(random_bytes(6))}` + +Tests using channels should use uniquely-named channels to avoid: +- Collisions between concurrent tests +- Server-side side-effects from previous test runs +- State leakage between test cases + +## Authentication Testing + +### Do NOT use `time()` for auth testing + +The `/time` endpoint does NOT require authentication (RSC16). Using it for auth tests will give misleading results. + +**Key behaviors of `time()`:** +- Does not send Authorization header, even when client has credentials +- Works over non-TLS connections (RSC18 doesn't apply) +- Does not trigger token acquisition + +**Use `channel.status()` instead** for testing authentication: +```pseudo +# For auth tests, use channel status which requires authentication +status = AWAIT client.channels.get("test").status() + +# Verify auth header was sent +ASSERT request.headers["Authorization"] == "Bearer token" +``` + +### Constructor still requires authentication credentials + +While `time()` doesn't require auth, the **client constructor still requires credentials**. You must provide one of: +- `key` (API key) +- `authCallback` +- `authUrl` +- `token` or `tokenDetails` + +**Wrong - constructor will reject:** +```pseudo +# This fails with 40106 "No authentication method provided" +client = Rest(options: ClientOptions(tls: false)) +``` + +**Correct - provide credentials, but time() won't use them:** +```pseudo +# Constructor accepts credentials, time() doesn't send them +client = Rest(options: ClientOptions(key: "app.key:secret")) +result = AWAIT client.time() +ASSERT "Authorization" NOT IN request.headers # time() doesn't send auth +``` + +### RSC18 only applies to Basic auth configurations + +The RSC18 restriction (no Basic auth over non-TLS) is checked at **client construction time**. The error is thrown immediately when creating a client that would use Basic auth over non-TLS. + +**RSC18 check triggers when:** +- API key is provided AND +- `tls: false` AND +- No `clientId` (which would force token auth) AND +- No `useTokenAuth: true` AND +- No authCallback/authUrl/token + +**Testing RSC18:** +```pseudo +# RSC18 test - Basic auth over HTTP rejected at construction +TRY: + client = Rest(options: ClientOptions(key: "app.key:secret", tls: false)) + FAIL("Expected exception at construction") +CATCH AblyException as e: + ASSERT e.code == 40103 + +# Token auth over HTTP allowed - client can be constructed +client = Rest(options: ClientOptions(token: "token", tls: false)) +status = AWAIT client.channels.get("test").status() # Works fine +ASSERT request.url.scheme == "http" +ASSERT request.headers["Authorization"] == "Bearer token" +``` + +**Why `time()` works over non-TLS with any client:** +Since `time()` uses `authenticated: false`, it never sends credentials, so RSC18 doesn't apply to it. A client configured for Basic auth can still call `time()` - it just can't make authenticated requests. + +## Token Testing + +Test with **both** token formats: +1. **JWTs** (primary) - Use a third-party JWT library for integration tests +2. **Ably native tokens** - Obtained via `requestToken()` + +For unit tests, any string works as a token value since tokens are opaque to the library. + +## Avoiding Flaky Tests + +**Never use fixed WAITs or arbitrary real-time delays.** + +```pseudo +# Bad - flaky +WAIT 5 seconds +ASSERT condition + +# Bad - arbitrary delay that may not be enough (or may be too slow) +ADVANCE_TIME(3000) +WAIT 100ms # Real-time delay - flaky! +ASSERT state == disconnected + +# Good - poll until condition +poll_until( + condition, + interval: 500ms, + timeout: 10s +) + +# Good - advance time and wait for state +ADVANCE_TIME(3000) +AWAIT_STATE state == disconnected +``` + +### Verifying Transient States (Record-and-Verify Pattern) + +**When testing disconnect/reconnect behavior, always use the record-and-verify pattern.** Do not use intermediate `AWAIT_STATE` calls to observe transient states like DISCONNECTED or SUSPENDED mid-test. The Ably spec mandates immediate reconnection on unexpected disconnect (RTN15a), which means transient states pass too quickly to be reliably observed between test steps. + +**The pattern:** + +1. Start recording state changes before triggering the behavior +2. Let the full cycle play out (disconnect -> reconnect) +3. Assert the recorded sequence at the end with `CONTAINS_IN_ORDER` + +```pseudo +# 1. Record state changes +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +# 2. Trigger disconnect and let cycle complete +ws_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# 3. Verify the full sequence at the end +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] +``` + +**`CONTAINS_IN_ORDER` semantics:** This assertion verifies that the listed states appear in the recorded sequence in the correct order, but does not require them to be the *only* states present. This allows for implementation-specific intermediate states (e.g., additional CONNECTING states between retries) without causing false failures. + +**Why NOT intermediate `AWAIT_STATE`:** + +```pseudo +# BAD - unreliable, DISCONNECTED may pass before this line executes +ws_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected # May miss it! +ADVANCE_TIME(6000) +AWAIT_STATE client.connection.state == ConnectionState.connected + +# GOOD - record everything, verify at the end +state_changes = [] +client.connection.on((change) => { state_changes.append(change.current) }) +ws_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.connected +ASSERT state_changes CONTAINS_IN_ORDER [disconnected, connecting, connected] +``` + +### Time-Advancement Loops for Retry Scenarios + +When tests involve multiple retries with fake timers (e.g., reconnection attempts that fail before eventually succeeding, or waiting for TTL expiry), use a **time-advancement loop** rather than calculating exact `ADVANCE_TIME` durations. This is more robust because: + +- The exact timing of retries, backoff, and state transitions is implementation-dependent +- A loop naturally accommodates varying numbers of retries +- It mirrors what the real-world clock does: time passes continuously, not in exact jumps + +```pseudo +enable_fake_timers() + +# Trigger disconnect, then advance time in increments +# until the client reconnects or we give up +ws_connection.simulate_disconnect() + +LOOP up to 15 times: + ADVANCE_TIME(2500) + IF client.connection.state == ConnectionState.connected: + BREAK + +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +Use this pattern when: +- Reconnection attempts may fail multiple times before succeeding +- The test needs to advance through multiple retry/backoff cycles +- State transitions depend on cumulative elapsed time (e.g., `connectionStateTtl` expiry triggering SUSPENDED) + +The final `AWAIT_STATE` after the loop acts as a safety net in case the loop iterations weren't quite enough. + +## Test Structure + +Each test should have three sections: + +### Setup +```pseudo +request_count = 0 +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + request_count++ + req.respond_with(200, {...}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.operation() +``` + +### Assertions +```pseudo +ASSERT result.field == expected +ASSERT request_count == 1 +ASSERT captured_requests[0].headers["Authorization"] == "Bearer token" +``` + +## Common Mock Patterns + +### Capturing All Requests + +```pseudo +captured_requests = [] + +onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {...}) +} +``` + +### Different Responses by Count + +```pseudo +request_count = 0 + +onRequest: (req) => { + request_count++ + IF request_count == 1: + req.respond_with(500, {...}) + ELSE: + req.respond_with(200, {...}) +} +``` + +### Different Responses by URL + +```pseudo +onRequest: (req) => { + IF req.url.path CONTAINS "/time": + req.respond_with(200, {"time": ...}) + ELSE IF req.url.path CONTAINS "/channels": + req.respond_with(200, [...]) +} +``` + +### Connection-Level Failures + +```pseudo +connection_count = 0 + +onConnectionAttempt: (conn) => { + connection_count++ + IF connection_count == 1: + conn.respond_with_refused() # Or timeout, dns_error + ELSE: + conn.respond_with_success() +} +``` + +## Common Assertion Patterns + +```pseudo +ASSERT value == expected +ASSERT value IS Type +ASSERT value IN list +ASSERT value matches pattern "regex" +ASSERT "key" IN object +ASSERT "key" NOT IN object +ASSERT value STARTS WITH "prefix" +ASSERT value CONTAINS "substring" +``` + +## Error Testing Pattern + +Use the `FAILS WITH error` pattern to test operations that should fail. This pattern: +- Explicitly ties the error to the specific operation that caused it +- Is language-agnostic (works for exceptions, Result types, error returns, etc.) +- Focuses on ErrorInfo fields rather than exception type names + +```pseudo +# Synchronous operation that fails +client.channels.get("channel", invalidOptions) FAILS WITH error +ASSERT error.code == 40000 + +# Async operation that fails +AWAIT client.auth.authorize(invalidParams) FAILS WITH error +ASSERT error.code == 40160 +ASSERT error.statusCode == 401 +``` + +**Do NOT use language-specific exception patterns:** +```pseudo +# BAD - assumes exceptions, names specific exception types +TRY: + AWAIT operation_that_fails() + FAIL("Expected exception") +CATCH AblyException as e: + ASSERT e.code == 40160 +``` + +The error object in `FAILS WITH error` represents the ErrorInfo associated with the failure. Implementations should verify the appropriate ErrorInfo fields (code, statusCode, message) regardless of how errors are propagated in that language. + +## Key Spec Points to Remember + +| Spec | Behavior | +|------|----------| +| RSA4b | key + clientId triggers token auth (not basic auth) | +| RSA4b4 | Token renewal on 40140-40149 errors | +| RSA8d | authCallback returns TokenDetails, TokenRequest, or JWT string | +| RSC16 | time() does NOT require authentication - doesn't send auth headers even with credentials | +| RSC18 | Basic auth requires TLS - only applies to authenticated operations (not time()) | +| RSC15l | Fallback on: host unreachable, timeout, HTTP 5xx | +| 40103 | Cannot use Basic auth over non-TLS | +| 40106 | No authentication method configured (constructor rejects) | +| 40171 | Token expired with no means of renewal | +| 40160 | Not permitted (capability error) | +| 40012 | Incompatible clientId | +| 40142 | Token expired | +| 40140 | Token error | + +## File Organization + +``` +rest/ + unit/ + helpers/ + mock_http.md # Mock HTTP infrastructure spec + auth/ + auth_callback.md # RSA8c, RSA8d + auth_scheme.md # RSA1-4, RSA4b + authorize.md # RSA10 + token_renewal.md # RSA4b4, RSA14 + client_id.md # RSA7, RSC17 + channel/ + publish.md # RSL1 + history.md # RSL2 + idempotency.md # RSL1k + rest_client.md # RSC7, RSC8, RSC13, RSC18 + fallback.md # RSC15, REC1, REC2 + time.md # RSC16 + stats.md # RSC6 + request.md # RSC19 + batch_publish.md # RSC22, BSP, BPR, BPF + presence/ + rest_presence.md # RSP1, RSP3, RSP4 + encoding/ + message_encoding.md # RSL4, RSL5, RSL6 + types/ + message_types.md # TM2, TM3, TM4 + error_types.md # TI1-5 + token_types.md # TD1-5, TK1-6, TE1-6 + options_types.md # TO3, AO2 + paginated_result.md # TG1-5 + integration/ + auth.md + publish.md + history.md + presence.md + pagination.md + time_stats.md +realtime/ + unit/ + helpers/ + mock_websocket.md # Mock WebSocket infrastructure spec + client/ + realtime_client.md # RTC1, RTC2, RTC15, RTC16 + client_options.md # TO3 (Realtime-specific) + connection/ + connection_failures_test.md + connection_open_failures_test.md + ... + integration/ + helpers/ + proxy.md # Proxy infrastructure spec + proxy/ + connection_open_failures.md # RTN14 tests via proxy + connection_resume.md # RTN15 tests via proxy + heartbeat.md # RTN23 tests via proxy + channel_faults.md # RTL4, RTL5, RTL13, RTL14 via proxy + rest_faults.md # RSC10, RSC15 via proxy + connection_lifecycle_test.md # Direct sandbox tests + ... +``` + +## Completion Status Matrix + +When adding a new test spec, update the completion status matrix at `docs/completion-status.md` to reflect the newly covered spec items. This matrix tracks which spec items have UTS test specs and which do not. + +## Writing Tips + +1. **Reference spec points** in test names and file headers +2. **Add spec requirement summaries** at the start of each test +3. **One concept per test** - don't combine unrelated assertions +4. **Describe what you're testing** - not implementation details +5. **Include error codes** when testing error conditions +6. **Mock responses realistically** - include all fields the real API returns +7. **Test both success and failure paths** +8. **Verify request formation** - check headers, path, body, query params +9. **Consider edge cases** - empty results, pagination boundaries, expired tokens +10. **Use handler pattern for simple tests**, await pattern for complex coordination +11. **Distinguish connection-level vs request-level failures** +12. **Use unique channel names** to avoid test interference +13. **Update `docs/completion-status.md`** when adding new test specs + +## Example Test Spec (Modern Pattern) + +```markdown +# Feature Name Tests + +Spec points: `RSA4`, `RSA8` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. + +--- + +## RSA4 - Descriptive test name + +**Test ID**: `rest/unit/RSA4/auth-selection-0` + +**Spec requirement:** Brief description of what the spec requires. + +Tests that [specific behavior being tested]. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"result": "success"}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.operation() +``` + +### Assertions +```pseudo +ASSERT result.field == "success" +ASSERT captured_requests[0].method == "GET" +ASSERT captured_requests[0].headers["Authorization"] IS NOT null +``` +``` + +## Pattern Decision Tree + +**Choose handler pattern when:** +- Response is predetermined +- Simple pass-through scenarios +- No need to inspect request before responding + +**Choose await pattern when:** +- Need to respond based on test execution state +- Need to coordinate timing with other operations +- Complex scenarios requiring request inspection before response +- Testing connection-level failures separately from request handling + +## Common Mistakes to Avoid + +1. Using `mock_http.queue_response()` (old pattern) -- Use `onRequest: (req) => req.respond_with(...)` instead +2. Referencing `mock_http.captured_requests` -- Use local `captured_requests` array +3. Referencing `mock_http.request_count` -- Use local `request_count` variable +4. Not installing mock: Missing `install_mock(mock_http)` -- Always call `install_mock(mock_http)` after creating mock +5. Passing mock to client: `Rest(..., httpClient: mock_http)` -- Mock is installed globally via `install_mock()` +6. Missing spec requirement summary -- Every test must have `**Spec requirement:**` or table +7. Using fixed WAITs for async operations -- Use polling with timeout or `AWAIT_STATE` +8. Not using unique channel names -- Generate unique names with random component +9. Synchronous state assertions: `ASSERT state == connecting` -- Use `AWAIT_STATE state == connecting` +10. Missing connection handler: Only defining `onRequest` -- Always include `onConnectionAttempt: (conn) => conn.respond_with_success()` +11. Using `send_to_client()` for DISCONNECTED or connection-level ERROR -- Use `send_to_client_and_close()` - server closes connection after these messages +12. Using `send_to_client_and_close()` for channel-level ERROR -- Use `send_to_client()` - ERROR with channel doesn't close connection +13. Using `time()` to test authentication behavior -- Use `channel.status()` - time() doesn't require or send auth +14. Creating client without credentials for time() tests: `ClientOptions(tls: false)` -- Constructor requires credentials +15. Using intermediate `AWAIT_STATE disconnected` to observe transient states mid-test -- Record all state changes and use `CONTAINS_IN_ORDER` to verify the sequence at the end +16. Using exact `ADVANCE_TIME` calculations for multi-retry scenarios -- Use a time-advancement loop +17. Loose path assertions: `ASSERT request.url.path CONTAINS "/channels/"` -- Use exact path with encoding +18. Mock echo missing fields that the test later asserts on -- Include all fields in the mock echo that the test assertions depend on +19. Using language-specific serialization names: `toMap()`, `fromMap()`, `to_dict()` -- Use portable `toJson()` / `fromJson()` + +### Keeping UTS and Derived Tests in Sync + +When a derived test reveals a bug or gap in a UTS spec (or vice versa), **always update both**. Common cases: +- Mock missing a field (e.g. `data: p.data` in a PRESENCE echo) -- fix in both +- Loop index bugs (e.g. hardcoded `:0` instead of `:${idx}`) -- fix in both +- Language-specific patterns (e.g. `authCallback` to avoid real HTTP for clientId) don't need UTS changes, but note the reason if the approaches differ significantly diff --git a/uts/objects/PLAN.md b/uts/objects/PLAN.md new file mode 100644 index 000000000..3cc547856 --- /dev/null +++ b/uts/objects/PLAN.md @@ -0,0 +1,379 @@ +# UTS Test Specs for LiveObjects Path-Based API + +## Context + +The LiveObjects feature lets clients store shared CRDT data on realtime channels. The specification is at `specification/specifications/objects-features.md` — specifically the path-based API version on branch `origin/AIT-30/liveobjects-path-based-api-spec` (with batch API additions on `origin/AIT-30/liveobjects-batch-api`). + +An earlier attempt at UTS test specs exists in `uts/test/realtime/unit/objects/` (14 files). It was written against a different spec namespace (PO* vs RTPO*/RTINS*/RTLCV*/RTLMV*), used v5 wire format field names, had apply-on-ACK contradictions, and duplicated setup across files. We're doing a clean rewrite using the correct spec, informed by that earlier work. + +All new test files go in `specification/uts/objects/`. + +## Spec Architecture Summary + +**Internal (not user-facing):** LiveObject, LiveCounter (CRDT counter), LiveMap (LWW map), ObjectsPool (sync state machine), RealtimeObject (channel orchestrator with publishAndApply) + +**Public (user-facing):** PathObject (lazy path reference), Instance (identity-bound reference), LiveCounterValueType/LiveMapValueType (creation descriptors via static `create()` factories), BatchContext (atomic multi-op publish) + +**Wire protocol v6:** `counterInc.number`, `mapSet.{key,value}`, `mapRemove.key`, `mapCreate.{semantics,entries}`, `counterCreateWithObjectId.{nonce,initialValue}`, `mapCreateWithObjectId.{nonce,initialValue}` + +**REST API:** Not specified in objects-features.md. ably-js has REST object tests but those are implementation-specific, not spec'd. No REST test files needed. + +--- + +## File Organization + +### Helper +| File | Purpose | +|------|---------| +| `helpers/standard_test_pool.md` | Shared: standard ObjectsPool fixture, protocol message builders, synced-channel setup pattern | + +### Pure Unit Tests (no mocks) +| File | Spec Points | ~Tests | +|------|-------------|--------| +| `unit/live_counter.md` | RTLC1-4, RTLC6-9, RTLC14, RTLC16, RTLO3, RTLO4a, RTLO4e, RTLO5, RTLO6 | ~28 | +| `unit/live_map.md` | RTLM1-9, RTLM14-16, RTLM18-19, RTLM22-25, RTLO3, RTLO4a, RTLO4e, RTLO5, RTLO6 | ~42 | +| `unit/objects_pool.md` | RTO3-9 | ~35 | +| `unit/object_id.md` | RTO14 | ~5 | +| `unit/value_types.md` | RTLCV1-4, RTLMV1-4 (consumption generates ObjectMessages with v6 wire format) | ~19 | + +### Mock WebSocket Unit Tests +| File | Spec Points | ~Tests | +|------|-------------|--------| +| `unit/realtime_object.md` | RTO2, RTO10, RTO15-20, RTO22-24 (sync events, publish, publishAndApply, mode checks, GC) | ~33 | +| `unit/live_counter_api.md` | RTLC5, RTLC11-13 (value, increment, decrement through channel) | ~13 | +| `unit/live_map_api.md` | RTLM5, RTLM10-13, RTLM20-21, RTLM24 (reads + mutations through channel, echoMessages check) | ~18 | +| `unit/live_object_subscribe.md` | RTLO4b, RTLO4c (subscribe/unsubscribe on internal LiveObject) | ~8 | +| `unit/path_object.md` | RTPO1-14 (navigation, value, instance, entries, compact, compactJson) | ~33 | +| `unit/path_object_mutations.md` | RTPO15-18, RTPO3c2 (set, remove, increment, decrement, error on unresolvable path) | ~12 | +| `unit/path_object_subscribe.md` | RTPO19-21, RTO24 (path subscriptions, depth filtering, path-following semantics, subscribeIterator) | ~20 | +| `unit/instance.md` | RTINS1-18 (id, value, get, entries, size, compact, set, remove, increment, subscribe) | ~26 | +| `unit/batch.md` | RTPO22, RTINS19, RTBC1-16 (batch entry, BatchContext methods, RootBatchContext flush/close) | ~20 | + +### Integration Tests (sandbox) +| File | Spec Points | ~Tests | +|------|-------------|--------| +| `integration/objects_lifecycle_test.md` | RTO23, RTPO15, RTPO17 (create objects, mutate via PathObject, read back, REST provisioning) | ~6 | +| `integration/objects_sync_test.md` | RTO4, RTO5, RTO17 (attach, sync sequence, re-attach) | ~4 | +| `integration/objects_batch_test.md` | RTPO22, RTBC12-15 (batch publish, atomic delivery) | ~3 | +| `integration/objects_gc_test.md` | RTO10, RTLM19 (behavioral GC verification with ADVANCE_TIME) | ~2 | + +### Proxy Integration Tests +| File | Spec Points | ~Tests | +|------|-------------|--------| +| `integration/proxy/objects_faults.md` | RTO5a2, RTO7, RTO8, RTO17, RTO20e (sync interruption, mutation buffering during re-sync, server-initiated detach, publish failure on FAILED channel, publish during delayed sync) | ~5 | + +**Totals: ~21 files, ~330 tests** + +--- + +## Helper Spec Design + +### `helpers/standard_test_pool.md` + +**Standard test tree:** +``` +root (LiveMap, objectId: "root") + +-- "name" -> string "Alice" + +-- "age" -> number 30 + +-- "active" -> boolean true + +-- "score" -> objectId "counter:score@1000" + +-- "profile" -> objectId "map:profile@1000" + +-- "data" -> json {"tags": ["a", "b"]} + +-- "avatar" -> bytes base64("AQID") (raw bytes: [1, 2, 3]) + +counter:score@1000 (LiveCounter, data: 100) + +map:profile@1000 (LiveMap) + +-- "email" -> string "alice@example.com" + +-- "nested_counter" -> objectId "counter:nested@1000" + +-- "prefs" -> objectId "map:prefs@1000" + +counter:nested@1000 (LiveCounter, data: 5) + +map:prefs@1000 (LiveMap) + +-- "theme" -> string "dark" +``` + +**Builder functions:** +- `build_object_sync_message(channel, channelSerial, objectMessages[])` -> OBJECT_SYNC ProtocolMessage +- `build_object_message(channel, objectMessages[])` -> OBJECT ProtocolMessage +- `build_ack_message(msgSerial, serials[])` -> ACK ProtocolMessage with `res: [{ serials }]` +- `build_counter_inc(objectId, number, serial, siteCode)` -> ObjectMessage +- `build_map_set(objectId, key, value, serial, siteCode)` -> ObjectMessage +- `build_map_remove(objectId, key, serial, siteCode, serialTimestamp?)` -> ObjectMessage +- `build_map_clear(objectId, serial, siteCode)` -> ObjectMessage +- `build_object_delete(objectId, serial, siteCode, serialTimestamp?)` -> ObjectMessage +- `build_counter_create(objectId, counterCreate, serial, siteCode)` -> ObjectMessage +- `build_map_create(objectId, mapCreate, serial, siteCode)` -> ObjectMessage +- `build_object_state(objectId, siteTimeserials, {map?, counter?, tombstone?, createOp?})` -> ObjectMessage wrapping ObjectState + +**Standard synced-channel pattern** (referenced by all mock-WS test files): +```pseudo +setup_synced_channel(channel_name): + mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", + siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, + channelSerial: "attach-serial-1", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message( + msg.channel, "sync1:", STANDARD_POOL_OBJECTS + )) + ELSE IF msg.action == OBJECT: + // Auto-ACK with generated serials + serials = msg.state.map((_, i) => "ack-serial-" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } + ) + install_mock(mock_ws) + client = Realtime(options: {key: "fake:key", autoConnect: true}) + channel = client.channels.get(channel_name, {modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"]}) + root = AWAIT channel.object.get() + RETURN {client, channel, root, mock_ws} +``` + +--- + +## Pure Unit Test Design + +### `unit/live_counter.md` -- CRDT Counter Data Structure + +Directly construct `LiveCounter`, call `applyOperation()` and `replaceData()`, assert internal state. + +**Key test groups:** +1. **Zero value (RTLC4):** data=0, siteTimeserials={}, createOperationIsMerged=false, isTombstone=false +2. **COUNTER_INC (RTLC9):** adds `counterInc.number` to data; noop when number missing +3. **COUNTER_CREATE (RTLC8/RTLC16):** merges `counterCreate.count`; noop when already merged +4. **Newness check (RTLO4a):** empty siteSerial allows apply; stale serial rejected; empty serial/siteCode logs warning +5. **siteTimeserials (RTLC7c):** CHANNEL source updates map; LOCAL source does not +6. **applyOperation returns bool (RTLC7g):** true on success, false on rejection/tombstone +7. **Tombstone (RTLC7e, RTLO4e, RTLO5):** OBJECT_DELETE tombstones; ops on tombstoned counter rejected +8. **replaceData (RTLC6):** full replacement; tombstone handling; createOp merge; diff calculation +9. **tombstonedAt (RTLO6):** from serialTimestamp if present, else local clock + +### `unit/live_map.md` -- LWW Map Data Structure + +Same pattern. Key additional concerns: + +1. **MAP_SET (RTLM7):** new entry, existing entry update, LWW rejection, clearTimeserial floor (RTLM7h), objectId creates zero-value object (RTLM7g) +2. **MAP_REMOVE (RTLM8):** tombstones entry, sets tombstonedAt via RTLO6, clearTimeserial floor (RTLM8g) +3. **MAP_CLEAR (RTLM24):** sets clearTimeserial, removes entries with serial <= clear serial, preserves newer entries +4. **Entry-level LWW (RTLM9):** 5 serial comparison cases +5. **MAP_CREATE (RTLM16/RTLM23):** merges entries via individual MAP_SET/MAP_REMOVE calls +6. **replaceData (RTLM6):** sets clearTimeserial from ObjectState.map.clearTimeserial (RTLM6i) +7. **get/size/entries (RTLM5/RTLM10/RTLM11):** value resolution, tombstone filtering, objectId reference resolution +8. **GC (RTLM19):** removes tombstoned entries past grace period +9. **Diff (RTLM22):** non-tombstoned entry comparison + +### `unit/objects_pool.md` -- Pool + Sync State Machine + +Directly construct ObjectsPool, call `processAttached()`, `processObjectSync()`, `processObjectMessage()`. + +1. **Initialization (RTO3):** root LiveMap always present +2. **ATTACHED handling (RTO4):** HAS_OBJECTS -> SYNCING; no flag -> clear pool + immediate SYNCED +3. **OBJECT_SYNC sequence (RTO5/RTO5f):** accumulate in SyncObjectsPool; partial merge (RTO5f2a); cursor parsing; new sequence discards old (RTO5a2) +4. **Sync completion (RTO5c):** replace existing (RTO5c1a), create new (RTO5c1b), remove absent (RTO5c2), emit updates (RTO5c7), apply buffered ops (RTO5c6), clear appliedOnAckSerials (RTO5c9), transition to SYNCED (RTO5c8) +5. **Buffering (RTO7/RTO8):** OBJECT messages buffered during SYNCING, applied when SYNCED +6. **Operation application (RTO9):** appliedOnAckSerials dedup (RTO9a3), LOCAL source adds to set (RTO9a2a4), null op warning (RTO9a1), unsupported action warning (RTO9a2b) +7. **Zero-value creation (RTO6):** infer type from objectId prefix +8. **GC (RTO10):** tombstoned objects removed after grace period + +### `unit/object_id.md` -- ObjectId Generation (RTO14) + +Pure function tests: +1. Format: `{type}:{base64url(SHA-256(initialValue:nonce))}@{timestamp}` +2. SHA-256 of UTF-8 `{initialValue}:{nonce}` -> base64url (RFC 4648 s.5) +3. `map` and `counter` type prefixes +4. Deterministic: same inputs -> same objectId +5. Different nonce -> different objectId + +### `unit/value_types.md` -- LiveCounterValueType / LiveMapValueType + +Tests the static `create()` factories and consumption procedure. + +**LiveCounterValueType (RTLCV1-4):** +1. `LiveCounter.create(42)` -> immutable LiveCounterValueType with count=42 +2. `LiveCounter.create()` -> count defaults to 0 +3. Consumption: validates count, builds CounterCreate, generates objectId, returns ObjectMessage with `counterCreateWithObjectId.{nonce, initialValue}` +4. Non-number count throws 40003 during consumption + +**LiveMapValueType (RTLMV1-4):** +1. `LiveMap.create({entries})` -> immutable LiveMapValueType +2. Consumption: validates keys/values, builds entries, generates objectId, returns ObjectMessage with `mapCreateWithObjectId.{nonce, initialValue}` +3. Nested value types: LiveMapValueType containing LiveCounterValueType -> depth-first ObjectMessage array (inner creates before outer) +4. Retains local MapCreate/CounterCreate alongside wire format (RTLMV4j5/RTLCV4g5) + +--- + +## Mock WebSocket Test Design + +### `unit/realtime_object.md` -- Orchestration + +Uses `setup_synced_channel()` from helper. + +**Key tests:** +- **RTO23:** get() requires OBJECT_SUBSCRIBE, throws on DETACHED/FAILED, waits for SYNCED, returns PathObject +- **RTO2:** channel mode enforcement (granted vs requested modes) +- **RTO15/RTO15h:** publish sends OBJECT PM, returns PublishResult from ACK res array +- **RTO20:** publishAndApply: publishes, constructs synthetic messages with siteCode from ConnectionDetails, applies with source=LOCAL, adds to appliedOnAckSerials +- **RTO20c:** fails gracefully when siteCode or serials missing +- **RTO20d1:** null serial in PublishResult (conflated op) is skipped +- **RTO20e:** waits for SYNCED during SYNCING; fails with 92008 if channel enters DETACHED/SUSPENDED/FAILED +- **RTO17/RTO18/RTO19:** sync state events, on/off registration +- **RTO10:** GC with fake timers + ADVANCE_TIME + +### `unit/path_object.md` -- Read Operations + +- **RTPO4:** path() string representation with dot escaping +- **RTPO5/RTPO6:** get(key) / at("a.b.c") -- pure navigation, no resolution +- **RTPO7:** value() -- counter returns number, primitive returns value, LiveMap returns null, unresolvable returns null +- **RTPO8:** instance() -- LiveObject returns Instance, primitive returns null +- **RTPO9-11:** entries/keys/values -- yields [key, PathObject] pairs for LiveMap entries +- **RTPO12:** size() -- non-tombstoned entry count +- **RTPO13:** compact() -- recursive, cycle detection with shared object references +- **RTPO14:** compactJson() -- binary as base64, cycles as {objectId: ...} +- **RTPO3:** path resolution (RTPO3a): walk segments through LiveMaps; fail if intermediate not LiveMap + +### `unit/path_object_mutations.md` -- Write Operations + +- **RTPO15:** set(value) -- constructs ObjectMessages, calls publishAndApply +- **RTPO16:** remove() -- constructs MAP_REMOVE ObjectMessage +- **RTPO17:** increment(n) -- constructs COUNTER_INC ObjectMessage +- **RTPO18:** decrement(n) -- delegates to increment(-n) +- **RTPO3c2:** mutation on unresolvable path throws 92007 + +### `unit/path_object_subscribe.md` -- Path-Based Subscriptions + +- **RTPO19:** subscribe returns Subscription, listener receives PathObjectSubscriptionEvent +- **RTPO19b1:** depth filtering -- depth=1 (self only), depth=2 (self+children), undefined (all) +- **RTPO19b1d:** non-positive depth throws 40003 +- **RTPO19e:** follows path not identity -- object replacement at path -> subscription tracks new object +- **RTPO19f:** child events bubble up to parent subscription +- **RTO24b3:** depth formula: `eventPath.length - subscriptionPath.length + 1 <= depth` +- **RTO24b5:** listener exception caught, doesn't affect other listeners +- **RTPO20:** unsubscribe deregisters + +### `unit/instance.md` -- Identity-Bound Reference + +- **RTINS1:** id property returns objectId +- **RTINS2:** value() -- counter returns number, map returns null +- **RTINS3-5:** get(key), entries(), keys(), values() -- delegate to underlying LiveMap +- **RTINS6:** size() -- non-tombstoned entry count +- **RTINS7:** compact() -- recursive with cycle detection +- **RTINS8:** compactJson() +- **RTINS9-12:** set, remove, increment, decrement -- construct ObjectMessages, call publishAndApply +- **RTINS13-16:** subscribe/unsubscribe with depth filtering +- **RTINS17:** instance follows identity not path -- object replacement at path doesn't affect Instance +- **RTINS18:** operations on tombstoned Instance throw error + +### `unit/live_counter_api.md` -- Counter Through Channel + +- **RTLC5:** value property returns current data +- **RTLC11/RTLC12:** increment/decrement construct correct v6 wire ObjectMessage +- **RTLC12d:** echoMessages=false skips publishAndApply, uses publish +- **RTLC13:** increment with non-number throws 40003 + +### `unit/live_map_api.md` -- Map Through Channel + +- **RTLM5:** get(key) returns resolved value +- **RTLM10/RTLM11:** entries/keys/values iterate non-tombstoned entries +- **RTLM12/RTLM13:** set/remove construct correct v6 wire ObjectMessages +- **RTLM20:** set with LiveCounterValueType/LiveMapValueType consumes value type +- **RTLM20d/RTLM21d:** echoMessages=false uses publish instead of publishAndApply +- **RTLM24:** clear constructs MAP_CLEAR ObjectMessage + +### `unit/live_object_subscribe.md` -- Internal Subscription + +- **RTLO4b:** subscribe(listener) registers on internal LiveObject +- **RTLO4c:** unsubscribe removes listener +- Events fire on applyOperation with update details + +### `unit/batch.md` -- Batch API + +- **RTPO22/RTINS19:** batch entry points -- resolve to LiveObject, create RootBatchContext, execute fn, flush +- **RTPO22c/RTINS19c:** unresolvable path / non-LiveObject throws 92007 +- **RTBC3-11:** read methods delegate to Instance (id, value, get, entries, keys, values, size, compact, compactJson) +- **RTBC4d:** get() wraps result via RootBatchContext#wrapInstance (memoized by objectId -- RTBC16c) +- **RTBC12-15:** write methods (set, remove, increment, decrement) queue message constructors synchronously +- **RTBC16d:** flush executes constructors, publishes all as single array via RTO15 (NOT publishAndApply) +- **RTBC16e:** closed batch throws 40000 on any method call +- **RTBC16f:** RootBatchContext closed after flush regardless of success/failure + +--- + +## Apply-on-ACK Testing Strategy + +The RTO20 publishAndApply flow: +1. Client publishes OBJECT PM +2. Server returns ACK with `res: [{ serials: [...] }]` +3. Client constructs synthetic inbound ObjectMessages (serial + siteCode from ConnectionDetails) +4. Applies via RTO9 with source=LOCAL -> adds serials to `appliedOnAckSerials` +5. When echoed OBJECT PM arrives with same serial -> RTO9a3 deduplicates and removes from set + +**Mock WS handler for mutation tests:** +```pseudo +onMessageFromClient: (msg) => { + IF msg.action == OBJECT: + serials = [] + FOR i IN 0..msg.state.length-1: + serials.append("ack-" + msg.msgSerial + "-" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) +} +``` + +**Tests verify:** +1. After `AWAIT pathObject.set(...)`, local state reflects the change +2. The correct OBJECT PM was sent (v6 wire format) +3. When echo arrives with same serial, no double-application +4. If ACK arrives during SYNCING (RTO20e), publishAndApply waits for SYNCED + +--- + +## Dependency Ordering (write order) + +1. `helpers/standard_test_pool.md` +2. `unit/live_counter.md` -- no dependencies +3. `unit/live_map.md` -- no dependencies +4. `unit/object_id.md` -- no dependencies +5. `unit/objects_pool.md` -- uses LiveCounter/LiveMap concepts +6. `unit/value_types.md` -- uses objectId generation +7. `unit/realtime_object.md` -- uses helper, tests orchestration +8. `unit/live_counter_api.md` -- uses helper +9. `unit/live_map_api.md` -- uses helper +10. `unit/live_object_subscribe.md` -- uses helper +11. `unit/path_object.md` -- uses helper +12. `unit/instance.md` -- uses helper +13. `unit/path_object_mutations.md` -- uses helper +14. `unit/path_object_subscribe.md` -- uses helper +15. `unit/batch.md` -- uses helper, depends on PathObject/Instance concepts +16. `integration/objects_lifecycle_test.md` +17. `integration/objects_sync_test.md` +18. `integration/objects_batch_test.md` +19. `integration/objects_gc_test.md` +20. `integration/proxy/objects_faults.md` + +--- + +## Key Decisions + +| Decision | Rationale | +|----------|-----------| +| Wire format v6 everywhere | Spec branch uses v6 field names; old v5 names are "replaced by" stubs | +| `appliedOnAckSerials` on RealtimeObject (RTO7b), not on pool | Matches spec's placement; cleared at sync completion (RTO5c9) | +| No REST test files | objects-features.md has no REST API spec points; REST used only for integration fixture provisioning | +| `echoMessages` check retained on mutations | Spec retains RTLC12d, RTLM20d, RTLM21d | +| Batch uses RTO15 (publish), NOT RTO20 (publishAndApply) | RTBC16d says "publishes ... using `RealtimeObject#publish`" -- batch does NOT apply locally on ACK | +| LiveObject/LiveMap/LiveCounter marked internal but still unit-tested | Direct testing of CRDT logic is essential; public API tests can't cover all edge cases | +| Test IDs use `objects/unit/` prefix | Matches directory structure, not nested under `realtime/` | +| Behavioral GC testing via ADVANCE_TIME | Verify GC through observable consequences (value becomes null, object recreatable) rather than internal pool state inspection | +| Table-driven tests for input validation | Use FOR loops over scenario arrays (like ably-js forScenarios) to test all invalid/valid type combinations | +| Bytes data type coverage | Standard test pool includes "avatar" bytes entry; compact/compactJson/value tests verify base64 encoding | diff --git a/uts/objects/helpers/standard_test_pool.md b/uts/objects/helpers/standard_test_pool.md new file mode 100644 index 000000000..e01062903 --- /dev/null +++ b/uts/objects/helpers/standard_test_pool.md @@ -0,0 +1,322 @@ +# Standard Test Pool and Helpers + +Shared fixtures, protocol message builders, and synced-channel setup pattern for all LiveObjects test files. + +## Standard Test Tree + +The standard test pool defines a fixed LiveObjects tree used across test files. All object IDs use short synthetic values for clarity (real servers validate the hash format, but unit tests construct objects directly). + +``` +root (LiveMap, objectId: "root", semantics: LWW) + +-- "name" -> string "Alice" + +-- "age" -> number 30 + +-- "active" -> boolean true + +-- "score" -> objectId "counter:score@1000" + +-- "profile" -> objectId "map:profile@1000" + +-- "data" -> json {"tags": ["a", "b"]} + +-- "avatar" -> bytes base64("AQID") (raw bytes: [1, 2, 3]) + +counter:score@1000 (LiveCounter, data: 100) + +map:profile@1000 (LiveMap, semantics: LWW) + +-- "email" -> string "alice@example.com" + +-- "nested_counter" -> objectId "counter:nested@1000" + +-- "prefs" -> objectId "map:prefs@1000" + +counter:nested@1000 (LiveCounter, data: 5) + +map:prefs@1000 (LiveMap, semantics: LWW) + +-- "theme" -> string "dark" +``` + +All map entries have timeserial `"t:0"` and `tombstone: false` unless otherwise noted. +All objects have `siteTimeserials: { "aaa": "t:0" }` and `createOperationIsMerged: true` unless otherwise noted. + +--- + +## STANDARD_POOL_OBJECTS + +An array of `ObjectMessage` instances wrapping `ObjectState` for building OBJECT_SYNC messages. Each object is represented as `build_object_state(...)` using the builders below. + +```pseudo +STANDARD_POOL_OBJECTS = [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "name": { data: { string: "Alice" }, timeserial: "t:0" }, + "age": { data: { number: 30 }, timeserial: "t:0" }, + "active": { data: { boolean: true }, timeserial: "t:0" }, + "score": { data: { objectId: "counter:score@1000" }, timeserial: "t:0" }, + "profile": { data: { objectId: "map:profile@1000" }, timeserial: "t:0" }, + "data": { data: { json: {"tags": ["a", "b"]} }, timeserial: "t:0" }, + "avatar": { data: { bytes: "AQID" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:score@1000", {"aaa": "t:0"}, { + counter: { count: 100 }, + createOp: { counterCreate: { count: 100 } } + }), + build_object_state("map:profile@1000", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "email": { data: { string: "alice@example.com" }, timeserial: "t:0" }, + "nested_counter": { data: { objectId: "counter:nested@1000" }, timeserial: "t:0" }, + "prefs": { data: { objectId: "map:prefs@1000" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:nested@1000", {"aaa": "t:0"}, { + counter: { count: 5 }, + createOp: { counterCreate: { count: 5 } } + }), + build_object_state("map:prefs@1000", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "theme": { data: { string: "dark" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +] +``` + +--- + +## Builder Functions + +### Protocol Message Builders + +```pseudo +build_object_sync_message(channel, channelSerial, objectMessages[]): + RETURN ProtocolMessage( + action: OBJECT_SYNC, + channel: channel, + channelSerial: channelSerial, + state: objectMessages + ) + +build_object_message(channel, objectMessages[]): + RETURN ProtocolMessage( + action: OBJECT, + channel: channel, + state: objectMessages + ) + +build_ack_message(msgSerial, serials[]): + RETURN ProtocolMessage( + action: ACK, + msgSerial: msgSerial, + res: [{ serials: serials }] + ) +``` + +### ObjectMessage Builders (Operations) + +```pseudo +build_counter_inc(objectId, number, serial, siteCode): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + operation: { + action: "COUNTER_INC", + objectId: objectId, + counterInc: { number: number } + } + ) + +build_map_set(objectId, key, value, serial, siteCode): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + operation: { + action: "MAP_SET", + objectId: objectId, + mapSet: { key: key, value: value } + } + ) + +build_map_remove(objectId, key, serial, siteCode, serialTimestamp?): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + serialTimestamp: serialTimestamp, + operation: { + action: "MAP_REMOVE", + objectId: objectId, + mapRemove: { key: key } + } + ) + +build_map_clear(objectId, serial, siteCode): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + operation: { + action: "MAP_CLEAR", + objectId: objectId + } + ) + +build_object_delete(objectId, serial, siteCode, serialTimestamp?): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + serialTimestamp: serialTimestamp, + operation: { + action: "OBJECT_DELETE", + objectId: objectId + } + ) + +build_counter_create(objectId, counterCreate, serial, siteCode): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + operation: { + action: "COUNTER_CREATE", + objectId: objectId, + counterCreate: counterCreate + } + ) + +build_map_create(objectId, mapCreate, serial, siteCode): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + operation: { + action: "MAP_CREATE", + objectId: objectId, + mapCreate: mapCreate + } + ) +``` + +### ObjectMessage Builder (State — for OBJECT_SYNC) + +```pseudo +build_object_state(objectId, siteTimeserials, opts): + state = { + objectId: objectId, + siteTimeserials: siteTimeserials + } + IF opts.map IS NOT null: + state.map = opts.map + IF opts.counter IS NOT null: + state.counter = opts.counter + IF opts.tombstone IS NOT null: + state.tombstone = opts.tombstone + IF opts.createOp IS NOT null: + state.createOp = opts.createOp + RETURN ObjectMessage(object: state) +``` + +--- + +## Standard Synced-Channel Setup + +Used by all mock WebSocket test files. Creates a connected client with a synced channel containing the standard test pool. + +```pseudo +setup_synced_channel(channel_name): + mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", + connectionKey: "conn-key-1", + siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message( + msg.channel, "sync1:", STANDARD_POOL_OBJECTS + )) + ELSE IF msg.action == OBJECT: + serials = [] + FOR i IN 0..msg.state.length - 1: + serials.append("ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } + ) + install_mock(mock_ws) + + client = Realtime(options: { + key: "fake:key", + autoConnect: true + }) + channel = client.channels.get(channel_name, { + modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] + }) + root = AWAIT channel.object.get() + + RETURN { client, channel, root, mock_ws } +``` + +### Variant: Setup Without Auto-ACK + +For tests that need to control ACK timing, use this variant that omits the OBJECT message handler: + +```pseudo +setup_synced_channel_no_ack(channel_name): + mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", + connectionKey: "conn-key-1", + siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message( + msg.channel, "sync1:", STANDARD_POOL_OBJECTS + )) + } + ) + install_mock(mock_ws) + + client = Realtime(options: { + key: "fake:key", + autoConnect: true + }) + channel = client.channels.get(channel_name, { + modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] + }) + root = AWAIT channel.object.get() + + RETURN { client, channel, root, mock_ws } +``` + +--- + +## REST Fixture Provisioning + +For integration tests that need pre-existing object state before the test client connects, use the REST API to establish fixtures. + +```pseudo +provision_objects_via_rest(api_key, channel_name, operations): + POST https://sandbox-rest.ably.io/channels/{encode_uri_component(channel_name)}/objects + WITH Authorization: Basic {base64(api_key)} + WITH Content-Type: application/json + WITH body: { "messages": operations } +``` diff --git a/uts/objects/integration/objects_batch_test.md b/uts/objects/integration/objects_batch_test.md new file mode 100644 index 000000000..a5805482a --- /dev/null +++ b/uts/objects/integration/objects_batch_test.md @@ -0,0 +1,201 @@ +# Objects Batch Integration Tests + +Spec points: `RTPO22`, `RTBC12`–`RTBC15` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +Batch operations end-to-end — multiple mutations in a single publish, atomic +propagation to subscribers. Verifies that batch() groups multiple operations +into a single ProtocolMessage and the server processes and delivers them +correctly to other clients. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Notes +- Each test uses a unique channel name + +--- + +## RTPO22 - Batch set of multiple keys arrives to second client + +**Test ID**: `objects/integration/RTPO22/batch-set-propagates-0` + +**Spec requirement:** batch() groups multiple mutations into a single publish. +All operations are delivered together to subscribers. + +### Setup +```pseudo +channel_name = "objects-batch-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +AWAIT root_a.batch((ctx) => { + ctx.set("x", 1) + ctx.set("y", 2) + ctx.set("z", 3) +}) + +poll_until(root_b.get("x").value() == 1, timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("x").value() == 1 +ASSERT root_b.get("y").value() == 2 +ASSERT root_b.get("z").value() == 3 +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTPO22 - Batch with mixed operations (set + remove + increment) + +**Test ID**: `objects/integration/RTPO22/batch-mixed-ops-0` + +**Spec requirement:** Batch can contain different operation types published atomically. + +### Setup +```pseudo +channel_name = "objects-batch-mixed-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +// Set up initial state +AWAIT root_a.set("to_remove", "temp") +AWAIT root_a.set("counter", LiveCounter.create(10)) +poll_until(root_b.get("to_remove").value() == "temp", timeout: 10s) +poll_until(root_b.get("counter").value() == 10, timeout: 10s) + +// Batch with mixed operations +AWAIT root_a.batch((ctx) => { + ctx.set("name", "Alice") + ctx.remove("to_remove") + child = ctx.get("counter") + child.increment(5) +}) + +poll_until(root_b.get("name").value() == "Alice", timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("name").value() == "Alice" +ASSERT root_b.get("to_remove").value() == null +ASSERT root_b.get("counter").value() == 15 +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTPO22 - Batch with LiveCounterValueType creates counter atomically + +**Test ID**: `objects/integration/RTPO22/batch-create-counter-0` + +**Spec requirement:** Batch containing LiveCounterValueType generates COUNTER_CREATE + +MAP_SET in a single publish. The server processes both atomically. + +### Setup +```pseudo +channel_name = "objects-batch-counter-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +AWAIT root_a.batch((ctx) => { + ctx.set("batch_counter", LiveCounter.create(99)) + ctx.set("label", "created in batch") +}) + +poll_until(root_b.get("batch_counter").value() == 99, timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("batch_counter").value() == 99 +ASSERT root_b.get("label").value() == "created in batch" +ASSERT root_b.get("batch_counter").instance() IS NOT null +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` diff --git a/uts/objects/integration/objects_gc_test.md b/uts/objects/integration/objects_gc_test.md new file mode 100644 index 000000000..2d9bc86a2 --- /dev/null +++ b/uts/objects/integration/objects_gc_test.md @@ -0,0 +1,138 @@ +# Objects GC Integration Tests + +Spec points: `RTO10`, `RTLM19` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +Behavioral verification of garbage collection for tombstoned objects and tombstoned +map entries. Uses `ADVANCE_TIME` (fake timers) to control timing and verifies GC +through observable API consequences rather than internal pool state inspection. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Notes +- These tests use fake timers to control GC timing +- Each test uses a unique channel name + +--- + +## RTO10 - Tombstoned object is GC'd and recreatable + +**Test ID**: `objects/integration/RTO10/tombstoned-object-gc-recreate-0` + +**Spec requirement:** After an object is tombstoned and the GC grace period elapses, +the object is removed from the pool. A new object can then be created at the same +map key. + +### Setup +```pseudo +enable_fake_timers() +channel_name = "objects-gc-object-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +// Create a counter +AWAIT root.set("counter", LiveCounter.create(42)) +ASSERT root.get("counter").value() == 42 +counter_id = root.get("counter").instance().id() + +// Remove it (tombstones the entry and the object) +AWAIT root.remove("counter") +ASSERT root.get("counter").value() == null + +// Advance past GC grace period +ADVANCE_TIME(86400000 + 300000) + +// Create a new counter at the same key +AWAIT root.set("counter", LiveCounter.create(99)) +``` + +### Assertions +```pseudo +ASSERT root.get("counter").value() == 99 +new_counter_id = root.get("counter").instance().id() +ASSERT new_counter_id != counter_id +``` + +### Teardown +```pseudo +client.close() +``` + +--- + +## RTLM19 - Tombstoned map entry is GC'd, re-settable with old serial + +**Test ID**: `objects/integration/RTLM19/tombstoned-entry-gc-reset-0` + +**Spec requirement:** After a map entry is tombstoned and GC'd, the entry is fully +removed. A subsequent MAP_SET with any serial succeeds because there is no existing +entry to compare against. + +### Setup +```pseudo +enable_fake_timers() +channel_name = "objects-gc-entry-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +// Set then remove a key +AWAIT root.set("ephemeral", "temporary") +ASSERT root.get("ephemeral").value() == "temporary" + +AWAIT root.remove("ephemeral") +ASSERT root.get("ephemeral").value() == null + +// Advance past GC grace period for entries +ADVANCE_TIME(86400000 + 300000) + +// Set the same key again +AWAIT root.set("ephemeral", "revived") +``` + +### Assertions +```pseudo +ASSERT root.get("ephemeral").value() == "revived" +``` + +### Teardown +```pseudo +client.close() +``` diff --git a/uts/objects/integration/objects_lifecycle_test.md b/uts/objects/integration/objects_lifecycle_test.md new file mode 100644 index 000000000..9c440f512 --- /dev/null +++ b/uts/objects/integration/objects_lifecycle_test.md @@ -0,0 +1,317 @@ +# Objects Lifecycle Integration Tests + +Spec points: `RTO23`, `RTPO15`, `RTPO17` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +End-to-end lifecycle: connect, sync, create objects via PathObject, mutate, and +verify propagation to a second client. Complements unit tests by verifying real +server sync, mutation delivery, and object creation. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Notes +- Each test uses a unique channel name to avoid interference + +--- + +## RTO23, RTPO15 - Set primitive via PathObject, second client reads it + +**Test ID**: `objects/integration/RTO23-RTPO15/set-primitive-propagates-0` + +**Spec requirement:** PathObject#set delegates to LiveMap#set. The mutation +propagates via the server and a second client sees the updated value. + +### Setup +```pseudo +channel_name = "objects-lifecycle-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +// Client A sets a value +AWAIT root_a.set("greeting", "hello") + +// Client B subscribes and waits for the update +events_b = [] +root_b.subscribe((event) => events_b.append(event)) +poll_until(root_b.get("greeting").value() == "hello", timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("greeting").value() == "hello" +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTPO15 - Set with LiveCounterValueType, second client reads counter + +**Test ID**: `objects/integration/RTPO15/set-counter-value-type-0` + +**Spec requirement:** PathObject#set with LiveCounterValueType creates a new counter +on the server. Second client syncs and reads the counter value. + +### Setup +```pseudo +channel_name = "objects-counter-create-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +AWAIT root_a.set("my_counter", LiveCounter.create(42)) +poll_until(root_b.get("my_counter").value() == 42, timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("my_counter").value() == 42 +ASSERT root_b.get("my_counter").instance() IS NOT null +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTPO17 - Increment counter, second client sees updated value + +**Test ID**: `objects/integration/RTPO17/increment-propagates-0` + +**Spec requirement:** PathObject#increment delegates to LiveCounter#increment. +The server applies the increment and propagates the updated value. + +### Setup +```pseudo +channel_name = "objects-increment-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +// Create a counter first +AWAIT root_a.set("hits", LiveCounter.create(0)) +poll_until(root_b.get("hits").value() == 0, timeout: 10s) + +// Increment it +AWAIT root_a.get("hits").increment(10) +poll_until(root_b.get("hits").value() == 10, timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_a.get("hits").value() == 10 +ASSERT root_b.get("hits").value() == 10 +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTPO15 - Set with LiveMapValueType, second client reads nested map + +**Test ID**: `objects/integration/RTPO15/set-map-value-type-0` + +**Spec requirement:** PathObject#set with LiveMapValueType creates a nested map. +Second client can navigate into the nested map. + +### Setup +```pseudo +channel_name = "objects-map-create-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +AWAIT root_a.set("settings", LiveMap.create({ + "theme": "dark", + "fontSize": 14 +})) +poll_until(root_b.get("settings").get("theme").value() == "dark", timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("settings").get("theme").value() == "dark" +ASSERT root_b.get("settings").get("fontSize").value() == 14 +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTO23 - get() waits for sync and returns PathObject + +**Test ID**: `objects/integration/RTO23/get-returns-path-object-0` + +**Spec requirement:** channel.object.get() returns a PathObject pointing to the root +after the sync sequence completes. + +### Setup +```pseudo +channel_name = "objects-get-root-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +root = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +ASSERT root.path() == "" +ASSERT root.size() == 0 +``` + +### Teardown +```pseudo +client.close() +``` + +--- + +## RTPO15 - Client syncs pre-existing data provisioned via REST + +**Test ID**: `objects/integration/RTPO15/rest-provisioned-data-sync-0` + +**Spec requirement:** Data created via the REST API is visible to a realtime client +that connects afterward. + +### Setup +```pseudo +channel_name = "objects-rest-provision-" + random_id() + +// Provision data via REST before any realtime client connects +provision_objects_via_rest(api_key, channel_name, [ + { + operation: { + action: "MAP_SET", + objectId: "root", + mapSet: { key: "provisioned", value: { string: "from_rest" } } + } + } +]) +``` + +### Test Steps +```pseudo +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root.get("provisioned").value() == "from_rest" +``` + +### Teardown +```pseudo +client.close() +``` diff --git a/uts/objects/integration/objects_sync_test.md b/uts/objects/integration/objects_sync_test.md new file mode 100644 index 000000000..7f0721ec2 --- /dev/null +++ b/uts/objects/integration/objects_sync_test.md @@ -0,0 +1,200 @@ +# Objects Sync Integration Tests + +Spec points: `RTO4`, `RTO5`, `RTO17` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +Verify the sync sequence against the real server: attach with HAS_OBJECTS, +receive OBJECT_SYNC, reach SYNCED state. Also tests re-attach behaviour where +the client detaches and re-attaches to verify the pool is re-synced. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Notes +- Each test uses a unique channel name + +--- + +## RTO4, RTO5 - Attach triggers sync, get() resolves after SYNCED + +**Test ID**: `objects/integration/RTO4-RTO5/attach-sync-get-0` + +**Spec requirement:** On ATTACHED with HAS_OBJECTS flag, client transitions to SYNCING, +processes OBJECT_SYNC messages, then transitions to SYNCED. get() waits for SYNCED. + +### Setup +```pseudo +channel_name = "objects-sync-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +root = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +ASSERT root.path() == "" +``` + +### Teardown +```pseudo +client.close() +``` + +--- + +## RTO5, RTO17 - Two clients sync same channel with pre-existing data + +**Test ID**: `objects/integration/RTO5-RTO17/two-clients-sync-0` + +**Spec requirement:** Both clients complete sync and see the same object pool state. + +### Setup +```pseudo +channel_name = "objects-two-sync-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +// Client A creates data +root_a = AWAIT channel_a.object.get() +AWAIT root_a.set("key1", "value1") + +// Client B attaches and syncs — should see the data +root_b = AWAIT channel_b.object.get() +poll_until(root_b.get("key1").value() == "value1", timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("key1").value() == "value1" +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTO17 - Re-attach re-syncs object pool + +**Test ID**: `objects/integration/RTO17/reattach-resyncs-0` + +**Spec requirement:** On re-attach, the sync state machine restarts and the pool +is re-populated from the server. + +### Setup +```pseudo +channel_name = "objects-reattach-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +// Set some data +AWAIT root.set("before_detach", "hello") +ASSERT root.get("before_detach").value() == "hello" + +// Detach and re-attach +AWAIT channel.detach() +AWAIT channel.attach() + +// Re-sync should restore data +root = AWAIT channel.object.get() +poll_until(root.get("before_detach").value() == "hello", timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root.get("before_detach").value() == "hello" +``` + +### Teardown +```pseudo +client.close() +``` + +--- + +## RTO4 - Attach without OBJECT_SUBSCRIBE still resolves get() with empty pool + +**Test ID**: `objects/integration/RTO4/attach-subscribe-only-0` + +**Spec requirement:** Channel attached with only OBJECT_SUBSCRIBE mode. Server +sends HAS_OBJECTS, sync completes, root is an empty LiveMap. + +### Setup +```pseudo +channel_name = "objects-subscribe-only-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE"] }) +``` + +### Test Steps +```pseudo +root = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +ASSERT root.size() == 0 +``` + +### Teardown +```pseudo +client.close() +``` diff --git a/uts/objects/integration/proxy/objects_faults.md b/uts/objects/integration/proxy/objects_faults.md new file mode 100644 index 000000000..8988a0191 --- /dev/null +++ b/uts/objects/integration/proxy/objects_faults.md @@ -0,0 +1,459 @@ +# Objects Proxy Integration Tests + +Spec points: `RTO5a2`, `RTO7`, `RTO8`, `RTO17`, `RTO20e` + +## Test Type + +Proxy integration test against Ably Sandbox endpoint + +## Proxy Infrastructure + +See `realtime/integration/helpers/proxy.md` for the full proxy infrastructure specification. + +## Corresponding Unit Tests + +- `objects/unit/objects_pool.md` — RTO5a2 (new sync discards old), RTO7/RTO8 (buffering during SYNCING) +- `objects/unit/realtime_object.md` — RTO17 (sync state events), RTO20e (publishAndApply waits for SYNCED/fails on FAILED) + +## Sandbox Setup + +Tests run against the Ably Sandbox via a programmable proxy. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Common Cleanup + +```pseudo +AFTER EACH TEST: + IF client IS NOT null AND client.connection.state IN [connected, connecting, disconnected]: + client.connection.close() + AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10 seconds + IF session IS NOT null: + session.close() +``` + +### Protocol Message Action Numbers (Objects-relevant) + +| Name | Number | +|------|--------| +| ATTACHED | 11 | +| DETACHED | 13 | +| OBJECT | 19 | +| OBJECT_SYNC | 20 | + +--- + +## RTO5a2, RTO17 - Sync interrupted by disconnect, re-syncs on reconnect + +**Test ID**: `objects/proxy/RTO5a2-RTO17/sync-interrupted-reconnect-0` + +| Spec | Requirement | +|------|-------------| +| RTO5a2 | New sync sequence discards old SyncObjectsPool | +| RTO17 | Sync state transitions: SYNCING → SYNCED, re-triggered on re-attach | + +Tests that when the connection drops mid-OBJECT_SYNC, the client discards +partial sync state and re-syncs cleanly on reconnect. The proxy disconnects +after the first OBJECT_SYNC frame so the sync is never completed, then on +reconnect the client re-attaches and syncs fully. + +### Setup + +```pseudo +channel_name = "objects-sync-interrupt-" + random_id() + +// Disconnect after first OBJECT_SYNC frame +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "ws_frame_to_client", "action": 20 }, + "action": { "type": "disconnect" }, + "times": 1, + "comment": "RTO5a2: Disconnect after first OBJECT_SYNC to interrupt sync" + }] +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + WITH timeout: 15 seconds + +// First attach triggers sync; proxy disconnects mid-sync +channel.attach() +AWAIT_STATE client.connection.state == DISCONNECTED + WITH timeout: 15 seconds + +// Client auto-reconnects; re-attach triggers fresh sync +AWAIT_STATE client.connection.state == CONNECTED + WITH timeout: 30 seconds + +// get() waits for SYNCED — will only resolve if re-sync completes +root = AWAIT channel.object.get() + WITH timeout: 30 seconds +``` + +### Assertions + +```pseudo +ASSERT root IS PathObject +ASSERT root.path() == "" +``` + +--- + +## RTO7, RTO8 - Mutations during re-sync are buffered and applied + +**Test ID**: `objects/proxy/RTO7-RTO8/mutations-buffered-during-resync-0` + +| Spec | Requirement | +|------|-------------| +| RTO7 | Buffer OBJECT messages during SYNCING | +| RTO8 | Apply buffered messages after sync completes | + +Client A publishes mutations while client B is re-syncing after reconnect. +The mutations should be buffered and applied after the sync completes. + +### Setup + +```pseudo +channel_name = "objects-buffer-resync-" + random_id() + +// Client A: direct connection (no proxy), publishes mutations +client_a = Realtime(options: { key: api_key }) +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + WITH timeout: 15 seconds + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root_a = AWAIT channel_a.object.get() + +// Set initial data +AWAIT root_a.set("key1", "initial") + +// Client B: through proxy, will be disconnected +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [] +) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps + +```pseudo +// Client B connects and syncs +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + WITH timeout: 15 seconds + +root_b = AWAIT channel_b.object.get() + WITH timeout: 15 seconds +poll_until(root_b.get("key1").value() == "initial", timeout: 10s) + +// Disconnect client B +session.trigger_action({ type: "disconnect" }) +AWAIT_STATE client_b.connection.state == DISCONNECTED + WITH timeout: 15 seconds + +// While B is disconnected, A publishes a mutation +AWAIT root_a.set("key1", "updated_during_disconnect") + +// Client B reconnects and re-syncs; the mutation should be visible +AWAIT_STATE client_b.connection.state == CONNECTED + WITH timeout: 30 seconds + +root_b = AWAIT channel_b.object.get() + WITH timeout: 15 seconds +poll_until(root_b.get("key1").value() == "updated_during_disconnect", timeout: 15s) +``` + +### Assertions + +```pseudo +ASSERT root_b.get("key1").value() == "updated_during_disconnect" +``` + +### Teardown + +```pseudo +client_a.close() +client_b.close() +session.close() +``` + +--- + +## RTO17 - Server-initiated detach triggers re-sync on re-attach + +**Test ID**: `objects/proxy/RTO17/server-detach-resync-0` + +| Spec | Requirement | +|------|-------------| +| RTO17 | On re-attach, sync state machine restarts from INITIALIZED | + +The proxy injects a DETACHED message for the channel, simulating a server-initiated +detach. After the client automatically re-attaches, it must re-sync the object pool. + +### Setup + +```pseudo +channel_name = "objects-detach-resync-" + random_id() + +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [] +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + WITH timeout: 15 seconds + +root = AWAIT channel.object.get() + WITH timeout: 15 seconds + +// Set some data +AWAIT root.set("before_detach", "hello") +ASSERT root.get("before_detach").value() == "hello" + +// Inject server-initiated DETACHED +session.trigger_action({ + type: "inject_to_client", + message: { + action: 13, + channel: channel_name + } +}) + +// Client should auto-re-attach (RTL13a) +AWAIT_STATE channel.state == ChannelState.attached + WITH timeout: 30 seconds + +// Re-sync should restore data +root = AWAIT channel.object.get() + WITH timeout: 15 seconds +poll_until(root.get("before_detach").value() == "hello", timeout: 15s) +``` + +### Assertions + +```pseudo +ASSERT root.get("before_detach").value() == "hello" +``` + +--- + +## RTO20e - publishAndApply fails when channel enters FAILED during SYNCING + +**Test ID**: `objects/proxy/RTO20e/publish-fails-on-channel-failed-0` + +| Spec | Requirement | +|------|-------------| +| RTO20e | publishAndApply waits for SYNCED; fails with 92008 if channel enters DETACHED/SUSPENDED/FAILED | + +Client sets up a channel with objects, then the proxy injects a channel ERROR +to transition to FAILED. A PathObject mutation (which uses publishAndApply +internally) should fail with error 92008. + +### Setup + +```pseudo +channel_name = "objects-publish-failed-" + random_id() + +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [] +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + WITH timeout: 15 seconds + +root = AWAIT channel.object.get() + WITH timeout: 15 seconds + +// Inject channel ERROR to transition to FAILED +session.trigger_action({ + type: "inject_to_client", + message: { + action: 9, + channel: channel_name, + error: { statusCode: 400, code: 90000, message: "injected error" } + } +}) + +AWAIT_STATE channel.state == ChannelState.failed + WITH timeout: 15 seconds + +// Attempt a mutation — should fail since channel is FAILED +AWAIT root.set("key", "value") FAILS WITH error +``` + +### Assertions + +```pseudo +ASSERT error.code == 92008 +``` + +--- + +## RTO5, RTO7 - Publish during sync, echo arrives after sync completes + +**Test ID**: `objects/proxy/RTO5-RTO7/publish-during-sync-echo-after-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c6 | Apply buffered OBJECT messages after sync completes | +| RTO7 | Buffer OBJECT messages during SYNCING | + +The proxy delays the OBJECT_SYNC completion so the client stays in SYNCING. +Client A publishes a mutation that arrives as an OBJECT message to client B +while B is still syncing. The mutation must be buffered and applied after +sync completes. + +### Setup + +```pseudo +channel_name = "objects-publish-during-sync-" + random_id() + +// Client A: direct, no proxy +client_a = Realtime(options: { key: api_key }) +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + WITH timeout: 15 seconds + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root_a = AWAIT channel_a.object.get() + +// Set up initial data +AWAIT root_a.set("existing", "before") + +// Client B: through proxy with delayed OBJECT_SYNC +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "ws_frame_to_client", "action": 20 }, + "action": { "type": "delay", "delayMs": 3000 }, + "times": 1, + "comment": "Delay first OBJECT_SYNC to keep B in SYNCING state" + }] +) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps + +```pseudo +// Start client B — will be stuck in SYNCING due to delayed OBJECT_SYNC +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + WITH timeout: 15 seconds +channel_b.attach() + +// While B is syncing, A publishes a mutation +AWAIT root_a.set("existing", "after") + +// B's get() will resolve once delayed sync completes +root_b = AWAIT channel_b.object.get() + WITH timeout: 30 seconds + +// The mutation from A should be visible (either in sync data or buffered OBJECT) +poll_until(root_b.get("existing").value() == "after", timeout: 15s) +``` + +### Assertions + +```pseudo +ASSERT root_b.get("existing").value() == "after" +``` + +### Teardown + +```pseudo +client_a.close() +client_b.close() +session.close() +``` diff --git a/uts/objects/unit/batch.md b/uts/objects/unit/batch.md new file mode 100644 index 000000000..b53098c35 --- /dev/null +++ b/uts/objects/unit/batch.md @@ -0,0 +1,782 @@ +# Batch API Tests + +Spec points: `RTPO22`, `RTINS19`, `RTBC1`–`RTBC16` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTPO22 - PathObject#batch resolves path and executes fn + +**Test ID**: `objects/unit/RTPO22/batch-resolves-and-executes-0` + +| Spec | Requirement | +|------|-------------| +| RTPO22c | Resolves path to LiveObject | +| RTPO22d | Creates RootBatchContext wrapping Instance | +| RTPO22e | Executes fn with BatchContext | +| RTPO22f | Flushes after fn returns | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.set("name", "Bob") + ctx.set("age", 31) +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +ASSERT captured_messages[0].state.length == 2 +ASSERT captured_messages[0].state[0].operation.action == "MAP_SET" +ASSERT captured_messages[0].state[0].operation.mapSet.key == "name" +ASSERT captured_messages[0].state[1].operation.action == "MAP_SET" +ASSERT captured_messages[0].state[1].operation.mapSet.key == "age" +``` + +--- + +## RTPO22c - PathObject#batch on unresolvable path throws 92007 + +**Test ID**: `objects/unit/RTPO22c/batch-unresolvable-throws-0` + +**Spec requirement:** If path does not resolve to LiveObject, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("nonexistent").get("deep").batch((ctx) => {}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTINS19 - Instance#batch resolves and executes fn + +**Test ID**: `objects/unit/RTINS19/batch-instance-executes-0` + +| Spec | Requirement | +|------|-------------| +| RTINS19d | Creates RootBatchContext wrapping Instance | +| RTINS19e | Executes fn with BatchContext | +| RTINS19f | Flushes after fn returns | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +instance = root.instance() +AWAIT instance.batch((ctx) => { + ctx.set("name", "Charlie") + ctx.remove("age") +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +ASSERT captured_messages[0].state.length == 2 +ASSERT captured_messages[0].state[0].operation.action == "MAP_SET" +ASSERT captured_messages[0].state[1].operation.action == "MAP_REMOVE" +``` + +--- + +## RTINS19c - Instance#batch on non-LiveObject throws 92007 + +**Test ID**: `objects/unit/RTINS19c/batch-non-live-object-throws-0` + +**Spec requirement:** If wrapped value is not a LiveObject, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +name_inst = root.instance().get("name") +AWAIT name_inst.batch((ctx) => {}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTBC3 - BatchContext#id returns objectId + +**Test ID**: `objects/unit/RTBC3/id-returns-objectid-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +received_id = null +AWAIT root.batch((ctx) => { + received_id = ctx.id() +}) +``` + +### Assertions +```pseudo +ASSERT received_id == "root" +``` + +--- + +## RTBC5 - BatchContext#value delegates to Instance#value + +**Test ID**: `objects/unit/RTBC5/value-delegates-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +received_value = null +AWAIT root.get("score").batch((ctx) => { + received_value = ctx.value() +}) +``` + +### Assertions +```pseudo +ASSERT received_value == 100 +``` + +--- + +## RTBC4 - BatchContext#get wraps result via wrapInstance + +**Test ID**: `objects/unit/RTBC4/get-wraps-instance-0` + +| Spec | Requirement | +|------|-------------| +| RTBC4c | Delegates to Instance#get | +| RTBC4d | Wraps result via RootBatchContext#wrapInstance | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +child_id = null +AWAIT root.batch((ctx) => { + child = ctx.get("score") + child_id = child.id() +}) +``` + +### Assertions +```pseudo +ASSERT child_id == "counter:score@1000" +``` + +--- + +## RTBC4 - BatchContext#get returns null for nonexistent key + +**Test ID**: `objects/unit/RTBC4/get-null-nonexistent-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +result = "not_null" +AWAIT root.batch((ctx) => { + result = ctx.get("nonexistent") +}) +``` + +### Assertions +```pseudo +ASSERT result == null +``` + +--- + +## RTBC6 - BatchContext#entries yields [key, BatchContext] pairs + +**Test ID**: `objects/unit/RTBC6/entries-yields-pairs-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +keys = [] +AWAIT root.batch((ctx) => { + FOR [key, child] IN ctx.entries(): + keys.append(key) +}) +``` + +### Assertions +```pseudo +ASSERT keys.length == 6 +ASSERT "name" IN keys +ASSERT "score" IN keys +``` + +--- + +## RTBC9 - BatchContext#size delegates to Instance#size + +**Test ID**: `objects/unit/RTBC9/size-delegates-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +received_size = null +AWAIT root.batch((ctx) => { + received_size = ctx.size() +}) +``` + +### Assertions +```pseudo +ASSERT received_size == 6 +``` + +--- + +## RTBC10 - BatchContext#compact delegates to Instance#compact + +**Test ID**: `objects/unit/RTBC10/compact-delegates-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +result = null +AWAIT root.batch((ctx) => { + result = ctx.compact() +}) +``` + +### Assertions +```pseudo +ASSERT result["name"] == "Alice" +ASSERT result["score"] == 100 +``` + +--- + +## RTBC12 - BatchContext#set queues MAP_SET message + +**Test ID**: `objects/unit/RTBC12/set-queues-map-set-0` + +| Spec | Requirement | +|------|-------------| +| RTBC12d | Queues message constructor for MAP_SET | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.set("name", "Bob") +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_SET" +ASSERT obj_msg.operation.objectId == "root" +ASSERT obj_msg.operation.mapSet.key == "name" +ASSERT obj_msg.operation.mapSet.value.string == "Bob" +``` + +--- + +## RTBC12c - BatchContext#set on non-LiveMap throws 92007 + +**Test ID**: `objects/unit/RTBC12c/set-non-map-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").batch((ctx) => { + ctx.set("key", "value") +}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTBC13 - BatchContext#remove queues MAP_REMOVE message + +**Test ID**: `objects/unit/RTBC13/remove-queues-map-remove-0` + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as RTPO22, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.remove("name") +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_REMOVE" +ASSERT obj_msg.operation.objectId == "root" +ASSERT obj_msg.operation.mapRemove.key == "name" +``` + +--- + +## RTBC14 - BatchContext#increment queues COUNTER_INC message + +**Test ID**: `objects/unit/RTBC14/increment-queues-counter-inc-0` + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as RTPO22, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.get("score").batch((ctx) => { + ctx.increment(25) +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "COUNTER_INC" +ASSERT obj_msg.operation.objectId == "counter:score@1000" +ASSERT obj_msg.operation.counterInc.number == 25 +``` + +--- + +## RTBC14c - BatchContext#increment on non-LiveCounter throws 92007 + +**Test ID**: `objects/unit/RTBC14c/increment-non-counter-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.increment(5) +}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTBC15 - BatchContext#decrement delegates to increment with negated amount + +**Test ID**: `objects/unit/RTBC15/decrement-negates-0` + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as RTPO22, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.get("score").batch((ctx) => { + ctx.decrement(10) +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "COUNTER_INC" +ASSERT obj_msg.operation.counterInc.number == -10 +``` + +--- + +## RTBC16c - wrapInstance memoizes by objectId + +**Test ID**: `objects/unit/RTBC16c/wrap-instance-memoized-0` + +**Spec requirement:** If a wrapper for that objectId already exists, the existing wrapper is returned. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +same_ref = false +AWAIT root.batch((ctx) => { + child1 = ctx.get("score") + child2 = ctx.get("score") + same_ref = (child1 IS child2) +}) +``` + +### Assertions +```pseudo +ASSERT same_ref == true +``` + +--- + +## RTBC16d - flush publishes via RTO15 (publish, not publishAndApply) + +**Test ID**: `objects/unit/RTBC16d/flush-uses-publish-0` + +**Spec requirement:** Flushes queued messages as a single array via RealtimeObject#publish. + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.set("name", "Bob") + ctx.set("age", 31) + child = ctx.get("score") + child.increment(50) +}) +``` + +### Assertions +```pseudo +// All operations published as a single OBJECT message +ASSERT captured_messages.length == 1 +ASSERT captured_messages[0].state.length == 3 +ASSERT captured_messages[0].state[0].operation.action == "MAP_SET" +ASSERT captured_messages[0].state[1].operation.action == "MAP_SET" +ASSERT captured_messages[0].state[2].operation.action == "COUNTER_INC" +``` + +--- + +## RTBC16d - flush with no queued messages does not publish + +**Test ID**: `objects/unit/RTBC16d/flush-empty-no-publish-0` + +**Spec requirement:** If there are no queued messages, no publish is performed. + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as RTPO22, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + // Read-only: no writes queued + ctx.value() + ctx.size() +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 0 +``` + +--- + +## RTBC16e - closed batch throws 40000 on any method call + +**Test ID**: `objects/unit/RTBC16e/closed-batch-throws-0` + +**Spec requirement:** After the batch is closed, any method call must throw 40000. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +saved_ctx = null +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + saved_ctx = ctx +}) + +saved_ctx.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +``` + +--- + +## RTBC16e - closed batch read methods also throw 40000 + +**Test ID**: `objects/unit/RTBC16e/closed-batch-read-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +saved_ctx = null +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + saved_ctx = ctx +}) + +saved_ctx.id() FAILS WITH error_id +saved_ctx.value() FAILS WITH error_value +saved_ctx.size() FAILS WITH error_size +``` + +### Assertions +```pseudo +ASSERT error_id.code == 40000 +ASSERT error_value.code == 40000 +ASSERT error_size.code == 40000 +``` + +--- + +## RTPO22g - RootBatchContext closed after flush regardless of success + +**Test ID**: `objects/unit/RTPO22g/closed-after-flush-0` + +**Spec requirement:** The RootBatchContext is closed after flush completes, regardless of success or failure. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +saved_ctx = null +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + saved_ctx = ctx + ctx.set("name", "Bob") +}) + +saved_ctx.set("age", 99) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +``` + +--- + +## RTPO22b - PathObject#batch requires OBJECT_PUBLISH mode + +**Test ID**: `objects/unit/RTPO22b/batch-requires-publish-mode-0` + +**Spec requirement:** Requires OBJECT_PUBLISH channel mode per RTO2. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS, modes: ["OBJECT_SUBSCRIBE"] + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.set("name", "Bob") +}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +``` diff --git a/uts/objects/unit/instance.md b/uts/objects/unit/instance.md new file mode 100644 index 000000000..221d635e7 --- /dev/null +++ b/uts/objects/unit/instance.md @@ -0,0 +1,524 @@ +# Instance Tests + +Spec points: `RTINS1`–`RTINS19` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTINS3 - id property returns objectId + +**Test ID**: `objects/unit/RTINS3/id-returns-objectid-0` + +| Spec | Requirement | +|------|-------------| +| RTINS3a | LiveObject -> returns objectId | +| RTINS3b | Primitive -> returns null | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +counter_inst = root.get("score").instance() +ASSERT counter_inst.id() == "counter:score@1000" + +map_inst = root.get("profile").instance() +ASSERT map_inst.id() == "map:profile@1000" +``` + +--- + +## RTINS4 - value() returns counter number or primitive + +**Test ID**: `objects/unit/RTINS4/value-counter-0` + +| Spec | Requirement | +|------|-------------| +| RTINS4a | LiveCounter -> numeric value | +| RTINS4c | LiveMap -> null | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +counter_inst = root.get("score").instance() +ASSERT counter_inst.value() == 100 + +map_inst = root.instance() +ASSERT map_inst.value() == null +``` + +--- + +## RTINS5 - get() returns Instance wrapping entry value + +**Test ID**: `objects/unit/RTINS5/get-wraps-entry-0` + +| Spec | Requirement | +|------|-------------| +| RTINS5b | LiveMap -> look up key, wrap result in Instance | +| RTINS5c | Non-LiveMap -> null | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +``` + +### Assertions +```pseudo +name_inst = root_inst.get("name") +ASSERT name_inst IS Instance +ASSERT name_inst.value() == "Alice" + +score_inst = root_inst.get("score") +ASSERT score_inst.id() == "counter:score@1000" + +null_inst = root_inst.get("nonexistent") +ASSERT null_inst == null +``` + +--- + +## RTINS6 - entries() yields [key, Instance] pairs + +**Test ID**: `objects/unit/RTINS6/entries-yields-instances-0` + +| Spec | Requirement | +|------|-------------| +| RTINS6a | LiveMap -> [key, Instance] pairs | +| RTINS6b | Non-LiveMap -> empty iterator | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +``` + +### Test Steps +```pseudo +entries = {} +FOR [key, inst] IN root_inst.entries(): + entries[key] = inst +``` + +### Assertions +```pseudo +ASSERT entries.length == 7 +ASSERT entries["name"] IS Instance +ASSERT entries["name"].value() == "Alice" +``` + +--- + +## RTINS9 - size() returns non-tombstoned count + +**Test ID**: `objects/unit/RTINS9/size-0` + +| Spec | Requirement | +|------|-------------| +| RTINS9a | LiveMap -> non-tombstoned entry count | +| RTINS9b | Non-LiveMap -> null | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +root_inst = root.instance() +ASSERT root_inst.size() == 7 + +counter_inst = root.get("score").instance() +ASSERT counter_inst.size() == null +``` + +--- + +## RTINS10 - compact() recursively compacts + +**Test ID**: `objects/unit/RTINS10/compact-0` + +**Spec requirement:** Behaves identically to PathObject#compact on the wrapped value. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +``` + +### Test Steps +```pseudo +result = root_inst.compact() +``` + +### Assertions +```pseudo +ASSERT result["name"] == "Alice" +ASSERT result["score"] == 100 +ASSERT result["profile"]["email"] == "alice@example.com" +``` + +--- + +## RTINS12 - set() delegates to LiveMap#set + +**Test ID**: `objects/unit/RTINS12/set-delegates-0` + +| Spec | Requirement | +|------|-------------| +| RTINS12b | LiveMap -> delegate to LiveMap#set | +| RTINS12c | Non-LiveMap -> throw 92007 | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +``` + +### Test Steps +```pseudo +AWAIT root_inst.set("name", "Bob") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Bob" +``` + +--- + +## RTINS12c - set() on non-LiveMap throws 92007 + +**Test ID**: `objects/unit/RTINS12c/set-non-map-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.set("key", "value") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTINS13 - remove() delegates to LiveMap#remove + +**Test ID**: `objects/unit/RTINS13/remove-delegates-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +``` + +### Test Steps +```pseudo +AWAIT root_inst.remove("name") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == null +``` + +--- + +## RTINS14 - increment() delegates to LiveCounter#increment + +**Test ID**: `objects/unit/RTINS14/increment-delegates-0` + +| Spec | Requirement | +|------|-------------| +| RTINS14b | LiveCounter -> delegate to increment | +| RTINS14c | Non-LiveCounter -> throw 92007 | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.increment(25) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 125 +``` + +--- + +## RTINS14c - increment() on non-LiveCounter throws 92007 + +**Test ID**: `objects/unit/RTINS14c/increment-non-counter-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +map_inst = root.instance() +``` + +### Test Steps +```pseudo +AWAIT map_inst.increment(5) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTINS15 - decrement() delegates to LiveCounter#decrement + +**Test ID**: `objects/unit/RTINS15/decrement-delegates-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.decrement(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 90 +``` + +--- + +## RTINS16 - subscribe() receives InstanceSubscriptionEvent + +**Test ID**: `objects/unit/RTINS16/subscribe-receives-events-0` + +| Spec | Requirement | +|------|-------------| +| RTINS16c | Subscribes via LiveObject#subscribe | +| RTINS16d1 | Event.object is the Instance | +| RTINS16e | Returns Subscription | +| RTINS16f | Identity-based subscription | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +events = [] +sub = counter_inst.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT sub IS Subscription +ASSERT events.length == 1 +ASSERT events[0].object IS Instance +ASSERT events[0].object.id() == "counter:score@1000" +``` + +--- + +## RTINS16b - subscribe() on primitive throws 92007 + +**Test ID**: `objects/unit/RTINS16b/subscribe-primitive-throws-0` + +**Spec requirement:** If wrapped value is not LiveObject, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +name_inst = root.instance().get("name") +name_inst.subscribe((event) => {}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTINS16f - Instance subscription follows identity not path + +**Test ID**: `objects/unit/RTINS16f/subscription-follows-identity-0` + +**Spec requirement:** Instance follows the specific LiveObject, regardless of tree position. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +events = [] +counter_inst.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") +])) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "100", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length >= 1 +ASSERT counter_inst.id() == "counter:score@1000" +``` + +--- + +## RTINS17 - unsubscribe() deregisters listener + +**Test ID**: `objects/unit/RTINS17/unsubscribe-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +events = [] +sub = counter_inst.subscribe((event) => events.append(event)) +sub.unsubscribe() +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 0 +``` + +--- + +## RTINS14a - increment() defaults to 1 + +**Test ID**: `objects/unit/RTINS14a/increment-default-0` + +**Spec requirement:** amount defaults to 1. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.increment() +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 101 +``` + +--- + +## RTINS15a - decrement() defaults to 1 + +**Test ID**: `objects/unit/RTINS15a/decrement-default-0` + +**Spec requirement:** amount defaults to 1. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.decrement() +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 99 +``` + +--- + +## RTINS16 - Subscription event contains message metadata + +**Test ID**: `objects/unit/RTINS16/subscription-event-metadata-0` + +| Spec | Requirement | +|------|-------------| +| RTINS16d1 | Event.object is the Instance | +| RTINS16d2 | Event.message is the ObjectMessage that triggered the update | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +events = [] +root_inst.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events[0].object IS Instance +ASSERT events[0].object.id() == "root" +ASSERT events[0].message IS NOT null +ASSERT events[0].message.operation.action == "MAP_SET" +ASSERT events[0].message.operation.mapSet.key == "name" +``` diff --git a/uts/objects/unit/live_counter.md b/uts/objects/unit/live_counter.md new file mode 100644 index 000000000..300f1779b --- /dev/null +++ b/uts/objects/unit/live_counter.md @@ -0,0 +1,824 @@ +# LiveCounter Tests + +Spec points: `RTLC1`, `RTLC3`, `RTLC4`, `RTLC6`, `RTLC7`, `RTLC8`, `RTLC9`, `RTLC14`, `RTLC16`, `RTLO3`, `RTLO4a`, `RTLO4e`, `RTLO5`, `RTLO6` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +Tests the `LiveCounter` CRDT data structure. LiveCounter holds a 64-bit float and supports increment operations, create operations (initial value merge), data replacement during sync, tombstoning, and serial-based newness checks. + +Tests operate directly on LiveCounter by calling `applyOperation()` and `replaceData()` with constructed messages. No channel or connection infrastructure is needed. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `build_counter_inc`, `build_counter_create`, `build_object_delete`, `build_object_state`. + +--- + +## RTLC4 - Zero-value LiveCounter + +**Test ID**: `objects/unit/RTLC4/zero-value-0` + +**Spec requirement:** The zero-value LiveCounter has data set to 0, empty siteTimeserials, createOperationIsMerged false, isTombstone false. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Assertions +```pseudo +ASSERT counter.data == 0 +ASSERT counter.objectId == "counter:abc@1000" +ASSERT counter.isTombstone == false +ASSERT counter.tombstonedAt == null +ASSERT counter.createOperationIsMerged == false +ASSERT counter.siteTimeserials == {} +``` + +--- + +## RTLC9 - COUNTER_INC adds number to data + +**Test ID**: `objects/unit/RTLC9/counter-inc-basic-0` + +| Spec | Requirement | +|------|-------------| +| RTLC9f | Add `CounterInc.number` to data if it exists | +| RTLC9g | Return LiveCounterUpdate with amount set to the number | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 5 +ASSERT update.noop == false +ASSERT update.update.amount == 5 +``` + +--- + +## RTLC9 - COUNTER_INC with negative number + +**Test ID**: `objects/unit/RTLC9/counter-inc-negative-0` + +**Spec requirement:** COUNTER_INC with a negative number decrements the counter. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 10 +counter.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", -3, "01", "site1") +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 7 +ASSERT update.update.amount == -3 +``` + +--- + +## RTLC9 - COUNTER_INC with missing number is noop + +**Test ID**: `objects/unit/RTLC9/counter-inc-missing-number-0` + +**Spec requirement:** If CounterInc.number does not exist, return noop. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 10 +``` + +### Test Steps +```pseudo +msg = ObjectMessage( + serial: "01", + siteCode: "site1", + operation: { + action: "COUNTER_INC", + objectId: "counter:abc@1000", + counterInc: {} + } +) +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 10 +ASSERT update.noop == true +``` + +--- + +## RTLC9 - Multiple COUNTER_INC operations accumulate + +**Test ID**: `objects/unit/RTLC9/counter-inc-accumulate-0` + +**Spec requirement:** Multiple increments accumulate additively. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +counter.applyOperation(build_counter_inc("counter:abc@1000", 10, "01", "site1"), source: CHANNEL) +counter.applyOperation(build_counter_inc("counter:abc@1000", 20, "02", "site1"), source: CHANNEL) +counter.applyOperation(build_counter_inc("counter:abc@1000", -5, "01", "site2"), source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 25 +``` + +--- + +## RTLC8, RTLC16 - COUNTER_CREATE merges initial count + +**Test ID**: `objects/unit/RTLC8/counter-create-merge-0` + +| Spec | Requirement | +|------|-------------| +| RTLC8c | Merge initial value via RTLC16 | +| RTLC16a | Add counterCreate.count to data | +| RTLC16b | Set createOperationIsMerged to true | +| RTLC16c | Return LiveCounterUpdate with amount = count | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_create("counter:abc@1000", { count: 42 }, "01", "site1") +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 42 +ASSERT counter.createOperationIsMerged == true +ASSERT update.update.amount == 42 +``` + +--- + +## RTLC8 - COUNTER_CREATE noop when already merged + +**Test ID**: `objects/unit/RTLC8/counter-create-already-merged-0` + +**Spec requirement:** If createOperationIsMerged is true, log and return noop. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 42 +counter.createOperationIsMerged = true +counter.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_counter_create("counter:abc@1000", { count: 99 }, "01", "site1") +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 42 +ASSERT update.noop == true +``` + +--- + +## RTLC16 - COUNTER_CREATE with missing count is noop + +**Test ID**: `objects/unit/RTLC16/counter-create-no-count-0` + +**Spec requirement:** If counterCreate.count does not exist, return noop. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_create("counter:abc@1000", {}, "01", "site1") +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 0 +ASSERT counter.createOperationIsMerged == true +ASSERT update.noop == true +``` + +--- + +## RTLO4a - canApplyOperation allows when siteSerial is empty + +**Test ID**: `objects/unit/RTLO4a/apply-empty-site-serial-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4a5 | If siteSerial is null or empty, return true | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result IS NOT false +ASSERT counter.data == 5 +``` + +--- + +## RTLO4a - canApplyOperation rejects stale serial + +**Test ID**: `objects/unit/RTLO4a/reject-stale-serial-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4a6 | Return true only if serial is greater than siteSerial lexicographically | +| RTLC7b | If canApplyOperation returns false, discard and return false | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.siteTimeserials = { "site1": "05" } +counter.data = 10 +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 99, "03", "site1") +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +ASSERT counter.data == 10 +``` + +--- + +## RTLO4a - canApplyOperation rejects equal serial + +**Test ID**: `objects/unit/RTLO4a/reject-equal-serial-0` + +**Spec requirement:** Serial must be strictly greater; equal serial is rejected. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.siteTimeserials = { "site1": "05" } +counter.data = 10 +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 99, "05", "site1") +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +ASSERT counter.data == 10 +``` + +--- + +## RTLO4a - canApplyOperation warns on empty serial or siteCode + +**Test ID**: `objects/unit/RTLO4a/warn-invalid-serial-0` + +**Spec requirement:** Both serial and siteCode must be non-empty strings. Otherwise, log warning and do not apply. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg_no_serial = ObjectMessage( + serial: "", + siteCode: "site1", + operation: { action: "COUNTER_INC", objectId: "counter:abc@1000", counterInc: { number: 5 } } +) +result1 = counter.applyOperation(msg_no_serial, source: CHANNEL) + +msg_no_site = ObjectMessage( + serial: "01", + siteCode: "", + operation: { action: "COUNTER_INC", objectId: "counter:abc@1000", counterInc: { number: 5 } } +) +result2 = counter.applyOperation(msg_no_site, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 0 +ASSERT result1 == false +ASSERT result2 == false +``` + +--- + +## RTLC7c - CHANNEL source updates siteTimeserials + +**Test ID**: `objects/unit/RTLC7c/channel-source-updates-serials-0` + +**Spec requirement:** If source is CHANNEL, set siteTimeserials[siteCode] = serial. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.siteTimeserials["site1"] == "01" +``` + +--- + +## RTLC7c - LOCAL source does not update siteTimeserials + +**Test ID**: `objects/unit/RTLC7c/local-source-no-serial-update-0` + +**Spec requirement:** If source is LOCAL, siteTimeserials must not be updated. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +counter.applyOperation(msg, source: LOCAL) +``` + +### Assertions +```pseudo +ASSERT counter.siteTimeserials == {} +ASSERT counter.data == 5 +``` + +--- + +## RTLC7g - applyOperation returns true on success + +**Test ID**: `objects/unit/RTLC7g/apply-returns-true-0` + +**Spec requirement:** Returns a boolean indicating whether the operation was successfully applied. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == true +``` + +--- + +## RTLO4e, RTLO5 - OBJECT_DELETE tombstones counter + +**Test ID**: `objects/unit/RTLO5/object-delete-tombstones-0` + +| Spec | Requirement | +|------|-------------| +| RTLO5b | Tombstone the LiveObject | +| RTLO4e2 | Set isTombstone to true | +| RTLO4e4 | Set data to zero-value | +| RTLC7d4a | Emit LiveCounterUpdate with negated previous value | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 42 +counter.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_object_delete("counter:abc@1000", "01", "site1", 1700000000000) +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.isTombstone == true +ASSERT counter.data == 0 +ASSERT counter.tombstonedAt == 1700000000000 +ASSERT update.update.amount == -42 +``` + +--- + +## RTLC7e - Operations on tombstoned counter are rejected + +**Test ID**: `objects/unit/RTLC7e/tombstoned-reject-ops-0` + +**Spec requirement:** If isTombstone is true, the operation cannot be applied. Return false. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.isTombstone = true +counter.tombstonedAt = 1700000000000 +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +ASSERT counter.data == 0 +``` + +--- + +## RTLO6 - tombstonedAt from serialTimestamp + +**Test ID**: `objects/unit/RTLO6/tombstoned-at-from-serial-timestamp-0` + +| Spec | Requirement | +|------|-------------| +| RTLO6a | tombstonedAt equals serialTimestamp if it exists | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_object_delete("counter:abc@1000", "01", "site1", 1700000050000) +counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.tombstonedAt == 1700000050000 +``` + +--- + +## RTLO6 - tombstonedAt from local clock when no serialTimestamp + +**Test ID**: `objects/unit/RTLO6/tombstoned-at-local-clock-0` + +| Spec | Requirement | +|------|-------------| +| RTLO6b | tombstonedAt equals current local time if serialTimestamp not provided | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +before_time = current_time() +``` + +### Test Steps +```pseudo +msg = build_object_delete("counter:abc@1000", "01", "site1") +counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +after_time = current_time() +ASSERT counter.tombstonedAt >= before_time +ASSERT counter.tombstonedAt <= after_time +``` + +--- + +## RTLC7d3 - Unsupported action is discarded + +**Test ID**: `objects/unit/RTLC7d3/unsupported-action-0` + +**Spec requirement:** Log warning, discard without action, return false. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = ObjectMessage( + serial: "01", + siteCode: "site1", + operation: { action: "MAP_SET", objectId: "counter:abc@1000", mapSet: { key: "x", value: { string: "y" } } } +) +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +ASSERT counter.data == 0 +``` + +--- + +## RTLC6 - replaceData sets data from ObjectState + +**Test ID**: `objects/unit/RTLC6/replace-data-basic-0` + +| Spec | Requirement | +|------|-------------| +| RTLC6a | Replace siteTimeserials from ObjectState | +| RTLC6b | Set createOperationIsMerged to false | +| RTLC6c | Set data to counter.count | +| RTLC6h | Return diff as LiveCounterUpdate | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 10 +counter.createOperationIsMerged = true +counter.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site2": "05"}, { + counter: { count: 50 } +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT counter.data == 50 +ASSERT counter.siteTimeserials == { "site2": "05" } +ASSERT counter.createOperationIsMerged == false +ASSERT update.update.amount == 40 +``` + +--- + +## RTLC6 - replaceData with createOp merges initial value + +**Test ID**: `objects/unit/RTLC6/replace-data-with-create-op-0` + +| Spec | Requirement | +|------|-------------| +| RTLC6c | Set data to counter.count | +| RTLC6d | If createOp present, merge via RTLC16 | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, { + counter: { count: 100 }, + createOp: { counterCreate: { count: 50 } } +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT counter.data == 150 +ASSERT counter.createOperationIsMerged == true +ASSERT update.update.amount == 150 +``` + +--- + +## RTLC6e - replaceData on tombstoned counter is noop + +**Test ID**: `objects/unit/RTLC6e/replace-data-tombstoned-noop-0` + +**Spec requirement:** If isTombstone is true, finish processing. Return noop. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.isTombstone = true +counter.tombstonedAt = 1700000000000 +counter.data = 0 +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, { + counter: { count: 999 } +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT counter.data == 0 +ASSERT update.noop == true +``` + +--- + +## RTLC6f - replaceData with tombstone flag tombstones counter + +**Test ID**: `objects/unit/RTLC6f/replace-data-tombstone-flag-0` + +| Spec | Requirement | +|------|-------------| +| RTLC6f | If ObjectState.tombstone is true, tombstone the counter | +| RTLC6f1 | Return LiveCounterUpdate with amount = negated previous data | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 30 +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, { + counter: { count: 0 }, + tombstone: true +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT counter.isTombstone == true +ASSERT counter.data == 0 +ASSERT update.update.amount == -30 +``` + +--- + +## RTLC6 - replaceData with missing counter.count defaults to 0 + +**Test ID**: `objects/unit/RTLC6/replace-data-missing-count-0` + +**Spec requirement:** Set data to counter.count, or to 0 if it does not exist. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 42 +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, { + counter: {} +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT counter.data == 0 +ASSERT update.update.amount == -42 +``` + +--- + +## RTLC14 - Diff calculation + +**Test ID**: `objects/unit/RTLC14/diff-calculation-0` + +**Spec requirement:** Return LiveCounterUpdate with amount = newData - previousData. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 20 +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, { + counter: { count: 75 } +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT update.update.amount == 55 +``` + +--- + +## RTLC8, RTLC16 - COUNTER_CREATE then COUNTER_INC accumulates + +**Test ID**: `objects/unit/RTLC8/create-then-inc-0` + +**Spec requirement:** Create operation merges initial count, then increment adds to it. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +counter.applyOperation( + build_counter_create("counter:abc@1000", { count: 100 }, "01", "site1"), + source: CHANNEL +) +counter.applyOperation( + build_counter_inc("counter:abc@1000", 25, "02", "site1"), + source: CHANNEL +) +``` + +### Assertions +```pseudo +ASSERT counter.data == 125 +ASSERT counter.createOperationIsMerged == true +``` + +--- + +## RTLO3 - LiveObject properties initialized correctly + +**Test ID**: `objects/unit/RTLO3/live-object-init-properties-0` + +| Spec | Requirement | +|------|-------------| +| RTLO3a1 | objectId must be provided in constructor | +| RTLO3b1 | siteTimeserials set to empty map | +| RTLO3c1 | createOperationIsMerged set to false | +| RTLO3d1 | isTombstone set to false | +| RTLO3e1 | tombstonedAt set to null | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:test@2000") +``` + +### Assertions +```pseudo +ASSERT counter.objectId == "counter:test@2000" +ASSERT counter.siteTimeserials == {} +ASSERT counter.createOperationIsMerged == false +ASSERT counter.isTombstone == false +ASSERT counter.tombstonedAt == null +``` diff --git a/uts/objects/unit/live_counter_api.md b/uts/objects/unit/live_counter_api.md new file mode 100644 index 000000000..2b5e733e9 --- /dev/null +++ b/uts/objects/unit/live_counter_api.md @@ -0,0 +1,343 @@ +# LiveCounter API Tests + +Spec points: `RTLC5`, `RTLC11`–`RTLC13` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTLC5 - value() returns current counter data + +**Test ID**: `objects/unit/RTLC5/value-returns-data-0` + +| Spec | Requirement | +|------|-------------| +| RTLC5c | Returns current data value | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +counter = root.get("score") +ASSERT counter.value() == 100 +``` + +--- + +## RTLC5a - value() requires OBJECT_SUBSCRIBE mode + +**Test ID**: `objects/unit/RTLC5a/value-requires-subscribe-0` + +**Spec requirement:** Requires OBJECT_SUBSCRIBE channel mode per RTO2. + +This is implicitly tested by `setup_synced_channel` which always includes OBJECT_SUBSCRIBE. A negative test would use a channel without OBJECT_SUBSCRIBE and verify the error. + +--- + +## RTLC12 - increment sends v6 COUNTER_INC message + +**Test ID**: `objects/unit/RTLC12/increment-sends-counter-inc-0` + +| Spec | Requirement | +|------|-------------| +| RTLC12e2 | action set to COUNTER_INC | +| RTLC12e3 | objectId set to counter's objectId | +| RTLC12e5 | counterInc.number set to amount | +| RTLC12g | Publishes via publishAndApply | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.increment(25) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "COUNTER_INC" +ASSERT obj_msg.operation.objectId == "counter:score@1000" +ASSERT obj_msg.operation.counterInc.number == 25 +``` + +--- + +## RTLC12 - increment applies locally after ACK + +**Test ID**: `objects/unit/RTLC12/increment-applies-locally-0` + +**Spec requirement:** Via publishAndApply, value reflects change after await. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment(50) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 150 +``` + +--- + +## RTLC12b - increment requires OBJECT_PUBLISH mode + +**Test ID**: `objects/unit/RTLC12b/increment-requires-publish-0` + +**Spec requirement:** Requires OBJECT_PUBLISH channel mode. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS, modes: ["OBJECT_SUBSCRIBE"] + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +``` + +--- + +## RTLC12d - increment with echoMessages false throws + +**Test ID**: `objects/unit/RTLC12d/echo-messages-false-0` + +**Spec requirement:** If echoMessages is false, throw 40000. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key", echoMessages: false }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +``` + +--- + +## RTLC12e1 - increment with non-number throws + +**Test ID**: `objects/unit/RTLC12e1/increment-non-number-0` + +**Spec requirement:** If amount is null, not Number, not finite, or omitted, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment("not_a_number") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLC13 - decrement delegates to increment with negated amount + +**Test ID**: `objects/unit/RTLC13/decrement-negates-0` + +| Spec | Requirement | +|------|-------------| +| RTLC13b | Alias for increment with negative amount | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.decrement(15) +``` + +### Assertions +```pseudo +ASSERT captured_messages[0].state[0].operation.counterInc.number == -15 +ASSERT root.get("score").value() == 85 +``` + +--- + +## RTLC11 - LiveCounterUpdate emitted on increment + +**Test ID**: `objects/unit/RTLC11/counter-update-on-inc-0` + +| Spec | Requirement | +|------|-------------| +| RTLC11b1 | update.amount is the increment value | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +updates = [] +instance = root.get("score").instance() +instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote-site") +])) + +poll_until(updates.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT updates[0].message.operation.counterInc.number == 7 +``` + +--- + +## RTLC12e1 - Table-driven invalid increment amounts + +**Test ID**: `objects/unit/RTLC12e1/increment-invalid-amounts-table-0` + +**Spec requirement:** If amount is null, not Number, not finite, or NaN, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +invalid_amounts = [ + { value: null, label: "null" }, + { value: NaN, label: "NaN" }, + { value: Infinity, label: "Infinity" }, + { value: -Infinity, label: "-Infinity" }, + { value: "10", label: "string" }, + { value: true, label: "boolean" }, + { value: [1, 2], label: "array" }, + { value: { n: 1 }, label: "object" } +] +``` + +### Test Steps +```pseudo +FOR scenario IN invalid_amounts: + AWAIT root.increment(scenario.value) FAILS WITH error + ASSERT error.code == 40003 +``` diff --git a/uts/objects/unit/live_map.md b/uts/objects/unit/live_map.md new file mode 100644 index 000000000..a930c17a3 --- /dev/null +++ b/uts/objects/unit/live_map.md @@ -0,0 +1,980 @@ +# LiveMap Tests + +Spec points: `RTLM1`–`RTLM9`, `RTLM14`–`RTLM16`, `RTLM18`–`RTLM19`, `RTLM22`–`RTLM25`, `RTLO3`, `RTLO4a`, `RTLO4e`, `RTLO5`, `RTLO6` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +Tests the `LiveMap` LWW-map CRDT data structure. LiveMap holds a dictionary of `ObjectsMapEntry` values with entry-level last-write-wins semantics, supports set/remove/clear operations, create operations (initial entries merge), data replacement during sync, tombstoning, GC of tombstoned entries, and diff calculation. + +Tests operate directly on LiveMap by calling `applyOperation()` and `replaceData()` with constructed messages. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for builder functions. + +--- + +## RTLM4 - Zero-value LiveMap + +**Test ID**: `objects/unit/RTLM4/zero-value-0` + +| Spec | Requirement | +|------|-------------| +| RTLM4 | Zero-value LiveMap has empty data map and null clearTimeserial | +| RTLM25 | clearTimeserial initially null | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Assertions +```pseudo +ASSERT map.data == {} +ASSERT map.clearTimeserial == null +ASSERT map.isTombstone == false +ASSERT map.createOperationIsMerged == false +ASSERT map.siteTimeserials == {} +``` + +--- + +## RTLM7 - MAP_SET creates new entry + +**Test ID**: `objects/unit/RTLM7/map-set-new-entry-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7b4 | Create new ObjectsMapEntry with data and timeserial | +| RTLM7f | Return LiveMapUpdate with key set to "updated" | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Alice" }, "01", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT map.data["name"].timeserial == "01" +ASSERT map.data["name"].tombstone == false +ASSERT update.update == { "name": "updated" } +``` + +--- + +## RTLM7 - MAP_SET updates existing entry + +**Test ID**: `objects/unit/RTLM7/map-set-update-entry-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7a2e | Set data to MapSet.value | +| RTLM7a2b | Set timeserial to the provided serial | +| RTLM7a2c | Set tombstone to false | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Bob" }, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Bob" } +ASSERT map.data["name"].timeserial == "02" +ASSERT update.update == { "name": "updated" } +``` + +--- + +## RTLM9 - LWW rejects stale serial on existing entry + +**Test ID**: `objects/unit/RTLM9/lww-reject-stale-0` + +| Spec | Requirement | +|------|-------------| +| RTLM9a | Operation serial must be strictly greater than entry serial | +| RTLM9e | Compare lexicographically | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "05", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Bob" }, "03", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT update.noop == true +``` + +--- + +## RTLM9 - LWW rejects equal serial + +**Test ID**: `objects/unit/RTLM9/lww-reject-equal-0` + +**Spec requirement:** Equal serials are rejected — must be strictly greater. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "05", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Bob" }, "05", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT update.noop == true +``` + +--- + +## RTLM9b - Both serials empty rejects operation + +**Test ID**: `objects/unit/RTLM9b/both-empty-reject-0` + +**Spec requirement:** If both the entry serial and operation serial are null/empty, considered equal, so operation is not applied. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Bob" }, "", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT update.noop == true +``` + +--- + +## RTLM9d - Missing entry serial allows operation + +**Test ID**: `objects/unit/RTLM9d/missing-entry-serial-allows-0` + +**Spec requirement:** If only the operation serial exists and is non-empty, it is greater than the missing entry serial. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: null, tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Bob" }, "01", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Bob" } +ASSERT update.update == { "name": "updated" } +``` + +--- + +## RTLM7h - MAP_SET rejected when serial <= clearTimeserial + +**Test ID**: `objects/unit/RTLM7h/map-set-clear-timeserial-floor-0` + +**Spec requirement:** If clearTimeserial is non-null and >= serial, discard operation. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.clearTimeserial = "05" +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Alice" }, "03", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT "name" NOT IN map.data +ASSERT update.noop == true +``` + +--- + +## RTLM7g - MAP_SET with objectId creates zero-value object + +**Test ID**: `objects/unit/RTLM7g/map-set-objectid-creates-zero-value-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7g | If MapSet.value.objectId is non-empty, create zero-value LiveObject | +| RTLM7g1 | Create via RTO6 | + +This test requires an ObjectsPool to be passed alongside the LiveMap. The LiveMap creates a zero-value object in the pool when it encounters an objectId reference. + +### Setup +```pseudo +pool = ObjectsPool() +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "score", { objectId: "counter:new@2000" }, "01", "site1") +map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT "counter:new@2000" IN pool +ASSERT pool["counter:new@2000"] IS LiveCounter +ASSERT pool["counter:new@2000"].data == 0 +``` + +--- + +## RTLM8 - MAP_REMOVE tombstones existing entry + +**Test ID**: `objects/unit/RTLM8/map-remove-existing-0` + +| Spec | Requirement | +|------|-------------| +| RTLM8a2a | Set data to null | +| RTLM8a2b | Set timeserial to serial | +| RTLM8a2c | Set tombstone to true | +| RTLM8a2d | Set tombstonedAt via RTLO6 | +| RTLM8e | Return LiveMapUpdate with key set to "removed" | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_remove("root", "name", "02", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == null +ASSERT map.data["name"].tombstone == true +ASSERT map.data["name"].timeserial == "02" +ASSERT map.data["name"].tombstonedAt == 1700000000000 +ASSERT update.update == { "name": "removed" } +``` + +--- + +## RTLM8 - MAP_REMOVE creates tombstoned entry if not exists + +**Test ID**: `objects/unit/RTLM8/map-remove-nonexistent-0` + +| Spec | Requirement | +|------|-------------| +| RTLM8b1 | Create new entry with data null and timeserial | +| RTLM8b2 | Set tombstone to true | +| RTLM8b3 | Set tombstonedAt via RTLO6 | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Test Steps +```pseudo +msg = build_map_remove("root", "ghost", "01", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["ghost"].tombstone == true +ASSERT map.data["ghost"].tombstonedAt == 1700000000000 +ASSERT update.update == { "ghost": "removed" } +``` + +--- + +## RTLM8g - MAP_REMOVE rejected when serial <= clearTimeserial + +**Test ID**: `objects/unit/RTLM8g/map-remove-clear-timeserial-floor-0` + +**Spec requirement:** If clearTimeserial is non-null and >= serial, discard operation. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.clearTimeserial = "05" +map.data = { + "name": { data: { string: "Alice" }, timeserial: "04", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_remove("root", "name", "03", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT map.data["name"].tombstone == false +ASSERT update.noop == true +``` + +--- + +## RTLM24 - MAP_CLEAR sets clearTimeserial and removes older entries + +**Test ID**: `objects/unit/RTLM24/map-clear-basic-0` + +| Spec | Requirement | +|------|-------------| +| RTLM24d | Set clearTimeserial to serial | +| RTLM24e1a | Remove entries with timeserial null or < serial | +| RTLM24f | Return LiveMapUpdate with removed keys | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "old": { data: { string: "old" }, timeserial: "02", tombstone: false }, + "new": { data: { string: "new" }, timeserial: "06", tombstone: false }, + "same": { data: { string: "same" }, timeserial: "04", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_clear("root", "04", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.clearTimeserial == "04" +ASSERT "old" NOT IN map.data +ASSERT "same" NOT IN map.data +ASSERT "new" IN map.data +ASSERT update.update == { "old": "removed", "same": "removed" } +``` + +--- + +## RTLM24c - MAP_CLEAR rejected when clearTimeserial is already greater + +**Test ID**: `objects/unit/RTLM24c/map-clear-stale-0` + +**Spec requirement:** If existing clearTimeserial is greater than provided serial, discard. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.clearTimeserial = "10" +``` + +### Test Steps +```pseudo +msg = build_map_clear("root", "05", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.clearTimeserial == "10" +ASSERT update.noop == true +``` + +--- + +## RTLM16, RTLM23 - MAP_CREATE merges entries + +**Test ID**: `objects/unit/RTLM16/map-create-merge-0` + +| Spec | Requirement | +|------|-------------| +| RTLM16d | Merge via RTLM23 | +| RTLM23a1 | Non-tombstoned entries merged via MAP_SET logic | +| RTLM23a2 | Tombstoned entries merged via MAP_REMOVE logic | +| RTLM23b | Set createOperationIsMerged to true | + +### Setup +```pseudo +map = LiveMap(objectId: "map:test@1000", semantics: "LWW") +``` + +### Test Steps +```pseudo +msg = build_map_create("map:test@1000", { + semantics: "LWW", + entries: { + "name": { data: { string: "Alice" }, timeserial: "01" }, + "removed_key": { tombstone: true, timeserial: "01", serialTimestamp: 1700000000000 } + } +}, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT map.data["removed_key"].tombstone == true +ASSERT map.createOperationIsMerged == true +ASSERT update.update == { "name": "updated", "removed_key": "removed" } +``` + +--- + +## RTLM16b - MAP_CREATE noop when already merged + +**Test ID**: `objects/unit/RTLM16b/map-create-already-merged-0` + +**Spec requirement:** If createOperationIsMerged is true, return noop. + +### Setup +```pseudo +map = LiveMap(objectId: "map:test@1000", semantics: "LWW") +map.createOperationIsMerged = true +map.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_map_create("map:test@1000", { + semantics: "LWW", + entries: { "name": { data: { string: "Bob" }, timeserial: "01" } } +}, "01", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT "name" NOT IN map.data +ASSERT update.noop == true +``` + +--- + +## RTLM15c - CHANNEL source updates siteTimeserials + +**Test ID**: `objects/unit/RTLM15c/channel-source-updates-serials-0` + +**Spec requirement:** If source is CHANNEL, set siteTimeserials[siteCode] = serial. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "x", { number: 1 }, "01", "site1") +map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.siteTimeserials["site1"] == "01" +``` + +--- + +## RTLM15e - Operations on tombstoned map are rejected + +**Test ID**: `objects/unit/RTLM15e/tombstoned-reject-ops-0` + +**Spec requirement:** If isTombstone is true, finish without action, return false. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.isTombstone = true +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "x", { number: 1 }, "01", "site1") +result = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +ASSERT map.data == {} +``` + +--- + +## RTLO5 - OBJECT_DELETE tombstones map + +**Test ID**: `objects/unit/RTLO5/object-delete-tombstones-map-0` + +| Spec | Requirement | +|------|-------------| +| RTLM15d5a | Emit LiveMapUpdate with removed keys | +| RTLM15d5b | Return true | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false }, + "age": { data: { number: 30 }, timeserial: "01", tombstone: false } +} +map.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_object_delete("root", "01", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.isTombstone == true +ASSERT map.data == {} +ASSERT update.update == { "name": "removed", "age": "removed" } +``` + +--- + +## RTLM14, RTLM14c - Tombstoned entry check includes objectId reference + +**Test ID**: `objects/unit/RTLM14/tombstone-check-objectid-ref-0` + +| Spec | Requirement | +|------|-------------| +| RTLM14a | Entry is tombstoned if entry.tombstone is true | +| RTLM14c | Entry is tombstoned if referenced LiveObject.isTombstone is true | + +### Setup +```pseudo +pool = ObjectsPool() +tombstoned_counter = LiveCounter(objectId: "counter:dead@1000") +tombstoned_counter.isTombstone = true +pool["counter:dead@1000"] = tombstoned_counter + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "alive": { data: { string: "ok" }, timeserial: "01", tombstone: false }, + "dead_entry": { data: null, timeserial: "01", tombstone: true }, + "dead_ref": { data: { objectId: "counter:dead@1000" }, timeserial: "01", tombstone: false } +} +``` + +### Assertions +```pseudo +ASSERT isTombstoned(map.data["alive"]) == false +ASSERT isTombstoned(map.data["dead_entry"]) == true +ASSERT isTombstoned(map.data["dead_ref"]) == true +``` + +--- + +## RTLM6 - replaceData sets data from ObjectState + +**Test ID**: `objects/unit/RTLM6/replace-data-basic-0` + +| Spec | Requirement | +|------|-------------| +| RTLM6a | Replace siteTimeserials | +| RTLM6b | Set createOperationIsMerged to false | +| RTLM6i | Set clearTimeserial from ObjectState.map.clearTimeserial | +| RTLM6c | Set data to ObjectState.map.entries | +| RTLM6h | Return diff LiveMapUpdate | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "old": { data: { string: "old" }, timeserial: "01", tombstone: false } +} +map.createOperationIsMerged = true +``` + +### Test Steps +```pseudo +state_msg = build_object_state("root", {"site2": "05"}, { + map: { + semantics: "LWW", + clearTimeserial: "03", + entries: { + "new": { data: { string: "new" }, timeserial: "04", tombstone: false } + } + } +}) +update = map.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT map.siteTimeserials == { "site2": "05" } +ASSERT map.createOperationIsMerged == false +ASSERT map.clearTimeserial == "03" +ASSERT "old" NOT IN map.data +ASSERT map.data["new"].data == { string: "new" } +ASSERT update.update == { "old": "removed", "new": "updated" } +``` + +--- + +## RTLM6c1 - replaceData sets tombstonedAt on tombstoned entries + +**Test ID**: `objects/unit/RTLM6c1/replace-data-tombstoned-entries-0` + +**Spec requirement:** For each tombstoned entry, set tombstonedAt via RTLO6. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Test Steps +```pseudo +state_msg = build_object_state("root", {"site1": "01"}, { + map: { + semantics: "LWW", + entries: { + "dead": { tombstone: true, timeserial: "01", serialTimestamp: 1700000050000 } + } + } +}) +map.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT map.data["dead"].tombstonedAt == 1700000050000 +``` + +--- + +## RTLM6d - replaceData with createOp merges initial entries + +**Test ID**: `objects/unit/RTLM6d/replace-data-with-create-op-0` + +**Spec requirement:** If createOp present, merge via RTLM23. + +### Setup +```pseudo +map = LiveMap(objectId: "map:test@1000", semantics: "LWW") +``` + +### Test Steps +```pseudo +state_msg = build_object_state("map:test@1000", {"site1": "01"}, { + map: { + semantics: "LWW", + entries: { + "from_sync": { data: { string: "synced" }, timeserial: "01" } + } + }, + createOp: { + mapCreate: { + semantics: "LWW", + entries: { + "from_create": { data: { string: "created" }, timeserial: "00" } + } + } + } +}) +map.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT map.data["from_sync"].data == { string: "synced" } +ASSERT map.data["from_create"].data == { string: "created" } +ASSERT map.createOperationIsMerged == true +``` + +--- + +## RTLM19 - GC removes tombstoned entries past grace period + +**Test ID**: `objects/unit/RTLM19/gc-tombstoned-entries-0` + +**Spec requirement:** Entries where tombstonedAt + gracePeriod <= currentTime are removed. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +grace_period = 86400000 +now = 1700100000000 + +map.data = { + "recent_dead": { data: null, timeserial: "01", tombstone: true, tombstonedAt: now - 1000 }, + "old_dead": { data: null, timeserial: "01", tombstone: true, tombstonedAt: now - grace_period - 1 }, + "alive": { data: { string: "ok" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +map.gcTombstonedEntries(grace_period, now) +``` + +### Assertions +```pseudo +ASSERT "recent_dead" IN map.data +ASSERT "old_dead" NOT IN map.data +ASSERT "alive" IN map.data +``` + +--- + +## RTLM22 - Diff between two data states + +**Test ID**: `objects/unit/RTLM22/diff-calculation-0` + +| Spec | Requirement | +|------|-------------| +| RTLM22b1 | Key in previous but not new -> removed | +| RTLM22b2 | Key in new but not previous -> updated | +| RTLM22b3 | Key in both with different data -> updated | +| RTLM22b | Only non-tombstoned entries are considered | + +### Setup +```pseudo +previousData = { + "removed": { data: { string: "gone" }, timeserial: "01", tombstone: false }, + "changed": { data: { string: "old" }, timeserial: "01", tombstone: false }, + "unchanged": { data: { string: "same" }, timeserial: "01", tombstone: false }, + "was_dead": { data: null, timeserial: "01", tombstone: true } +} + +newData = { + "added": { data: { string: "new" }, timeserial: "02", tombstone: false }, + "changed": { data: { string: "new_val" }, timeserial: "02", tombstone: false }, + "unchanged": { data: { string: "same" }, timeserial: "01", tombstone: false }, + "now_dead": { data: null, timeserial: "02", tombstone: true } +} +``` + +### Test Steps +```pseudo +update = LiveMap.diff(previousData, newData) +``` + +### Assertions +```pseudo +ASSERT update.update["removed"] == "removed" +ASSERT update.update["added"] == "updated" +ASSERT update.update["changed"] == "updated" +ASSERT "unchanged" NOT IN update.update +ASSERT "was_dead" NOT IN update.update +ASSERT "now_dead" NOT IN update.update +``` + +--- + +## RTLM15d4 - Unsupported action is discarded + +**Test ID**: `objects/unit/RTLM15d4/unsupported-action-0` + +**Spec requirement:** Log warning, discard, return false. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Test Steps +```pseudo +msg = ObjectMessage( + serial: "01", siteCode: "site1", + operation: { action: "COUNTER_INC", objectId: "root", counterInc: { number: 5 } } +) +result = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +``` + +--- + +## RTLM6i - replaceData without clearTimeserial resets to null + +**Test ID**: `objects/unit/RTLM6i/replace-data-resets-clear-timeserial-0` + +**Spec requirement:** If ObjectState.map.clearTimeserial is absent, clearTimeserial is reset to null. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.clearTimeserial = "05" +map.data = { + "x": { data: { number: 1 }, timeserial: "03", tombstone: false } +} +``` + +### Test Steps +```pseudo +state_msg = build_object_state("root", {"site1": "01"}, { + map: { + semantics: "LWW", + entries: { + "y": { data: { number: 2 }, timeserial: "01" } + } + } +}) +map.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT map.clearTimeserial == null +ASSERT "y" IN map.data +``` + +--- + +## RTLM14c, RTLM5 - MAP_SET referencing tombstoned objectId yields null value + +**Test ID**: `objects/unit/RTLM14c/tombstoned-ref-yields-null-0` + +**Spec requirement:** If entry references an objectId whose LiveObject is tombstoned, the entry is treated as tombstoned (RTLM14c). Value resolution returns null. + +### Setup +```pseudo +pool = ObjectsPool() +tombstoned_counter = LiveCounter(objectId: "counter:dead@1000") +tombstoned_counter.isTombstone = true +pool["counter:dead@1000"] = tombstoned_counter + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "ref": { data: { objectId: "counter:dead@1000" }, timeserial: "01", tombstone: false } +} +``` + +### Assertions +```pseudo +// The entry itself is not tombstoned, but the referenced object is +ASSERT map.data["ref"].tombstone == false +// size() should NOT count this entry because RTLM14c makes it tombstoned +ASSERT map.size() == 0 +// get() should return null for the value +ASSERT map.get("ref") == null +``` + +--- + +## RTLM7 - MAP_SET revives tombstoned entry + +**Test ID**: `objects/unit/RTLM7/map-set-revives-tombstoned-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7a2c | Set tombstone to false | +| RTLM7a2d | Set tombstonedAt to null | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: null, timeserial: "01", tombstone: true, tombstonedAt: 1700000000000 } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Alice" }, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT map.data["name"].tombstone == false +ASSERT map.data["name"].tombstonedAt == null +ASSERT update.update == { "name": "updated" } +``` + +--- + +## RTLM24 - MAP_CLEAR preserves entries with newer serial + +**Test ID**: `objects/unit/RTLM24/map-clear-preserves-newer-0` + +**Spec requirement:** Only entries with timeserial null or <= serial are removed. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "before": { data: { string: "a" }, timeserial: "03", tombstone: false }, + "after": { data: { string: "b" }, timeserial: "07", tombstone: false }, + "no_ts": { data: { string: "c" }, timeserial: null, tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_clear("root", "05", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT "before" NOT IN map.data +ASSERT "no_ts" NOT IN map.data +ASSERT map.data["after"].data == { string: "b" } +ASSERT "before" IN update.update +ASSERT "no_ts" IN update.update +ASSERT "after" NOT IN update.update +``` diff --git a/uts/objects/unit/live_map_api.md b/uts/objects/unit/live_map_api.md new file mode 100644 index 000000000..7a7282246 --- /dev/null +++ b/uts/objects/unit/live_map_api.md @@ -0,0 +1,483 @@ +# LiveMap API Tests + +Spec points: `RTLM5`, `RTLM10`–`RTLM13`, `RTLM20`–`RTLM21`, `RTLM24` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTLM5 - get() returns resolved value from LiveMap + +**Test ID**: `objects/unit/RTLM5/get-string-value-0` + +**Spec requirement:** Returns value at key, resolved per RTLM5d2. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Alice" +ASSERT root.get("age").value() == 30 +ASSERT root.get("active").value() == true +``` + +--- + +## RTLM5 - get() returns null for non-existent key + +**Test ID**: `objects/unit/RTLM5/get-nonexistent-key-0` + +**Spec requirement:** If no entry exists at key, return null. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("nonexistent").value() == null +``` + +--- + +## RTLM5 - get() resolves objectId to LiveObject + +**Test ID**: `objects/unit/RTLM5/get-objectid-reference-0` + +**Spec requirement:** If data.objectId exists, resolve from pool. Return LiveCounter/LiveMap. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 100 +ASSERT root.get("profile").get("email").value() == "alice@example.com" +``` + +--- + +## RTLM10 - size() returns non-tombstoned entry count + +**Test ID**: `objects/unit/RTLM10/size-non-tombstoned-0` + +**Spec requirement:** Returns number of non-tombstoned entries. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.size() == 7 +``` + +--- + +## RTLM11 - entries() yields key-value pairs + +**Test ID**: `objects/unit/RTLM11/entries-yields-pairs-0` + +**Spec requirement:** Returns non-tombstoned key-value pairs. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +entries = [] +FOR [key, pathObj] IN root.entries(): + entries.append(key) +``` + +### Assertions +```pseudo +ASSERT "name" IN entries +ASSERT "age" IN entries +ASSERT "active" IN entries +ASSERT "score" IN entries +ASSERT "profile" IN entries +ASSERT "data" IN entries +ASSERT "avatar" IN entries +ASSERT entries.length == 7 +``` + +--- + +## RTLM12 - keys() yields only keys + +**Test ID**: `objects/unit/RTLM12/keys-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +keys = list(root.keys()) +``` + +### Assertions +```pseudo +ASSERT keys.length == 7 +ASSERT "name" IN keys +``` + +--- + +## RTLM20 - set() sends MAP_SET message with v6 format + +**Test ID**: `objects/unit/RTLM20/set-sends-map-set-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20e2 | action set to MAP_SET | +| RTLM20e3 | objectId set to LiveMap's objectId | +| RTLM20e6 | mapSet.key set | +| RTLM20e7c | mapSet.value.string for string value | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_SET" +ASSERT obj_msg.operation.objectId == "root" +ASSERT obj_msg.operation.mapSet.key == "name" +ASSERT obj_msg.operation.mapSet.value.string == "Bob" +``` + +--- + +## RTLM20 - set() with different value types + +**Test ID**: `objects/unit/RTLM20/set-value-types-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20e7b | JsonArray/JsonObject -> mapSet.value.json | +| RTLM20e7d | Number -> mapSet.value.number | +| RTLM20e7e | Boolean -> mapSet.value.boolean | + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as above, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.set("num_key", 42) +AWAIT root.set("bool_key", false) +AWAIT root.set("json_key", {"nested": true}) +``` + +### Assertions +```pseudo +ASSERT captured_messages[0].state[0].operation.mapSet.value.number == 42 +ASSERT captured_messages[1].state[0].operation.mapSet.value.boolean == false +ASSERT captured_messages[2].state[0].operation.mapSet.value.json == {"nested": true} +``` + +--- + +## RTLM20e7g - set() with LiveCounterValueType consumes and sends create + set + +**Test ID**: `objects/unit/RTLM20e7g/set-counter-value-type-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20e7g1 | Consume value type to generate COUNTER_CREATE | +| RTLM20e7g2 | Set mapSet.value.objectId to the created objectId | +| RTLM20h1 | Array: CREATE messages then MAP_SET | + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as above) +``` + +### Test Steps +```pseudo +AWAIT root.set("new_counter", LiveCounter.create(50)) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +state = captured_messages[0].state +ASSERT state.length == 2 +ASSERT state[0].operation.action == "COUNTER_CREATE" +ASSERT state[0].operation.objectId STARTS WITH "counter:" +ASSERT state[1].operation.action == "MAP_SET" +ASSERT state[1].operation.mapSet.value.objectId == state[0].operation.objectId +``` + +--- + +## RTLM21 - remove() sends MAP_REMOVE message + +**Test ID**: `objects/unit/RTLM21/remove-sends-map-remove-0` + +| Spec | Requirement | +|------|-------------| +| RTLM21e2 | action set to MAP_REMOVE | +| RTLM21e5 | mapRemove.key set | + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as above) +``` + +### Test Steps +```pseudo +AWAIT root.remove("name") +``` + +### Assertions +```pseudo +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_REMOVE" +ASSERT obj_msg.operation.objectId == "root" +ASSERT obj_msg.operation.mapRemove.key == "name" +``` + +--- + +## RTLM20d - set() with echoMessages false throws + +**Test ID**: `objects/unit/RTLM20d/echo-messages-false-0` + +**Spec requirement:** If echoMessages is false, throw 40000. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key", echoMessages: false }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +``` + +--- + +## RTLM21d - remove() with echoMessages false throws + +**Test ID**: `objects/unit/RTLM21d/echo-messages-false-0` + +**Spec requirement:** Same as RTLM20d for remove. + +### Setup +```pseudo +// Same echoMessages: false setup as above +``` + +### Test Steps +```pseudo +AWAIT root.remove("name") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +``` + +--- + +## RTLM20 - set() applies locally after ACK + +**Test ID**: `objects/unit/RTLM20/set-applies-locally-0` + +**Spec requirement:** Via publishAndApply, local state reflects change after await. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Bob" +``` + +--- + +## RTLM24 - clear() sends MAP_CLEAR message + +**Test ID**: `objects/unit/RTLM24/clear-sends-map-clear-0` + +**Spec requirement:** Constructs MAP_CLEAR ObjectMessage. + +### Setup +```pseudo +captured_messages = [] +// (same mock setup capturing OBJECT messages) +``` + +### Test Steps +```pseudo +instance = root.instance() +AWAIT instance.clear() +``` + +### Assertions +```pseudo +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_CLEAR" +ASSERT obj_msg.operation.objectId == "root" +``` + +--- + +## RTLM20 - Table-driven invalid set value types + +**Test ID**: `objects/unit/RTLM20/set-invalid-values-table-0` + +**Spec requirement:** set() rejects values of unsupported types with error 40013. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +invalid_values = [ + { value: some_function, label: "function" }, + { value: undefined, label: "undefined" }, + { value: some_symbol, label: "symbol" } +] +``` + +### Test Steps +```pseudo +FOR scenario IN invalid_values: + AWAIT root.set("key", scenario.value) FAILS WITH error + ASSERT error.code == 40013 +``` + +--- + +## RTLM20 - set() with bytes value type + +**Test ID**: `objects/unit/RTLM20/set-bytes-value-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20e7f | Binary -> mapSet.value.bytes (base64 encoded) | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("binary_data", bytes([1, 2, 3])) +``` + +### Assertions +```pseudo +ASSERT captured_messages[0].state[0].operation.mapSet.value.bytes == "AQID" +``` diff --git a/uts/objects/unit/live_object_subscribe.md b/uts/objects/unit/live_object_subscribe.md new file mode 100644 index 000000000..5f8398e87 --- /dev/null +++ b/uts/objects/unit/live_object_subscribe.md @@ -0,0 +1,244 @@ +# LiveObject Subscribe Tests + +Spec points: `RTLO4b`, `RTLO4c` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTLO4b - subscribe registers listener for data updates + +**Test ID**: `objects/unit/RTLO4b/subscribe-receives-updates-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4b3 | User provides listener for data updates | +| RTLO4b4c2 | Listener called with LiveObjectUpdate | +| RTLO4b7 | Returns Subscription object | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.get("score").instance() +sub = instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT sub IS Subscription +ASSERT updates.length == 1 +``` + +--- + +## RTLO4b4c1 - noop update does not trigger listener + +**Test ID**: `objects/unit/RTLO4b4c1/noop-no-trigger-0` + +**Spec requirement:** If LiveObjectUpdate is a noop, do nothing. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.get("score").instance() +instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "01", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + ObjectMessage( + serial: "01", siteCode: "remote", + operation: { action: "COUNTER_INC", objectId: "counter:score@1000", counterInc: {} } + ) +])) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 +``` + +--- + +## RTLO4c - unsubscribe deregisters listener + +**Test ID**: `objects/unit/RTLO4c/unsubscribe-deregisters-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4c3 | Once deregistered, subsequent updates do not call listener | +| RTLO4c4 | No side effects on channel or RealtimeObject | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.get("score").instance() +sub = instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "01", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) + +sub.unsubscribe() + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "02", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 +``` + +--- + +## RTLO4b1 - subscribe requires OBJECT_SUBSCRIBE mode + +**Test ID**: `objects/unit/RTLO4b1/subscribe-requires-mode-0` + +**Spec requirement:** Requires OBJECT_SUBSCRIBE channel mode per RTO2. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS, modes: ["OBJECT_PUBLISH"] + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +instance = root.get("score").instance() +``` + +### Test Steps +```pseudo +instance.subscribe((event) => {}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +``` + +--- + +## RTLO4b6 - subscribe has no side effects + +**Test ID**: `objects/unit/RTLO4b6/subscribe-no-side-effects-0` + +**Spec requirement:** Must not have side effects on RealtimeObject, channel, or their status. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +state_before = channel.state +instance = root.get("score").instance() +``` + +### Test Steps +```pseudo +instance.subscribe((event) => {}) +``` + +### Assertions +```pseudo +ASSERT channel.state == state_before +``` + +--- + +## RTLO4b - subscribe on LiveMap receives LiveMapUpdate + +**Test ID**: `objects/unit/RTLO4b/subscribe-map-update-0` + +**Spec requirement:** LiveMapUpdate.update contains key -> "updated"/"removed". + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.instance() +instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 +``` + +--- + +## RTLO4c1 - unsubscribe requires no channel mode + +**Test ID**: `objects/unit/RTLO4c1/unsubscribe-no-mode-required-0` + +**Spec requirement:** Does not require any specific channel modes. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +instance = root.get("score").instance() +sub = instance.subscribe((event) => {}) +``` + +### Test Steps +```pseudo +sub.unsubscribe() +``` + +### Assertions +```pseudo +// No error thrown +``` diff --git a/uts/objects/unit/object_id.md b/uts/objects/unit/object_id.md new file mode 100644 index 000000000..8f51f7bc9 --- /dev/null +++ b/uts/objects/unit/object_id.md @@ -0,0 +1,159 @@ +# ObjectId Generation Tests + +Spec points: `RTO14` + +## Test Type +Unit test — pure function, no mocks required. + +## Purpose + +Tests the ObjectId generation procedure. ObjectId format is `{type}:{base64url(SHA-256(initialValue:nonce))}@{timestamp}`. This is a deterministic hash-based scheme that ensures uniqueness across clients. + +--- + +## RTO14 - ObjectId format for counter type + +**Test ID**: `objects/unit/RTO14/objectid-format-counter-0` + +| Spec | Requirement | +|------|-------------| +| RTO14a1 | type must be "map" or "counter" | +| RTO14b1 | SHA-256 of UTF-8 encoded "[initialValue]:[nonce]" | +| RTO14b2 | Base64URL encode (RFC 4648 s.5) | +| RTO14c | Format: [type]:[hash]@[timestamp] | + +### Test Steps +```pseudo +objectId = generateObjectId( + type: "counter", + initialValue: '{"counter":{"count":42}}', + nonce: "test-nonce-12345678", + timestamp: 1700000000000 +) +``` + +### Assertions +```pseudo +ASSERT objectId STARTS WITH "counter:" +ASSERT objectId CONTAINS "@1700000000000" +parts = objectId.split(":") +type_part = parts[0] +rest = parts[1] +hash_and_ts = rest.split("@") +hash_part = hash_and_ts[0] +ts_part = hash_and_ts[1] +ASSERT type_part == "counter" +ASSERT ts_part == "1700000000000" +ASSERT hash_part IS valid base64url string +ASSERT hash_part does NOT contain "+" or "/" or "=" +``` + +--- + +## RTO14 - ObjectId format for map type + +**Test ID**: `objects/unit/RTO14/objectid-format-map-0` + +**Spec requirement:** Same format with "map" type prefix. + +### Test Steps +```pseudo +objectId = generateObjectId( + type: "map", + initialValue: '{"map":{"semantics":"LWW","entries":{}}}', + nonce: "test-nonce-12345678", + timestamp: 1700000000000 +) +``` + +### Assertions +```pseudo +ASSERT objectId STARTS WITH "map:" +ASSERT objectId CONTAINS "@1700000000000" +``` + +--- + +## RTO14 - Deterministic output for same inputs + +**Test ID**: `objects/unit/RTO14/deterministic-0` + +**Spec requirement:** Same type, initialValue, nonce, and timestamp produce the same objectId. + +### Test Steps +```pseudo +id1 = generateObjectId( + type: "counter", + initialValue: '{"counter":{"count":0}}', + nonce: "same-nonce-1234567", + timestamp: 1700000000000 +) +id2 = generateObjectId( + type: "counter", + initialValue: '{"counter":{"count":0}}', + nonce: "same-nonce-1234567", + timestamp: 1700000000000 +) +``` + +### Assertions +```pseudo +ASSERT id1 == id2 +``` + +--- + +## RTO14 - Different nonce produces different objectId + +**Test ID**: `objects/unit/RTO14/different-nonce-0` + +**Spec requirement:** Nonce ensures uniqueness across clients. + +### Test Steps +```pseudo +id1 = generateObjectId( + type: "counter", + initialValue: '{"counter":{"count":0}}', + nonce: "nonce-aaaaaaaaaaaaa", + timestamp: 1700000000000 +) +id2 = generateObjectId( + type: "counter", + initialValue: '{"counter":{"count":0}}', + nonce: "nonce-bbbbbbbbbbbbb", + timestamp: 1700000000000 +) +``` + +### Assertions +```pseudo +ASSERT id1 != id2 +``` + +--- + +## RTO14b - SHA-256 hash is base64url encoded (not standard base64) + +**Test ID**: `objects/unit/RTO14b/base64url-encoding-0` + +| Spec | Requirement | +|------|-------------| +| RTO14b2 | Must use URL-safe Base64 per RFC 4648 s.5, not standard Base64 | + +### Test Steps +```pseudo +objectId = generateObjectId( + type: "counter", + initialValue: '{"counter":{"count":0}}', + nonce: "test-nonce-12345678", + timestamp: 1700000000000 +) +hash_part = objectId.split(":")[1].split("@")[0] +``` + +### Assertions +```pseudo +ASSERT hash_part does NOT contain "+" +ASSERT hash_part does NOT contain "/" +ASSERT hash_part does NOT end with "=" +``` diff --git a/uts/objects/unit/objects_pool.md b/uts/objects/unit/objects_pool.md new file mode 100644 index 000000000..214fe7db0 --- /dev/null +++ b/uts/objects/unit/objects_pool.md @@ -0,0 +1,910 @@ +# ObjectsPool Tests + +Spec points: `RTO3`–`RTO9` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +Tests the `ObjectsPool` internal data structure and sync state machine. ObjectsPool is a `Dict` that manages all objects on a channel. It processes ATTACHED messages (to determine sync mode), OBJECT_SYNC messages (to build state from server), and OBJECT messages (to apply operations). It maintains a SyncObjectsPool for accumulating sync data, buffers operations during SYNCING, and manages the INITIALIZED -> SYNCING -> SYNCED state transitions. + +Tests operate directly on ObjectsPool by calling `processAttached()`, `processObjectSync()`, and `processObjectMessage()`. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for builder functions and STANDARD_POOL_OBJECTS. + +--- + +## RTO3 - ObjectsPool initialization with root LiveMap + +**Test ID**: `objects/unit/RTO3/pool-init-root-0` + +| Spec | Requirement | +|------|-------------| +| RTO3a | ObjectsPool is Dict | +| RTO3b | Must always contain a LiveMap with id "root" | +| RTO3b1 | On initialization, create zero-value LiveMap with objectId "root" | + +### Setup +```pseudo +pool = ObjectsPool() +``` + +### Assertions +```pseudo +ASSERT "root" IN pool +ASSERT pool["root"] IS LiveMap +ASSERT pool["root"].data == {} +ASSERT pool["root"].objectId == "root" +``` + +--- + +## RTO4a - ATTACHED with HAS_OBJECTS flag starts SYNCING + +**Test ID**: `objects/unit/RTO4/attached-has-objects-syncing-0` + +| Spec | Requirement | +|------|-------------| +| RTO4c | Sync state transitions to SYNCING | +| RTO4d | bufferedObjectOperations cleared | +| RTO4a | HAS_OBJECTS=1 means server will send OBJECT_SYNC | + +### Setup +```pseudo +pool = ObjectsPool() +``` + +### Test Steps +```pseudo +pool.processAttached(ProtocolMessage( + action: ATTACHED, + channel: "test", + channelSerial: "sync1:cursor", + flags: HAS_OBJECTS +)) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCING +``` + +--- + +## RTO4b - ATTACHED without HAS_OBJECTS clears pool and goes to SYNCED + +**Test ID**: `objects/unit/RTO4b/attached-no-objects-synced-0` + +| Spec | Requirement | +|------|-------------| +| RTO4b1 | Remove all objects except root | +| RTO4b2 | Clear root LiveMap data to zero-value | +| RTO4b2a | Emit LiveMapUpdate for root with removed entries | +| RTO4b4 | Perform sync completion actions | + +### Setup +```pseudo +pool = ObjectsPool() +pool["counter:abc@1000"] = LiveCounter(objectId: "counter:abc@1000") +pool["root"].data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +updates = [] +pool["root"].subscribe((update) => updates.append(update)) + +pool.processAttached(ProtocolMessage( + action: ATTACHED, + channel: "test", + flags: 0 +)) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "counter:abc@1000" NOT IN pool +ASSERT "root" IN pool +ASSERT pool["root"].data == {} +ASSERT updates.length >= 1 +ASSERT updates[0].update == { "name": "removed" } +``` + +--- + +## RTO5 - OBJECT_SYNC complete sequence + +**Test ID**: `objects/unit/RTO5/sync-complete-sequence-0` + +| Spec | Requirement | +|------|-------------| +| RTO5a1 | channelSerial is "sequenceId:cursor" | +| RTO5a4 | Sync complete when cursor is empty | +| RTO5f1 | Store new entries in SyncObjectsPool | +| RTO5c8 | Transition to SYNCED | + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { "name": { data: { string: "Alice" }, timeserial: "t:0" } } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { + counter: { count: 42 }, + createOp: { counterCreate: { count: 42 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "root" IN pool +ASSERT "counter:abc@1000" IN pool +ASSERT pool["root"].data["name"].data == { string: "Alice" } +ASSERT pool["counter:abc@1000"].data == 42 +``` + +--- + +## RTO5a2 - New sync sequence discards previous + +**Test ID**: `objects/unit/RTO5a2/new-sequence-discards-old-0` + +| Spec | Requirement | +|------|-------------| +| RTO5a2a | SyncObjectsPool must be cleared | +| RTO5a2 | New sequence id starts fresh sync | + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "seq1:cursor", flags: HAS_OBJECTS +)) +pool.processObjectSync(build_object_sync_message("test", "seq1:more", [ + build_object_state("counter:old@1000", {"aaa": "t:0"}, { counter: { count: 10 } }) +])) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "seq2:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:new@1000", {"aaa": "t:0"}, { counter: { count: 99 } }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "counter:old@1000" NOT IN pool +ASSERT "counter:new@1000" IN pool +``` + +--- + +## RTO5f2a - Partial object state merge for maps + +**Test ID**: `objects/unit/RTO5f2a/partial-map-merge-0` + +| Spec | Requirement | +|------|-------------| +| RTO5f2 | Existing entry: partial state, merge into existing | +| RTO5f2a2 | Merge map entries from incoming into existing | + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:more", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { "name": { data: { string: "Alice" }, timeserial: "t:0" } } + } + }) +])) + +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { "age": { data: { number: 30 }, timeserial: "t:0" } } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool["root"].data["name"].data == { string: "Alice" } +ASSERT pool["root"].data["age"].data == { number: 30 } +``` + +--- + +## RTO5c2 - Sync completion removes objects not in sync + +**Test ID**: `objects/unit/RTO5c2/remove-absent-objects-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c2 | Remove objects not received during sync | +| RTO5c2a | root must not be removed | + +### Setup +```pseudo +pool = ObjectsPool() +pool["counter:old@1000"] = LiveCounter(objectId: "counter:old@1000") +pool["counter:old@1000"].data = 99 +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT "counter:old@1000" NOT IN pool +ASSERT "root" IN pool +``` + +--- + +## RTO5c9 - Sync completion clears appliedOnAckSerials + +**Test ID**: `objects/unit/RTO5c9/clear-applied-on-ack-serials-0` + +**Spec requirement:** appliedOnAckSerials set must be cleared after sync. + +### Setup +```pseudo +pool = ObjectsPool() +pool.appliedOnAckSerials = {"serial-1", "serial-2"} +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.appliedOnAckSerials == {} +``` + +--- + +## RTO7, RTO8a - OBJECT messages buffered during SYNCING + +**Test ID**: `objects/unit/RTO8a/buffer-during-syncing-0` + +| Spec | Requirement | +|------|-------------| +| RTO8a | If sync state is not SYNCED, buffer ObjectMessages | +| RTO7a | bufferedObjectOperations is an array | + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:abc@1000", 5, "01", "site1") +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCING +ASSERT pool.bufferedObjectOperations.length == 1 +ASSERT "counter:abc@1000" NOT IN pool +``` + +--- + +## RTO5c6, RTO8b - Buffered operations applied on sync completion + +**Test ID**: `objects/unit/RTO5c6/apply-buffered-on-sync-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c6 | Apply buffered operations with source CHANNEL | +| RTO8b | When SYNCED, apply directly | + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) + +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:abc@1000", 10, "02", "site1") +])) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { + counter: { count: 100 }, + createOp: { counterCreate: { count: 100 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool["counter:abc@1000"].data == 110 +ASSERT pool.bufferedObjectOperations.length == 0 +``` + +--- + +## RTO9a1 - Null operation is discarded with warning + +**Test ID**: `objects/unit/RTO9a1/null-operation-warning-0` + +**Spec requirement:** If ObjectMessage.operation is null or omitted, log warning and discard. + +### Setup +```pseudo +pool = ObjectsPool() +pool.syncState = SYNCED +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + ObjectMessage(serial: "01", siteCode: "site1", operation: null) +])) +``` + +### Assertions +```pseudo +ASSERT pool.keys().length == 1 +``` + +--- + +## RTO9a3 - appliedOnAckSerials deduplication + +**Test ID**: `objects/unit/RTO9a3/dedup-applied-on-ack-0` + +**Spec requirement:** If appliedOnAckSerials contains the serial, log debug, remove from set, and discard. + +### Setup +```pseudo +pool = ObjectsPool() +pool.syncState = SYNCED +pool["counter:abc@1000"] = LiveCounter(objectId: "counter:abc@1000") +pool["counter:abc@1000"].data = 10 +pool.appliedOnAckSerials = {"echo-serial-1"} +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + ObjectMessage( + serial: "echo-serial-1", + siteCode: "site1", + operation: { action: "COUNTER_INC", objectId: "counter:abc@1000", counterInc: { number: 5 } } + ) +])) +``` + +### Assertions +```pseudo +ASSERT pool["counter:abc@1000"].data == 10 +ASSERT "echo-serial-1" NOT IN pool.appliedOnAckSerials +``` + +--- + +## RTO9a2a4 - LOCAL source adds serial to appliedOnAckSerials + +**Test ID**: `objects/unit/RTO9a2a4/local-source-adds-serial-0` + +**Spec requirement:** If source is LOCAL and operation was applied successfully, add serial to appliedOnAckSerials. + +### Setup +```pseudo +pool = ObjectsPool() +pool.syncState = SYNCED +pool["counter:abc@1000"] = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +pool.applyObjectMessages([ + build_counter_inc("counter:abc@1000", 5, "local-serial-1", "test-site") +], source: LOCAL) +``` + +### Assertions +```pseudo +ASSERT "local-serial-1" IN pool.appliedOnAckSerials +ASSERT pool["counter:abc@1000"].data == 5 +``` + +--- + +## RTO9a2b - Unsupported action is discarded with warning + +**Test ID**: `objects/unit/RTO9a2b/unsupported-action-warning-0` + +**Spec requirement:** Log warning, discard. + +### Setup +```pseudo +pool = ObjectsPool() +pool.syncState = SYNCED +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + ObjectMessage( + serial: "01", siteCode: "site1", + operation: { action: "UNKNOWN_ACTION", objectId: "counter:abc@1000" } + ) +])) +``` + +### Assertions +```pseudo +ASSERT pool.keys().length == 1 +``` + +--- + +## RTO6 - Zero-value object creation from objectId prefix + +**Test ID**: `objects/unit/RTO6/zero-value-from-prefix-0` + +| Spec | Requirement | +|------|-------------| +| RTO6b1 | Parse type from objectId prefix before ":" | +| RTO6b2 | "map" prefix creates zero-value LiveMap | +| RTO6b3 | "counter" prefix creates zero-value LiveCounter | +| RTO6a | Skip if object already exists | + +### Setup +```pseudo +pool = ObjectsPool() +pool.syncState = SYNCED +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:new@2000", 5, "01", "site1") +])) +pool.processObjectMessage(build_object_message("test", [ + build_map_set("map:new@2000", "key", { string: "val" }, "01", "site1") +])) +``` + +### Assertions +```pseudo +ASSERT "counter:new@2000" IN pool +ASSERT pool["counter:new@2000"] IS LiveCounter +ASSERT pool["counter:new@2000"].data == 5 + +ASSERT "map:new@2000" IN pool +ASSERT pool["map:new@2000"] IS LiveMap +ASSERT pool["map:new@2000"].data["key"].data == { string: "val" } +``` + +--- + +## RTO5d - OBJECT_SYNC with null object field is skipped + +**Test ID**: `objects/unit/RTO5d/null-object-skipped-0` + +**Spec requirement:** If ObjectMessage.object is null or omitted, skip processing. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + ObjectMessage(object: null), + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +``` + +--- + +## RTO5f3 - OBJECT_SYNC with unsupported object type is skipped + +**Test ID**: `objects/unit/RTO5f3/unsupported-type-skipped-0` + +**Spec requirement:** If neither map nor counter is present, log warning and skip. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + ObjectMessage(object: { objectId: "unknown:xyz@1000", siteTimeserials: {} }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "unknown:xyz@1000" NOT IN pool +``` + +--- + +## RTO5e - OBJECT_SYNC transitions to SYNCING + +**Test ID**: `objects/unit/RTO5e/object-sync-transitions-syncing-0` + +**Spec requirement:** When OBJECT_SYNC received, sync state must transition to SYNCING if not already. + +### Setup +```pseudo +pool = ObjectsPool() +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:more", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCING +``` + +--- + +## RTO5c7 - Sync completion emits updates for existing objects + +**Test ID**: `objects/unit/RTO5c7/sync-emits-updates-0` + +**Spec requirement:** For each previously existing object updated by sync, emit the stored LiveObjectUpdate. + +### Setup +```pseudo +pool = ObjectsPool() +pool["root"].data = { + "name": { data: { string: "Old" }, timeserial: "01", tombstone: false } +} + +updates = [] +pool["root"].subscribe((update) => updates.append(update)) + +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { "name": { data: { string: "New" }, timeserial: "t:0" } } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT updates.length >= 1 +ASSERT "name" IN updates[0].update +ASSERT updates[0].update["name"] == "updated" +``` + +--- + +## RTO5f2b - Partial counter state logs error + +**Test ID**: `objects/unit/RTO5f2b/partial-counter-error-0` + +**Spec requirement:** If counter is present on partial merge, log error and skip. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:more", [ + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { counter: { count: 10 } }) +])) +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { counter: { count: 5 } }) +])) +``` + +### Assertions +```pseudo +ASSERT pool["counter:abc@1000"].data == 10 +``` + +--- + +## RTO4d - ATTACHED clears buffered operations + +**Test ID**: `objects/unit/RTO4d/attached-clears-buffer-0` + +**Spec requirement:** On ATTACHED, bufferedObjectOperations is cleared. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) + +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:abc@1000", 5, "01", "site1") +])) +ASSERT pool.bufferedObjectOperations.length == 1 +``` + +### Test Steps +```pseudo +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +``` + +### Assertions +```pseudo +ASSERT pool.bufferedObjectOperations.length == 0 +``` + +--- + +## RTO4, RTO5 - ATTACHED during SYNCING resets sync + +**Test ID**: `objects/unit/RTO4-RTO5/attached-during-syncing-resets-0` + +**Spec requirement:** A new ATTACHED message during SYNCING resets the sync state machine. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +pool.processObjectSync(build_object_sync_message("test", "sync1:more", [ + build_object_state("counter:old@1000", {"aaa": "t:0"}, { counter: { count: 10 } }) +])) +ASSERT pool.syncState == SYNCING +``` + +### Test Steps +```pseudo +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) + +pool.processObjectSync(build_object_sync_message("test", "sync2:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:new@1000", {"aaa": "t:0"}, { counter: { count: 99 } }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "counter:old@1000" NOT IN pool +ASSERT "counter:new@1000" IN pool +``` + +--- + +## RTO5, RTO7 - New OBJECT_SYNC sequence does NOT clear buffer + +**Test ID**: `objects/unit/RTO5-RTO7/new-sync-keeps-buffer-0` + +**Spec requirement:** When a new OBJECT_SYNC sequence starts (RTO5a2), only the SyncObjectsPool is discarded. Buffered OBJECT messages are retained for application after sync completion. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) + +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:abc@1000", 5, "01", "site1") +])) +ASSERT pool.bufferedObjectOperations.length == 1 +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "seq2:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { + counter: { count: 100 }, + createOp: { counterCreate: { count: 100 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT pool["counter:abc@1000"].data == 105 +``` + +--- + +## RTO7, RTO8 - OBJECT messages buffered even without preceding ATTACHED + +**Test ID**: `objects/unit/RTO7-RTO8/buffer-without-attached-0` + +**Spec requirement:** RTO8a: if sync state is not SYNCED, buffer ObjectMessages. This applies regardless of whether ATTACHED was received — INITIALIZED state also buffers. + +### Setup +```pseudo +pool = ObjectsPool() +ASSERT pool.syncState == INITIALIZED +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:abc@1000", 5, "01", "site1") +])) +``` + +### Assertions +```pseudo +ASSERT pool.bufferedObjectOperations.length == 1 +``` + +--- + +## RTO5c, RTLM23 - Sync with clearTimeserial hides initial createOp entries + +**Test ID**: `objects/unit/RTO5c-RTLM23/sync-clear-timeserial-hides-create-entries-0` + +**Spec requirement:** When a map's ObjectState includes a clearTimeserial, createOp entries with serials <= clearTimeserial are rejected during merge. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: {}, + clearTimeserial: "05" + }, + createOp: { + mapCreate: { + semantics: "LWW", + entries: { + "old_key": { data: { string: "old" }, timeserial: "03" }, + "new_key": { data: { string: "new" }, timeserial: "07" } + } + } + } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "old_key" NOT IN pool["root"].data +ASSERT pool["root"].data["new_key"].data == { string: "new" } +``` diff --git a/uts/objects/unit/path_object.md b/uts/objects/unit/path_object.md new file mode 100644 index 000000000..5a83c8e9c --- /dev/null +++ b/uts/objects/unit/path_object.md @@ -0,0 +1,603 @@ +# PathObject Read Operations Tests + +Spec points: `RTPO1`–`RTPO14` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTPO4 - path() returns dot-delimited string + +**Test ID**: `objects/unit/RTPO4/path-string-representation-0` + +| Spec | Requirement | +|------|-------------| +| RTPO4a | Dot-delimited string of path segments | +| RTPO4c | Empty path returns empty string | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.path() == "" +ASSERT root.get("profile").path() == "profile" +ASSERT root.get("profile").get("email").path() == "profile.email" +``` + +--- + +## RTPO4b - path() escapes dots in segments + +**Test ID**: `objects/unit/RTPO4b/path-escapes-dots-0` + +**Spec requirement:** Dot characters within segments are escaped with backslash. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +po = root.get("a.b").get("c") +``` + +### Assertions +```pseudo +ASSERT po.path() == "a\\.b.c" +``` + +--- + +## RTPO5 - get() returns new PathObject with appended key + +**Test ID**: `objects/unit/RTPO5/get-appends-key-0` + +| Spec | Requirement | +|------|-------------| +| RTPO5c | New PathObject with key appended | +| RTPO5d | Purely navigational, no resolution | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +child = root.get("profile") +grandchild = child.get("email") +``` + +### Assertions +```pseudo +ASSERT child.path() == "profile" +ASSERT grandchild.path() == "profile.email" +ASSERT child IS NOT root +``` + +--- + +## RTPO5b - get() throws on non-string key + +**Test ID**: `objects/unit/RTPO5b/get-non-string-throws-0` + +**Spec requirement:** If key is not String, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root.get(123) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTPO6 - at() parses dot-delimited path + +**Test ID**: `objects/unit/RTPO6/at-parses-path-0` + +| Spec | Requirement | +|------|-------------| +| RTPO6b | Parses dots as separators, backslash-escaped dots as literal | +| RTPO6d | Equivalent to chained get() calls | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +po = root.at("profile.email") +``` + +### Assertions +```pseudo +ASSERT po.path() == "profile.email" +ASSERT po.value() == "alice@example.com" +``` + +--- + +## RTPO6 - at() respects escaped dots + +**Test ID**: `objects/unit/RTPO6/at-escaped-dots-0` + +**Spec requirement:** `\.` is a literal dot within a segment. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +po = root.at("a\\.b.c") +``` + +### Assertions +```pseudo +ASSERT po.path() == "a\\.b.c" +``` + +--- + +## RTPO7 - value() returns counter numeric value + +**Test ID**: `objects/unit/RTPO7/value-counter-0` + +**Spec requirement:** If resolved value is LiveCounter, returns numeric value. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 100 +``` + +--- + +## RTPO7 - value() returns primitive value + +**Test ID**: `objects/unit/RTPO7/value-primitive-0` + +**Spec requirement:** If resolved value is a primitive, returns the value directly. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Alice" +ASSERT root.get("age").value() == 30 +ASSERT root.get("active").value() == true +``` + +--- + +## RTPO7d - value() returns null for LiveMap + +**Test ID**: `objects/unit/RTPO7d/value-livemap-null-0` + +**Spec requirement:** If resolved value is a LiveMap, returns null. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("profile").value() == null +``` + +--- + +## RTPO7e - value() returns null on resolution failure + +**Test ID**: `objects/unit/RTPO7e/value-unresolvable-null-0` + +**Spec requirement:** If path resolution fails, returns null. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("nonexistent").get("deep").value() == null +``` + +--- + +## RTPO8 - instance() returns Instance for LiveObject + +**Test ID**: `objects/unit/RTPO8/instance-live-object-0` + +| Spec | Requirement | +|------|-------------| +| RTPO8b | LiveMap or LiveCounter -> Instance wrapping that object | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +counter_inst = root.get("score").instance() +ASSERT counter_inst IS Instance +ASSERT counter_inst.id() == "counter:score@1000" + +map_inst = root.get("profile").instance() +ASSERT map_inst IS Instance +ASSERT map_inst.id() == "map:profile@1000" +``` + +--- + +## RTPO8c - instance() returns null for primitive + +**Test ID**: `objects/unit/RTPO8c/instance-primitive-null-0` + +**Spec requirement:** If resolved value is a primitive, returns null. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("name").instance() == null +``` + +--- + +## RTPO9 - entries() yields [key, PathObject] pairs + +**Test ID**: `objects/unit/RTPO9/entries-yields-pairs-0` + +| Spec | Requirement | +|------|-------------| +| RTPO9b | Iterator of [key, PathObject] for LiveMap entries | +| RTPO9c | Only non-tombstoned entries | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +entries = {} +FOR [key, pathObj] IN root.entries(): + entries[key] = pathObj.path() +``` + +### Assertions +```pseudo +ASSERT entries["name"] == "name" +ASSERT entries["profile"] == "profile" +ASSERT entries.length == 7 +``` + +--- + +## RTPO9d - entries() returns empty iterator for non-LiveMap + +**Test ID**: `objects/unit/RTPO9d/entries-non-map-empty-0` + +**Spec requirement:** If resolved value is not LiveMap or resolution fails, return empty iterator. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +entries = list(root.get("score").entries()) +``` + +### Assertions +```pseudo +ASSERT entries.length == 0 +``` + +--- + +## RTPO12 - size() returns non-tombstoned count + +**Test ID**: `objects/unit/RTPO12/size-count-0` + +**Spec requirement:** For LiveMap, returns non-tombstoned entry count. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.size() == 7 +ASSERT root.get("profile").size() == 3 +``` + +--- + +## RTPO12c - size() returns null for non-LiveMap + +**Test ID**: `objects/unit/RTPO12c/size-non-map-null-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("score").size() == null +ASSERT root.get("name").size() == null +``` + +--- + +## RTPO13 - compact() recursively compacts LiveMap tree + +**Test ID**: `objects/unit/RTPO13/compact-recursive-0` + +| Spec | Requirement | +|------|-------------| +| RTPO13b1 | Each entry included, tombstoned excluded | +| RTPO13b2 | Nested LiveMap recursively compacted | +| RTPO13b3 | Nested LiveCounter resolved to number | +| RTPO13b4 | Primitives as-is | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +result = root.compact() +``` + +### Assertions +```pseudo +ASSERT result["name"] == "Alice" +ASSERT result["age"] == 30 +ASSERT result["active"] == true +ASSERT result["score"] == 100 +ASSERT result["data"] == {"tags": ["a", "b"]} +ASSERT result["avatar"] IS bytes [1, 2, 3] +ASSERT result["profile"]["email"] == "alice@example.com" +ASSERT result["profile"]["nested_counter"] == 5 +ASSERT result["profile"]["prefs"]["theme"] == "dark" +``` + +--- + +## RTPO13b5 - compact() handles cycles via shared reference + +**Test ID**: `objects/unit/RTPO13b5/compact-cycle-detection-0` + +**Spec requirement:** Cyclic references reuse the already-compacted in-memory object. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:prefs@1000", "back_ref", { objectId: "map:profile@1000" }, "99", "remote") +])) +``` + +### Test Steps +```pseudo +result = root.get("profile").compact() +``` + +### Assertions +```pseudo +ASSERT result["prefs"]["back_ref"] IS result +``` + +--- + +## RTPO13c - compact() returns number for LiveCounter + +**Test ID**: `objects/unit/RTPO13c/compact-counter-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("score").compact() == 100 +``` + +--- + +## RTPO14 - compactJson() encodes binary as base64 and cycles as objectId + +**Test ID**: `objects/unit/RTPO14/compact-json-0` + +| Spec | Requirement | +|------|-------------| +| RTPO14a1 | Binary as base64 strings | +| RTPO14a2 | Cycles as {objectId: ...} | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:prefs@1000", "back_ref", { objectId: "map:profile@1000" }, "99", "remote") +])) +``` + +### Test Steps +```pseudo +result = root.get("profile").compactJson() +``` + +### Assertions +```pseudo +ASSERT result["prefs"]["back_ref"] == { "objectId": "map:profile@1000" } +``` + +--- + +## RTPO3 - Path resolution walks through LiveMaps + +**Test ID**: `objects/unit/RTPO3/path-resolution-walk-0` + +| Spec | Requirement | +|------|-------------| +| RTPO3a | Walk segments through LiveMaps | +| RTPO3b | Empty path resolves to root | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.value() == null +ASSERT root.get("profile").get("prefs").get("theme").value() == "dark" +``` + +--- + +## RTPO3a1 - Resolution fails if intermediate is not LiveMap + +**Test ID**: `objects/unit/RTPO3a1/intermediate-not-map-0` + +**Spec requirement:** Current object must be a LiveMap. If not, resolution fails. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("score").get("something").value() == null +``` + +--- + +## RTPO3c1 - Read operation returns null on resolution failure + +**Test ID**: `objects/unit/RTPO3c1/read-null-on-failure-0` + +**Spec requirement:** For read operations, return null. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("nonexistent").value() == null +ASSERT root.get("nonexistent").instance() == null +ASSERT root.get("nonexistent").size() == null +ASSERT root.get("nonexistent").compact() == null +``` + +--- + +## RTPO6b - at() throws for non-string input + +**Test ID**: `objects/unit/RTPO6b/at-non-string-throws-0` + +**Spec requirement:** If path is not String, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root.at(123) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTPO7 - value() returns bytes for binary entry + +**Test ID**: `objects/unit/RTPO7/value-bytes-0` + +**Spec requirement:** If resolved value is bytes, returns the raw binary data. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("avatar").value() IS bytes [1, 2, 3] +``` + +--- + +## RTPO14 - compactJson() encodes bytes as base64 string + +**Test ID**: `objects/unit/RTPO14/compact-json-bytes-0` + +**Spec requirement:** Binary values encoded as base64 strings in JSON representation. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +result = root.compactJson() +``` + +### Assertions +```pseudo +ASSERT result["avatar"] == "AQID" +``` diff --git a/uts/objects/unit/path_object_mutations.md b/uts/objects/unit/path_object_mutations.md new file mode 100644 index 000000000..ef33a1a15 --- /dev/null +++ b/uts/objects/unit/path_object_mutations.md @@ -0,0 +1,321 @@ +# PathObject Write Operations Tests + +Spec points: `RTPO15`–`RTPO18`, `RTPO3c2` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTPO15 - set() delegates to LiveMap#set + +**Test ID**: `objects/unit/RTPO15/set-delegates-to-map-0` + +| Spec | Requirement | +|------|-------------| +| RTPO15b | Resolves path, on failure throws RTPO3c2 | +| RTPO15c | LiveMap -> delegates to LiveMap#set | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Bob" +``` + +--- + +## RTPO15 - set() on nested path + +**Test ID**: `objects/unit/RTPO15/set-nested-path-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("profile").set("email", "bob@example.com") +``` + +### Assertions +```pseudo +ASSERT root.get("profile").get("email").value() == "bob@example.com" +``` + +--- + +## RTPO15d - set() on non-LiveMap throws 92007 + +**Test ID**: `objects/unit/RTPO15d/set-non-map-throws-0` + +**Spec requirement:** If resolved value is not a LiveMap, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").set("key", "value") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTPO16 - remove() delegates to LiveMap#remove + +**Test ID**: `objects/unit/RTPO16/remove-delegates-to-map-0` + +| Spec | Requirement | +|------|-------------| +| RTPO16b | Resolves path, on failure throws RTPO3c2 | +| RTPO16c | LiveMap -> delegates to LiveMap#remove | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.remove("name") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == null +``` + +--- + +## RTPO16d - remove() on non-LiveMap throws 92007 + +**Test ID**: `objects/unit/RTPO16d/remove-non-map-throws-0` + +**Spec requirement:** If resolved value is not a LiveMap, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").remove("key") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTPO17 - increment() delegates to LiveCounter#increment + +**Test ID**: `objects/unit/RTPO17/increment-delegates-to-counter-0` + +| Spec | Requirement | +|------|-------------| +| RTPO17b | Resolves path, on failure throws RTPO3c2 | +| RTPO17c | LiveCounter -> delegates to LiveCounter#increment | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").increment(25) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 125 +``` + +--- + +## RTPO17 - increment() defaults to 1 + +**Test ID**: `objects/unit/RTPO17/increment-default-amount-0` + +**Spec requirement:** amount defaults to 1. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").increment() +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 101 +``` + +--- + +## RTPO17d - increment() on non-LiveCounter throws 92007 + +**Test ID**: `objects/unit/RTPO17d/increment-non-counter-throws-0` + +**Spec requirement:** If resolved value is not a LiveCounter, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment(5) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTPO18 - decrement() delegates to LiveCounter#decrement + +**Test ID**: `objects/unit/RTPO18/decrement-delegates-to-counter-0` + +| Spec | Requirement | +|------|-------------| +| RTPO18b | Resolves path, on failure throws RTPO3c2 | +| RTPO18c | LiveCounter -> delegates to LiveCounter#decrement | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").decrement(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 90 +``` + +--- + +## RTPO18 - decrement() defaults to 1 + +**Test ID**: `objects/unit/RTPO18/decrement-default-amount-0` + +**Spec requirement:** amount defaults to 1. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").decrement() +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 99 +``` + +--- + +## RTPO18d - decrement() on non-LiveCounter throws 92007 + +**Test ID**: `objects/unit/RTPO18d/decrement-non-counter-throws-0` + +**Spec requirement:** If resolved value is not a LiveCounter, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.decrement(5) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTPO3c2 - set() on unresolvable path throws 92005 + +**Test ID**: `objects/unit/RTPO3c2/set-unresolvable-throws-0` + +**Spec requirement:** For write operations, if path resolution fails, throw 92005. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("nonexistent").get("deep").set("key", "value") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92005 +``` + +--- + +## RTPO3c2 - increment() on unresolvable path throws 92005 + +**Test ID**: `objects/unit/RTPO3c2/increment-unresolvable-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("nonexistent").increment(5) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92005 +``` diff --git a/uts/objects/unit/path_object_subscribe.md b/uts/objects/unit/path_object_subscribe.md new file mode 100644 index 000000000..503ac43f2 --- /dev/null +++ b/uts/objects/unit/path_object_subscribe.md @@ -0,0 +1,618 @@ +# PathObject Subscribe Tests + +Spec points: `RTPO19`–`RTPO21`, `RTO24` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTPO19 - subscribe() returns Subscription and receives events + +**Test ID**: `objects/unit/RTPO19/subscribe-receives-events-0` + +| Spec | Requirement | +|------|-------------| +| RTPO19c | Returns Subscription object | +| RTPO19d1 | Event.object is a PathObject pointing to change path | +| RTPO19d2 | Event.message is the ObjectMessage | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +sub = root.get("score").subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT sub IS Subscription +ASSERT events.length == 1 +ASSERT events[0].object IS PathObject +ASSERT events[0].object.path() == "score" +ASSERT events[0].message IS NOT null +``` + +--- + +## RTPO19b1b - subscribe() with depth 1 only receives self events + +**Test ID**: `objects/unit/RTPO19b1b/subscribe-depth-1-self-only-0` + +**Spec requirement:** depth=1 means only changes at the exact subscribed path trigger the listener. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event), { depth: 1 }) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "100", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 1 +``` + +--- + +## RTPO19b1c - subscribe() with depth 2 receives self and children + +**Test ID**: `objects/unit/RTPO19b1c/subscribe-depth-2-children-0` + +**Spec requirement:** depth=n means changes up to n-1 levels of children trigger the listener. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event), { depth: 2 }) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "101", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 2 +``` + +--- + +## RTPO19b1a - subscribe() with no depth receives all descendants + +**Test ID**: `objects/unit/RTPO19b1a/subscribe-unlimited-depth-0` + +**Spec requirement:** If depth is undefined, subscription receives events at any depth. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:prefs@1000", "theme", { string: "light" }, "101", "remote") +])) +poll_until(events.length >= 3, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length >= 3 +``` + +--- + +## RTPO19b1d - subscribe() with non-positive depth throws 40003 + +**Test ID**: `objects/unit/RTPO19b1d/subscribe-non-positive-depth-throws-0` + +**Spec requirement:** If depth is provided and is not a positive integer, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root.subscribe((event) => {}, { depth: 0 }) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTPO19b1d - subscribe() with negative depth throws 40003 + +**Test ID**: `objects/unit/RTPO19b1d/subscribe-negative-depth-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root.subscribe((event) => {}, { depth: -1 }) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTPO19e - subscribe() follows path not identity + +**Test ID**: `objects/unit/RTPO19e/subscribe-follows-path-0` + +**Spec requirement:** If the object at the path changes identity, the subscription continues to deliver events for the new object. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.get("score").subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +// Replace the counter at "score" with a new counter +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") +])) + +// Increment the NEW counter at "score" +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:new@2000", 10, "100", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +// Should receive event for the new counter, since subscription follows path +found_new = false +FOR event IN events: + IF event.object.path() == "score": + found_new = true +ASSERT found_new == true +``` + +--- + +## RTPO19f - child events bubble up to parent subscription + +**Test ID**: `objects/unit/RTPO19f/child-events-bubble-0` + +**Spec requirement:** Events at child paths bubble up subject to depth filtering. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.get("profile").subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:nested@1000", 3, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length >= 2 +``` + +--- + +## RTO24b3 - depth filtering formula + +**Test ID**: `objects/unit/RTO24b3/depth-filtering-formula-0` + +**Spec requirement:** Event dispatched if `eventPath.length - subscriptionPath.length + 1 <= depth`. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +// Subscribe at "profile" with depth 2: +// self (profile) → segmentDiff=0, 0+1=1 ≤ 2 ✓ +// child (profile.email) → segmentDiff=1, 1+1=2 ≤ 2 ✓ +// grandchild (profile.prefs.theme) → segmentDiff=2, 2+1=3 > 2 ✗ +root.get("profile").subscribe((event) => events.append(event), { depth: 2 }) +``` + +### Test Steps +```pseudo +// Self event (profile map update) +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) + +// Child event (nested counter) +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:nested@1000", 3, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) + +// Grandchild event (prefs.theme) — should NOT be received +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:prefs@1000", "theme", { string: "light" }, "101", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 2 +``` + +--- + +## RTO24b5 - listener exception does not affect other listeners + +**Test ID**: `objects/unit/RTO24b5/listener-exception-caught-0` + +**Spec requirement:** If a listener throws, the error is caught and logged without affecting other subscriptions. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => { THROW Error("boom") }) +root.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length == 1 +``` + +--- + +## RTPO20 - unsubscribe() deregisters listener + +**Test ID**: `objects/unit/RTPO20/unsubscribe-deregisters-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +sub = root.get("score").subscribe((event) => events.append(event)) +sub.unsubscribe() +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 0 +``` + +--- + +## RTPO19g - subscribe() has no side effects + +**Test ID**: `objects/unit/RTPO19g/subscribe-no-side-effects-0` + +**Spec requirement:** Must not have side effects on RealtimeObject, channel, or their status. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +state_before = channel.state +``` + +### Test Steps +```pseudo +root.get("score").subscribe((event) => {}) +``` + +### Assertions +```pseudo +ASSERT channel.state == state_before +``` + +--- + +## RTPO19 - MAP_CLEAR triggers subscription events on child paths + +**Test ID**: `objects/unit/RTPO19/map-clear-triggers-child-events-0` + +**Spec requirement:** When MAP_CLEAR is applied, subscriptions on affected child paths receive events. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_clear("root", "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length >= 1 +``` + +--- + +## RTPO19 - subscribe() on primitive path receives change events + +**Test ID**: `objects/unit/RTPO19/subscribe-primitive-path-0` + +**Spec requirement:** A subscription on a path pointing to a primitive (e.g., root.get("name")) fires when the map entry at that key changes. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.get("name").subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length == 1 +ASSERT events[0].object.path() == "name" +``` + +--- + +## RTPO19d - subscribe() event provides correct PathObject + +**Test ID**: `objects/unit/RTPO19d/event-path-object-correct-0` + +**Spec requirement:** RTPO19d1: event.object is a PathObject pointing to the change location. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events[0].object IS PathObject +ASSERT events[0].object.path() == "score" +ASSERT events[0].object.value() == 107 +``` + +--- + +## RTPO21 - subscribeIterator() yields events + +**Test ID**: `objects/unit/RTPO21/subscribe-iterator-yields-0` + +| Spec | Requirement | +|------|-------------| +| RTPO21b | Returns async iterable of PathObjectSubscriptionEvent | +| RTPO21d | Each iteration yields next event | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +iter = root.get("score").subscribeIterator() +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) + +event = AWAIT iter.next() +``` + +### Assertions +```pseudo +ASSERT event.object IS PathObject +ASSERT event.object.path() == "score" +``` + +--- + +## RTPO21 - subscribeIterator() with depth option + +**Test ID**: `objects/unit/RTPO21/subscribe-iterator-depth-0` + +**Spec requirement:** subscribeIterator accepts same options as subscribe, including depth. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +iter = root.subscribeIterator({ depth: 1 }) +``` + +### Test Steps +```pseudo +// Self event (depth 1 allows) +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +event = AWAIT iter.next() + +// Child event (depth 1 rejects — counter at depth 2) +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "100", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT event.object.path() == "" +``` + +--- + +## RTPO21 - subscribeIterator() break cleanup + +**Test ID**: `objects/unit/RTPO21/subscribe-iterator-break-cleanup-0` + +**Spec requirement:** Breaking out of the iterator loop cleans up the underlying subscription. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +received = [] +``` + +### Test Steps +```pseudo +iter = root.get("score").subscribeIterator() + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 1, "99", "remote") +])) + +event = AWAIT iter.next() +received.append(event) + +// Break the iterator (cleanup) +iter.return() + +// Further events should not be received +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 1, "100", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT received.length == 1 +``` + +--- + +## RTPO21 - subscribeIterator() multiple concurrent iterators + +**Test ID**: `objects/unit/RTPO21/subscribe-iterator-concurrent-0` + +**Spec requirement:** Multiple iterators can coexist independently. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +iter1 = root.get("score").subscribeIterator() +iter2 = root.get("score").subscribeIterator() +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "99", "remote") +])) + +event1 = AWAIT iter1.next() +event2 = AWAIT iter2.next() +``` + +### Assertions +```pseudo +ASSERT event1.object.path() == "score" +ASSERT event2.object.path() == "score" +``` diff --git a/uts/objects/unit/realtime_object.md b/uts/objects/unit/realtime_object.md new file mode 100644 index 000000000..fd833be65 --- /dev/null +++ b/uts/objects/unit/realtime_object.md @@ -0,0 +1,927 @@ +# RealtimeObject Tests + +Spec points: `RTO2`, `RTO10`, `RTO15`, `RTO17`–`RTO20`, `RTO22`–`RTO24` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel`, `setup_synced_channel_no_ack`, and builder functions. + +--- + +## RTO23 - get() returns PathObject wrapping root + +**Test ID**: `objects/unit/RTO23/get-returns-path-object-0` + +| Spec | Requirement | +|------|-------------| +| RTO23d | Returns PathObject wrapping root LiveMap with empty path | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +ASSERT root.path() == "" +``` + +--- + +## RTO23a - get() requires OBJECT_SUBSCRIBE mode + +**Test ID**: `objects/unit/RTO23a/get-requires-subscribe-mode-0` + +**Spec requirement:** Requires OBJECT_SUBSCRIBE channel mode per RTO2. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site" + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +AWAIT channel.object.get() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +``` + +--- + +## RTO23b - get() throws on DETACHED or FAILED channel + +**Test ID**: `objects/unit/RTO23b/get-throws-detached-0` + +**Spec requirement:** If channel is DETACHED or FAILED, throw 90001. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site" + }) + ) +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE"] }) +``` + +### Test Steps +```pseudo +AWAIT channel.object.get() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 90001 +``` + +--- + +## RTO23c - get() waits for SYNCED state + +**Test ID**: `objects/unit/RTO23c/get-waits-for-synced-0` + +**Spec requirement:** If sync state is not SYNCED, waits for SYNCED transition. + +### Setup +```pseudo +attach_sent = false +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_sent = true + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:cursor", + flags: HAS_OBJECTS + )) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +get_future = channel.object.get() + +poll_until(attach_sent, timeout: 5s) + +mock_ws.send_to_client(build_object_sync_message( + "test", "sync1:", STANDARD_POOL_OBJECTS +)) + +root = AWAIT get_future +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +``` + +--- + +## RTO15 - publish sends OBJECT ProtocolMessage + +**Test ID**: `objects/unit/RTO15/publish-sends-object-pm-0` + +| Spec | Requirement | +|------|-------------| +| RTO15e1 | action set to OBJECT | +| RTO15e2 | channel set to channel name | +| RTO15e3 | state set to encoded ObjectMessages | +| RTO15h | Returns PublishResult from ACK | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, ["serial-0"])) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +result = AWAIT channel.object.publish([ + build_counter_inc("counter:score@1000", 5, null, null) +]) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +ASSERT captured_messages[0].action == OBJECT +ASSERT captured_messages[0].channel == "test" +ASSERT captured_messages[0].state.length == 1 +ASSERT result.serials == ["serial-0"] +``` + +--- + +## RTO20 - publishAndApply applies locally on ACK + +**Test ID**: `objects/unit/RTO20/publish-and-apply-local-0` + +| Spec | Requirement | +|------|-------------| +| RTO20b | Calls publish and awaits PublishResult | +| RTO20d2a | Synthetic message serial from PublishResult | +| RTO20d2b | Synthetic message siteCode from ConnectionDetails | +| RTO20f | Apply synthetic messages with source LOCAL | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 110 +``` + +--- + +## RTO20c - publishAndApply logs error when siteCode missing + +**Test ID**: `objects/unit/RTO20c/missing-site-code-0` + +| Spec | Requirement | +|------|-------------| +| RTO20c1 | Requires siteCode from ConnectionDetails | + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + mock_ws.send_to_client(build_ack_message(msg.msgSerial, ["serial-0"])) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 100 +``` + +--- + +## RTO20d1 - null serial in PublishResult is skipped + +**Test ID**: `objects/unit/RTO20d1/null-serial-skipped-0` + +**Spec requirement:** If serial from PublishResult is null, skip that ObjectMessage. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + mock_ws.send_to_client(build_ack_message(msg.msgSerial, [null])) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 100 +``` + +--- + +## RTO20e - publishAndApply waits for SYNCED during SYNCING + +**Test ID**: `objects/unit/RTO20e/waits-for-synced-0` + +**Spec requirement:** If sync state is not SYNCED, wait for SYNCED transition. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", + flags: HAS_OBJECTS +)) + +inc_future = root.increment(10) + +mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) + +AWAIT inc_future +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 110 +``` + +--- + +## RTO20e1 - publishAndApply fails when channel enters FAILED during sync wait + +**Test ID**: `objects/unit/RTO20e1/fails-on-channel-failed-0` + +**Spec requirement:** If channel enters DETACHED/SUSPENDED/FAILED while waiting, fail with 92008. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", + flags: HAS_OBJECTS +)) + +inc_future = root.increment(10) + +mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, channel: "test", + error: { code: 90000, statusCode: 400, message: "Channel detached" } +)) + +AWAIT inc_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92008 +``` + +--- + +## RTO17, RTO18 - Sync state events + +**Test ID**: `objects/unit/RTO17/sync-state-events-0` + +| Spec | Requirement | +|------|-------------| +| RTO17b | Emit event matching new sync state | +| RTO18b1 | SYNCING event | +| RTO18b2 | SYNCED event | +| RTO18e | Listeners called with no arguments | + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:cursor", + flags: HAS_OBJECTS + )) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +events = [] +channel.object.on(SYNCING, () => events.append("SYNCING")) +channel.object.on(SYNCED, () => events.append("SYNCED")) +``` + +### Test Steps +```pseudo +get_future = channel.object.get() + +poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_sync_message("test", "sync1:", STANDARD_POOL_OBJECTS)) + +AWAIT get_future +``` + +### Assertions +```pseudo +ASSERT events CONTAINS_IN_ORDER ["SYNCING", "SYNCED"] +``` + +--- + +## RTO18d - Duplicate listener registered twice fires twice + +**Test ID**: `objects/unit/RTO18d/duplicate-listener-0` + +**Spec requirement:** If same listener registered twice, it is invoked twice per event. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +call_count = 0 +listener = () => { call_count++ } +channel.object.on(SYNCED, listener) +channel.object.on(SYNCED, listener) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) + +poll_until(call_count >= 2, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT call_count == 2 +``` + +--- + +## RTO19 - off() deregisters listener + +**Test ID**: `objects/unit/RTO19/off-deregisters-0` + +**Spec requirement:** Deregisters event listener previously registered via on(). + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +call_count = 0 +listener = () => { call_count++ } +sub = channel.object.on(SYNCED, listener) +sub.off() +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) +``` + +### Assertions +```pseudo +ASSERT call_count == 0 +``` + +--- + +## RTO2 - Channel mode enforcement + +**Test ID**: `objects/unit/RTO2/mode-enforcement-0` + +| Spec | Requirement | +|------|-------------| +| RTO2a | ATTACHED state checks granted modes | +| RTO2b | Non-ATTACHED checks requested modes | +| RTO2a2 | Missing mode throws 40024 | + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS, + modes: ["OBJECT_SUBSCRIBE"] + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +``` + +--- + +## RTO10 - GC removes tombstoned objects past grace period + +**Test ID**: `objects/unit/RTO10/gc-tombstoned-objects-0` + +| Spec | Requirement | +|------|-------------| +| RTO10a | Check at regular intervals | +| RTO10c1b | Remove if difference >= grace period | +| RTO10b1 | Grace period from ConnectionDetails | + +### Setup +```pseudo +enable_fake_timers() +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +mock_ws.send_to_client(build_object_message("test", [ + build_object_delete("counter:score@1000", "99", "site1", 1000) +])) +``` + +### Test Steps +```pseudo +ADVANCE_TIME(86400000 + 300000) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == null +``` + +--- + +## RTO20 - Echo deduplication via appliedOnAckSerials + +**Test ID**: `objects/unit/RTO20/echo-dedup-0` + +**Spec requirement:** When echo arrives with same serial as applied-on-ACK, it is deduplicated. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +score_after_apply = root.get("score").value() + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "ack-0:0", "test-site") +])) +score_after_echo = root.get("score").value() +``` + +### Assertions +```pseudo +ASSERT score_after_apply == 110 +ASSERT score_after_echo == 110 +``` + +--- + +## RTO20f - Apply-on-ACK does not update siteTimeserials + +**Test ID**: `objects/unit/RTO20f/ack-no-site-timeserials-update-0` + +| Spec | Requirement | +|------|-------------| +| RTO20f | Apply with source LOCAL | +| RTLC7c2 | LOCAL source does not update siteTimeserials | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +site_serials_before = root.get("score").instance()._liveObject.siteTimeserials +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +site_serials_after = root.get("score").instance()._liveObject.siteTimeserials +``` + +### Assertions +```pseudo +ASSERT site_serials_after == site_serials_before +``` + +--- + +## RTO20 - ACK after echo does not double-apply + +**Test ID**: `objects/unit/RTO20/ack-after-echo-no-double-apply-0` + +**Spec requirement:** If the echo arrives before the ACK is processed, the ACK-based apply finds the serial already applied and deduplicates via RTO9a3. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel_no_ack("test") +``` + +### Test Steps +```pseudo +inc_future = root.increment(10) + +// Send the echo BEFORE the ACK +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "ack-0:0", "test-site") +])) + +// Now send the ACK +mock_ws.send_to_client(build_ack_message(0, ["ack-0:0"])) + +AWAIT inc_future +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 110 +``` + +--- + +## RTO5c9, RTO20 - appliedOnAckSerials cleared on re-sync + +**Test ID**: `objects/unit/RTO5c9-RTO20/ack-serials-cleared-on-resync-0` + +**Spec requirement:** appliedOnAckSerials is cleared when sync completes. After re-sync, an echo with a previously-applied serial is applied normally (not deduplicated). + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +ASSERT root.get("score").value() == 110 + +// Trigger re-sync +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) + +// After re-sync, the score is back to 100 (from pool state) +ASSERT root.get("score").value() == 100 +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 100 +``` + +--- + +## RTO20 - Subscription fires on apply-on-ACK + +**Test ID**: `objects/unit/RTO20/subscription-fires-on-ack-apply-0` + +**Spec requirement:** When publishAndApply applies locally via ACK, subscription listeners are notified. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.get("score").subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +``` + +### Assertions +```pseudo +ASSERT events.length >= 1 +ASSERT root.get("score").value() == 110 +``` + +--- + +## RTO23 - get() implicitly attaches channel + +**Test ID**: `objects/unit/RTO23/get-implicit-attach-0` + +**Spec requirement:** get() triggers attach if channel is not yet attached. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +ASSERT channel.state == INITIALIZED +root = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +ASSERT channel.state == ATTACHED +``` + +--- + +## RTO23d - get() resolves immediately when already SYNCED + +**Test ID**: `objects/unit/RTO23d/get-resolves-immediately-synced-0` + +**Spec requirement:** If sync state is already SYNCED, get() resolves immediately. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root2 = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root2 IS PathObject +ASSERT root2.path() == "" +``` + +--- + +## RTO10b1 - GC grace period from ConnectionDetails + +**Test ID**: `objects/unit/RTO10b1/gc-grace-period-source-0` + +**Spec requirement:** GC grace period comes from ConnectionDetails.objectsGCGracePeriod. + +### Setup +```pseudo +enable_fake_timers() +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 5000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() + +mock_ws.send_to_client(build_object_message("test", [ + build_object_delete("counter:score@1000", "99", "site1", 1000) +])) +``` + +### Test Steps +```pseudo +// Short grace period (5000ms) — advance past it +ADVANCE_TIME(5000 + 1000) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == null +``` + +--- + +## RTO17, RTO18 - Sync event sequences for all state transitions + +**Test ID**: `objects/unit/RTO17-RTO18/sync-event-sequences-0` + +**Spec requirement:** Verify all sync state transition sequences. + +### Setup +```pseudo +scenarios = [ + { + name: "initial attach", + trigger: () => { + channel.attach() + }, + expected_events: ["SYNCING", "SYNCED"] + }, + { + name: "re-attach after detach", + trigger: () => { + mock_ws.send_to_client(ProtocolMessage(action: DETACHED, channel: "test")) + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) + }, + expected_events: ["SYNCING", "SYNCED"] + }, + { + name: "re-sync on new ATTACHED", + trigger: () => { + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync3:cursor", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync3:", STANDARD_POOL_OBJECTS)) + }, + expected_events: ["SYNCING", "SYNCED"] + }, + { + name: "ATTACHED without HAS_OBJECTS", + trigger: () => { + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync4:", flags: 0 + )) + }, + expected_events: ["SYNCED"] + } +] + +FOR scenario IN scenarios: + { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + events = [] + channel.object.on(SYNCING, () => events.append("SYNCING")) + channel.object.on(SYNCED, () => events.append("SYNCED")) + + scenario.trigger() + poll_until(events.length >= scenario.expected_events.length, timeout: 5s) + + ASSERT events == scenario.expected_events +``` diff --git a/uts/objects/unit/value_types.md b/uts/objects/unit/value_types.md new file mode 100644 index 000000000..dc99aec26 --- /dev/null +++ b/uts/objects/unit/value_types.md @@ -0,0 +1,451 @@ +# Value Types Tests + +Spec points: `RTLCV1`–`RTLCV4`, `RTLMV1`–`RTLMV4` + +## Test Type +Unit test — pure construction and consumption, no mocks required. + +## Purpose + +Tests `LiveCounterValueType` and `LiveMapValueType` — immutable blueprints created via `LiveCounter.create()` and `LiveMap.create()` static factories. When consumed by a mutation method, they generate `ObjectMessages` with v6 wire format fields (`counterCreateWithObjectId`, `mapCreateWithObjectId`). + +--- + +## RTLCV3 - LiveCounter.create with initial count + +**Test ID**: `objects/unit/RTLCV3/create-with-count-0` + +| Spec | Requirement | +|------|-------------| +| RTLCV3a1 | Accepts optional initialCount | +| RTLCV3b | Returns LiveCounterValueType with internal count | +| RTLCV3d | Returned value is immutable | + +### Test Steps +```pseudo +vt = LiveCounter.create(42) +``` + +### Assertions +```pseudo +ASSERT vt IS LiveCounterValueType +ASSERT vt.count == 42 +``` + +--- + +## RTLCV3 - LiveCounter.create defaults to 0 + +**Test ID**: `objects/unit/RTLCV3/create-default-zero-0` + +**Spec requirement:** If initialCount omitted, defaults to 0. + +### Test Steps +```pseudo +vt = LiveCounter.create() +``` + +### Assertions +```pseudo +ASSERT vt.count == 0 +``` + +--- + +## RTLCV3c - No validation at creation time + +**Test ID**: `objects/unit/RTLCV3c/no-validation-at-create-0` + +**Spec requirement:** No input validation is performed at creation time; deferred to consumption. + +### Test Steps +```pseudo +vt = LiveCounter.create("not_a_number") +``` + +### Assertions +```pseudo +ASSERT vt IS LiveCounterValueType +``` + +--- + +## RTLCV4 - Consumption generates COUNTER_CREATE ObjectMessage + +**Test ID**: `objects/unit/RTLCV4/consume-generates-message-0` + +| Spec | Requirement | +|------|-------------| +| RTLCV4b1 | CounterCreate.count set to internal count | +| RTLCV4c | Initial value JSON string from CounterCreate | +| RTLCV4d | Unique nonce with 16+ characters | +| RTLCV4f | objectId generated via RTO14 with type "counter" | +| RTLCV4g1 | action set to COUNTER_CREATE | +| RTLCV4g2 | objectId set | +| RTLCV4g3 | counterCreateWithObjectId.nonce set | +| RTLCV4g4 | counterCreateWithObjectId.initialValue set | + +### Test Steps +```pseudo +vt = LiveCounter.create(42) +messages = consume(vt) +``` + +### Assertions +```pseudo +ASSERT messages.length == 1 +msg = messages[0] +ASSERT msg.operation.action == "COUNTER_CREATE" +ASSERT msg.operation.objectId STARTS WITH "counter:" +ASSERT msg.operation.objectId CONTAINS "@" +ASSERT msg.operation.counterCreateWithObjectId IS NOT null +ASSERT msg.operation.counterCreateWithObjectId.nonce IS NOT null +ASSERT msg.operation.counterCreateWithObjectId.nonce.length >= 16 +ASSERT msg.operation.counterCreateWithObjectId.initialValue IS NOT null +``` + +--- + +## RTLCV4g5 - Consumption retains local CounterCreate + +**Test ID**: `objects/unit/RTLCV4g5/retains-local-counter-create-0` + +**Spec requirement:** Client must retain CounterCreate alongside CounterCreateWithObjectId for local use. + +### Test Steps +```pseudo +vt = LiveCounter.create(42) +messages = consume(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +ASSERT msg.operation.counterCreate IS NOT null +ASSERT msg.operation.counterCreate.count == 42 +``` + +--- + +## RTLCV4a - Consumption validates count type + +**Test ID**: `objects/unit/RTLCV4a/consume-validates-count-0` + +**Spec requirement:** If count is not undefined and (not a Number or not finite), throw 40003. + +### Test Steps +```pseudo +vt = LiveCounter.create("not_a_number") +consume(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLCV4 - Consumption with count 0 + +**Test ID**: `objects/unit/RTLCV4/consume-zero-count-0` + +**Spec requirement:** count=0 is valid and should be included in CounterCreate. + +### Test Steps +```pseudo +vt = LiveCounter.create(0) +messages = consume(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +ASSERT msg.operation.counterCreate.count == 0 +``` + +--- + +## RTLMV3 - LiveMap.create with entries + +**Test ID**: `objects/unit/RTLMV3/create-with-entries-0` + +| Spec | Requirement | +|------|-------------| +| RTLMV3a1 | Accepts optional entries dict | +| RTLMV3b | Returns LiveMapValueType with internal entries | +| RTLMV3d | Returned value is immutable | + +### Test Steps +```pseudo +vt = LiveMap.create({ + "name": "Alice", + "age": 30 +}) +``` + +### Assertions +```pseudo +ASSERT vt IS LiveMapValueType +ASSERT vt.entries["name"] == "Alice" +ASSERT vt.entries["age"] == 30 +``` + +--- + +## RTLMV3 - LiveMap.create with no entries + +**Test ID**: `objects/unit/RTLMV3/create-no-entries-0` + +**Spec requirement:** If entries omitted, internal entries is undefined. + +### Test Steps +```pseudo +vt = LiveMap.create() +``` + +### Assertions +```pseudo +ASSERT vt IS LiveMapValueType +``` + +--- + +## RTLMV4 - Consumption generates MAP_CREATE ObjectMessage + +**Test ID**: `objects/unit/RTLMV4/consume-generates-message-0` + +| Spec | Requirement | +|------|-------------| +| RTLMV4e1 | MapCreate.semantics set to LWW | +| RTLMV4f | Initial value JSON string | +| RTLMV4g | Unique nonce 16+ chars | +| RTLMV4i | objectId via RTO14 with type "map" | +| RTLMV4j1 | action set to MAP_CREATE | +| RTLMV4j3 | mapCreateWithObjectId.nonce set | +| RTLMV4j4 | mapCreateWithObjectId.initialValue set | + +### Test Steps +```pseudo +vt = LiveMap.create({ "name": "Alice" }) +messages = consume(vt) +``` + +### Assertions +```pseudo +ASSERT messages.length == 1 +msg = messages[0] +ASSERT msg.operation.action == "MAP_CREATE" +ASSERT msg.operation.objectId STARTS WITH "map:" +ASSERT msg.operation.mapCreateWithObjectId IS NOT null +ASSERT msg.operation.mapCreateWithObjectId.nonce.length >= 16 +ASSERT msg.operation.mapCreateWithObjectId.initialValue IS NOT null +``` + +--- + +## RTLMV4j5 - Consumption retains local MapCreate + +**Test ID**: `objects/unit/RTLMV4j5/retains-local-map-create-0` + +**Spec requirement:** Client must retain MapCreate alongside MapCreateWithObjectId for local use. + +### Test Steps +```pseudo +vt = LiveMap.create({ "name": "Alice" }) +messages = consume(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +ASSERT msg.operation.mapCreate IS NOT null +ASSERT msg.operation.mapCreate.semantics == "LWW" +ASSERT msg.operation.mapCreate.entries["name"].data.string == "Alice" +``` + +--- + +## RTLMV4d - Entry value type mapping + +**Test ID**: `objects/unit/RTLMV4d/entry-value-types-0` + +| Spec | Requirement | +|------|-------------| +| RTLMV4d3 | JsonArray/JsonObject -> data.json | +| RTLMV4d4 | String -> data.string | +| RTLMV4d5 | Number -> data.number | +| RTLMV4d6 | Boolean -> data.boolean | +| RTLMV4d7 | Binary -> data.bytes | + +### Test Steps +```pseudo +vt = LiveMap.create({ + "str": "hello", + "num": 42, + "bool": true, + "json_arr": [1, 2, 3], + "json_obj": { "key": "value" } +}) +messages = consume(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +entries = msg.operation.mapCreate.entries +ASSERT entries["str"].data.string == "hello" +ASSERT entries["num"].data.number == 42 +ASSERT entries["bool"].data.boolean == true +ASSERT entries["json_arr"].data.json == [1, 2, 3] +ASSERT entries["json_obj"].data.json == { "key": "value" } +``` + +--- + +## RTLMV4d1, RTLMV4d2 - Nested value types produce depth-first ObjectMessages + +**Test ID**: `objects/unit/RTLMV4d1/nested-value-types-0` + +| Spec | Requirement | +|------|-------------| +| RTLMV4d1 | LiveCounterValueType consumed, ObjectMessage collected, objectId set | +| RTLMV4d2 | LiveMapValueType recursively consumed, all ObjectMessages collected | +| RTLMV4k | Return depth-first order: inner creates before outer | + +### Test Steps +```pseudo +inner_counter = LiveCounter.create(10) +inner_map = LiveMap.create({ + "nested_count": inner_counter +}) +outer = LiveMap.create({ + "child": inner_map +}) +messages = consume(outer) +``` + +### Assertions +```pseudo +ASSERT messages.length == 3 +ASSERT messages[0].operation.action == "COUNTER_CREATE" +ASSERT messages[0].operation.objectId STARTS WITH "counter:" +ASSERT messages[1].operation.action == "MAP_CREATE" +ASSERT messages[1].operation.objectId STARTS WITH "map:" +ASSERT messages[2].operation.action == "MAP_CREATE" +ASSERT messages[2].operation.objectId STARTS WITH "map:" + +inner_counter_id = messages[0].operation.objectId +inner_map_id = messages[1].operation.objectId +outer_map_id = messages[2].operation.objectId + +ASSERT messages[1].operation.mapCreate.entries["nested_count"].data.objectId == inner_counter_id +ASSERT messages[2].operation.mapCreate.entries["child"].data.objectId == inner_map_id +``` + +--- + +## RTLMV4a - Consumption validates entries type + +**Test ID**: `objects/unit/RTLMV4a/consume-validates-entries-0` + +**Spec requirement:** If entries is not undefined and (is null or not Dict), throw 40003. + +### Test Steps +```pseudo +vt = LiveMap.create(null) +consume(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLMV4b - Consumption validates key types + +**Test ID**: `objects/unit/RTLMV4b/consume-validates-keys-0` + +**Spec requirement:** If any key is not String, throw 40003. + +### Test Steps +```pseudo +vt = LiveMap.create({ 123: "value" }) +consume(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLMV4c - Consumption validates value types + +**Test ID**: `objects/unit/RTLMV4c/consume-validates-values-0` + +**Spec requirement:** If any value is not an expected type, throw 40013. + +### Test Steps +```pseudo +vt = LiveMap.create({ "fn": some_function }) +consume(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40013 +``` + +--- + +## RTLMV4e2 - Empty entries produces MapCreate with empty entries + +**Test ID**: `objects/unit/RTLMV4e2/empty-entries-0` + +**Spec requirement:** If internal entries is undefined, MapCreate.entries is empty map. + +### Test Steps +```pseudo +vt = LiveMap.create() +messages = consume(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +ASSERT msg.operation.mapCreate.entries == {} +``` + +--- + +## RTLMV4d - Table-driven MAP_SET value type mapping + +**Test ID**: `objects/unit/RTLMV4d/map-set-all-types-table-0` + +**Spec requirement:** Every supported value type maps to the correct data field. + +### Test Steps +```pseudo +type_scenarios = [ + { input: "hello", expected_field: "string", expected_value: "hello" }, + { input: 42, expected_field: "number", expected_value: 42 }, + { input: 3.14, expected_field: "number", expected_value: 3.14 }, + { input: 0, expected_field: "number", expected_value: 0 }, + { input: -1, expected_field: "number", expected_value: -1 }, + { input: true, expected_field: "boolean", expected_value: true }, + { input: false, expected_field: "boolean", expected_value: false }, + { input: [1, "a", null], expected_field: "json", expected_value: [1, "a", null] }, + { input: { "k": "v" }, expected_field: "json", expected_value: { "k": "v" } }, + { input: bytes([1, 2, 3]), expected_field: "bytes", expected_value: "AQID" } +] + +FOR scenario IN type_scenarios: + vt = LiveMap.create({ "test_key": scenario.input }) + messages = consume(vt) + entry = messages[0].operation.mapCreate.entries["test_key"] + ASSERT entry.data[scenario.expected_field] == scenario.expected_value +``` diff --git a/uts/realtime/integration/auth.md b/uts/realtime/integration/auth.md new file mode 100644 index 000000000..b6e6ce509 --- /dev/null +++ b/uts/realtime/integration/auth.md @@ -0,0 +1,265 @@ +# Realtime Auth Integration Tests + +Spec points: `RTC8`, `RSA8`, `RSA7` + +## Test Type +Integration test against Ably sandbox + +## Token Formats + +Tests use JWTs generated using a third-party JWT library, signed with the app key secret using HMAC-SHA256. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RTC8a - In-band reauthorization on CONNECTED client + +**Test ID**: `realtime/integration/RTC8a/in-band-reauth-connected-0` + +**Spec requirement:** RTC8a - When `auth.authorize()` is called on a CONNECTED realtime client, it sends an AUTH protocol message with the new token rather than disconnecting/reconnecting. + +Tests that calling authorize() on a connected client succeeds and the connection remains connected (UPDATE event, not disconnect/reconnect). + +### Setup +```pseudo +auth_callback = FUNCTION(params): + RETURN generate_jwt( + key_name: extract_key_name(api_key), + key_secret: extract_key_secret(api_key), + ttl: 3600000 + ) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + endpoint: "nonprod:sandbox", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +# Connect and wait for CONNECTED +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +# Record connection ID before reauth +connection_id_before = client.connection.id + +# Collect state changes during reauth +state_changes = [] +subscription = client.connection.on(LISTEN state_changes.append) + +# Call authorize — should send AUTH and get UPDATE, not disconnect +token = AWAIT client.auth.authorize() + +# Check state after reauth +connection_id_after = client.connection.id +``` + +### Assertions +```pseudo +# authorize() returned a valid token +ASSERT token IS NOT NULL +ASSERT token.token IS String + +# Connection remained connected — same connection ID +ASSERT connection_id_after == connection_id_before + +# No state transitions occurred (UPDATE has current == previous == connected, +# so filtering for actual transitions should yield nothing) +state_transitions = state_changes.filter(c => c.current != c.previous) +ASSERT state_transitions IS EMPTY + +AWAIT client.close() +``` + +--- + +## RTC8c - authorize() from INITIALIZED initiates connection + +**Test ID**: `realtime/integration/RTC8c/authorize-initiates-connection-0` + +**Spec requirement:** RTC8c - When `auth.authorize()` is called on a client in INITIALIZED state, it should initiate the connection. + +Tests that calling authorize() on an unconnected client triggers a connection. + +### Setup +```pseudo +auth_callback = FUNCTION(params): + RETURN generate_jwt( + key_name: extract_key_name(api_key), + key_secret: extract_key_secret(api_key), + ttl: 3600000 + ) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + endpoint: "nonprod:sandbox", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +# Client starts in INITIALIZED, no connection +ASSERT client.connection.state == INITIALIZED + +# authorize() should trigger connection +token = AWAIT client.auth.authorize() + +# Wait for connection to be established +AWAIT_STATE client.connection.state == CONNECTED +``` + +### Assertions +```pseudo +ASSERT token IS NOT NULL +ASSERT client.connection.state == CONNECTED +ASSERT client.connection.id IS NOT NULL + +AWAIT client.close() +``` + +--- + +## RSA8 - Token auth on realtime connection + +**Test ID**: `realtime/integration/RSA8/token-auth-connect-0` + +**Spec requirement:** RSA8 - Realtime client can connect using token authentication via an authCallback that returns JWTs. + +Tests that a realtime client can connect using JWT-based token auth. + +### Setup +```pseudo +auth_callback = FUNCTION(params): + RETURN generate_jwt( + key_name: extract_key_name(api_key), + key_secret: extract_key_secret(api_key), + ttl: 3600000 + ) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + endpoint: "nonprod:sandbox", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == CONNECTED +``` + +### Assertions +```pseudo +ASSERT client.connection.state == CONNECTED +ASSERT client.connection.id IS NOT NULL +ASSERT client.connection.errorReason IS NULL + +AWAIT client.close() +``` + +--- + +## RSA7 - clientId validation on realtime connection + +**Spec requirement:** RSA7 - The server validates clientId consistency between token claims and connection parameters. + +Tests that: +1. A JWT with a clientId allows connection with matching clientId +2. A JWT with a clientId rejects connection with mismatched clientId + +### Test 1: Matching clientId succeeds + +**Test ID**: `realtime/integration/RSA7/matching-clientid-succeeds-0` + +#### Setup +```pseudo +test_client_id = "test-client-" + random_id() + +auth_callback = FUNCTION(params): + RETURN generate_jwt( + key_name: extract_key_name(api_key), + key_secret: extract_key_secret(api_key), + client_id: test_client_id, + ttl: 3600000 + ) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + clientId: test_client_id, + endpoint: "nonprod:sandbox", + autoConnect: false +)) +``` + +#### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == CONNECTED +``` + +#### Assertions +```pseudo +ASSERT client.connection.state == CONNECTED +ASSERT client.auth.clientId == test_client_id + +AWAIT client.close() +``` + +### Test 2: Mismatched clientId fails + +**Test ID**: `realtime/integration/RSA7/mismatched-clientid-fails-1` + +#### Setup +```pseudo +auth_callback = FUNCTION(params): + RETURN generate_jwt( + key_name: extract_key_name(api_key), + key_secret: extract_key_secret(api_key), + client_id: "token-client-id", + ttl: 3600000 + ) +``` + +#### Test Steps +```pseudo +# ClientOptions constructor should reject mismatched clientId +# The clientId in options ("wrong-client-id") doesn't match the token's clientId +# This is validated client-side per RSA7 +EXPECT THROW creating Realtime(options: ClientOptions( + authCallback: auth_callback, + clientId: "wrong-client-id", + endpoint: "nonprod:sandbox", + autoConnect: false +)) +``` + +#### Assertions +```pseudo +# Note: The mismatch is detected client-side when the token is obtained. +# The exact behavior depends on implementation: it may throw during +# authorize() or during token validation. The key assertion is that +# the connection enters FAILED state with error code 40102. +``` diff --git a/uts/realtime/integration/auth/token_renewal_test.md b/uts/realtime/integration/auth/token_renewal_test.md new file mode 100644 index 000000000..34e332085 --- /dev/null +++ b/uts/realtime/integration/auth/token_renewal_test.md @@ -0,0 +1,113 @@ +# Realtime Token Renewal Integration Tests + +Spec points: `RSA4b`, `RTN14b` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +End-to-end verification that the realtime client handles token expiry correctly: +when the server rejects a connection or in-flight request due to an expired token, +the client automatically renews via the authCallback and recovers. + +## Token Formats + +Tests use JWTs generated using a third-party JWT library, signed with the app key +secret using HMAC-SHA256. + +## Sandbox Setup + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RSA4b, RTN14b - Token renewal on expiry + +**Test ID**: `realtime/integration/RSA4b/token-renewal-on-expiry-0` + +| Spec | Requirement | +|------|-------------| +| RSA4b | Client with renewable token automatically reissues on token error | +| RTN14b | Token error on connection triggers renewal and reconnection | + +**Spec requirement:** When a realtime client's token expires, the server sends +a DISCONNECTED message with a token error code (40140-40149). The client must +automatically invoke the authCallback to obtain a new token and reconnect. + +### Setup +```pseudo +key_name = extract_key_name(api_key) +key_secret = extract_key_secret(api_key) +callback_count = 0 + +auth_callback = FUNCTION(params): + callback_count++ + IF callback_count == 1: + # First token: very short TTL (5 seconds) + RETURN generate_jwt( + key_name: key_name, + key_secret: key_secret, + ttl: 5000 + ) + ELSE: + # Subsequent tokens: long TTL + RETURN generate_jwt( + key_name: key_name, + key_secret: key_secret, + ttl: 3600000 + ) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +# Record the initial connection ID +initial_connection_id = client.connection.id +ASSERT callback_count == 1 + +# Wait for token to expire and the client to recover. +# The server will send a DISCONNECTED with a token error once the token +# expires. The client should automatically renew and reconnect. +poll_until( + condition: FUNCTION() => callback_count >= 2, + interval: 1000ms, + timeout: 30s +) + +# Wait for reconnection +AWAIT_STATE client.connection.state == CONNECTED + WITH timeout: 15s +``` + +### Assertions +```pseudo +# authCallback was invoked at least twice (initial + renewal) +ASSERT callback_count >= 2 + +# Client is connected +ASSERT client.connection.state == CONNECTED + +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/integration/auth/token_request_test.md b/uts/realtime/integration/auth/token_request_test.md new file mode 100644 index 000000000..5e5f966bc --- /dev/null +++ b/uts/realtime/integration/auth/token_request_test.md @@ -0,0 +1,129 @@ +# Realtime Token Request Integration Tests + +Spec points: `RSA9`, `RSA9a`, `RSA9g` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +End-to-end verification that `Auth#createTokenRequest` produces a signed +`TokenRequest` that the Ably service accepts. This validates that the HMAC +signature computation (RSA9g) is compatible with the server. + +## Sandbox Setup + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RSA9a, RSA9g - createTokenRequest produces server-accepted token + +**Test ID**: `realtime/integration/RSA9a/token-request-server-accepted-0` + +| Spec | Requirement | +|------|-------------| +| RSA9a | Returns a signed TokenRequest that can be used to obtain a token | +| RSA9g | A valid HMAC is created using the key secret | + +**Spec requirement:** A TokenRequest created by `createTokenRequest` contains +a valid HMAC signature. When this TokenRequest is passed to another client's +`authCallback`, that client must be able to connect successfully, proving the +server accepted the TokenRequest. + +### Setup +```pseudo +# Client A creates TokenRequests using the API key +creator = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) + +# Client B connects using TokenRequests from client A +client = Realtime(options: ClientOptions( + authCallback: FUNCTION(params): + token_request = AWAIT creator.auth.createTokenRequest() + RETURN token_request, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + WITH timeout: 15s +``` + +### Assertions +```pseudo +ASSERT client.connection.state == CONNECTED +ASSERT client.connection.id IS NOT NULL +ASSERT client.connection.errorReason IS NULL + +CLOSE_CLIENT(client) +``` + +--- + +## RSA9 - createTokenRequest with clientId + +**Test ID**: `realtime/integration/RSA9/token-request-with-clientid-0` + +| Spec | Requirement | +|------|-------------| +| RSA9 | createTokenRequest accepts TokenParams including clientId | + +**Spec requirement:** A TokenRequest created with a specific clientId produces +a token that authenticates the client with that identity. + +### Setup +```pseudo +test_client_id = "token-request-client-" + random_id() + +creator = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) + +client = Realtime(options: ClientOptions( + authCallback: FUNCTION(params): + token_request = AWAIT creator.auth.createTokenRequest( + TokenParams(clientId: test_client_id) + ) + RETURN token_request, + clientId: test_client_id, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + WITH timeout: 15s +``` + +### Assertions +```pseudo +ASSERT client.connection.state == CONNECTED +ASSERT client.auth.clientId == test_client_id + +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/integration/channel_history_test.md b/uts/realtime/integration/channel_history_test.md new file mode 100644 index 000000000..8b9d76c14 --- /dev/null +++ b/uts/realtime/integration/channel_history_test.md @@ -0,0 +1,118 @@ +# RealtimeChannel History Integration Test + +Spec points: `RTL10d` + +## Test Type +Integration test against Ably Sandbox endpoint + +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RTL10d - History contains messages published by another client + +**Test ID**: `realtime/integration/RTL10d/history-cross-client-0` + +| Spec | Requirement | +|------|-------------| +| RTL10d | A test should exist that publishes messages from one client, and upon confirmation of message delivery, a history request should be made on another client to ensure all messages are available | + +Tests that messages published by one Realtime client are available in the history retrieved by a separate client. + +### Setup + +```pseudo +channel_name = "history-RTL10d-" + random_id() + +publisher = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) + +subscriber = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) + +publisher.connect() +subscriber.connect() + +AWAIT_STATE publisher.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +AWAIT_STATE subscriber.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +pub_channel = publisher.channels.get(channel_name) +sub_channel = subscriber.channels.get(channel_name) + +AWAIT pub_channel.attach() +AWAIT sub_channel.attach() +``` + +### Test Steps + +```pseudo +# Publish messages from publisher client and await confirmation +AWAIT pub_channel.publish(name: "event1", data: "data1") +AWAIT pub_channel.publish(name: "event2", data: "data2") +AWAIT pub_channel.publish(name: "event3", data: "data3") + +# Retrieve history from subscriber client +# Poll until all messages appear +history = poll_until( + condition: FUNCTION() => + result = AWAIT sub_channel.history() + RETURN result.items.length == 3, + interval: 500ms, + timeout: 10s +) +``` + +### Assertions + +```pseudo +ASSERT history.items.length == 3 + +# Default order is backwards (newest first) +ASSERT history.items[0].name == "event3" +ASSERT history.items[0].data == "data3" + +ASSERT history.items[1].name == "event2" +ASSERT history.items[1].data == "data2" + +ASSERT history.items[2].name == "event1" +ASSERT history.items[2].data == "data1" +``` + +### Cleanup + +```pseudo +AFTER TEST: + publisher.close() + subscriber.close() +``` diff --git a/uts/realtime/integration/channels/channel_attach_test.md b/uts/realtime/integration/channels/channel_attach_test.md new file mode 100644 index 000000000..f897ff39f --- /dev/null +++ b/uts/realtime/integration/channels/channel_attach_test.md @@ -0,0 +1,177 @@ +# Realtime Channel Attach/Detach Integration Tests + +Spec points: `RTL4`, `RTL4c`, `RTL5`, `RTL5d`, `RTL14` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +End-to-end verification that channel attach and detach protocol messages are +accepted by the server and produce the correct state transitions. Also verifies +that attaching to a channel with insufficient capability produces the correct +error. + +## Sandbox Setup + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + + # Key at index 3 has subscribe-only capability: {"*":["subscribe"]} + subscribe_only_key = app_config.keys[3].key_str + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RTL4c - Attach succeeds + +**Test ID**: `realtime/integration/RTL4c/attach-succeeds-0` + +| Spec | Requirement | +|------|-------------| +| RTL4c | An ATTACH ProtocolMessage is sent, state transitions to ATTACHING, then ATTACHED on confirmation | + +**Spec requirement:** When attach() is called on a channel, the SDK sends an +ATTACH protocol message. The server responds with ATTACHED and the channel +transitions to the ATTACHED state. + +### Setup +```pseudo +channel_name = "attach-RTL4c-" + random_id() + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: false +)) + +client.connect() +AWAIT_STATE client.connection.state == CONNECTED +``` + +### Test Steps +```pseudo +channel = client.channels.get(channel_name) +ASSERT channel.state == INITIALIZED + +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ATTACHED +ASSERT channel.errorReason IS NULL + +CLOSE_CLIENT(client) +``` + +--- + +## RTL5d - Detach succeeds + +**Test ID**: `realtime/integration/RTL5d/detach-succeeds-0` + +| Spec | Requirement | +|------|-------------| +| RTL5d | A DETACH ProtocolMessage is sent, state transitions to DETACHING, then DETACHED on confirmation | + +**Spec requirement:** When detach() is called on an attached channel, the SDK +sends a DETACH protocol message. The server responds with DETACHED and the +channel transitions to the DETACHED state. + +### Setup +```pseudo +channel_name = "detach-RTL5d-" + random_id() + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: false +)) + +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name) +AWAIT channel.attach() +ASSERT channel.state == ATTACHED +``` + +### Test Steps +```pseudo +AWAIT channel.detach() +``` + +### Assertions +```pseudo +ASSERT channel.state == DETACHED + +CLOSE_CLIENT(client) +``` + +--- + +## RTL14 - Insufficient capability causes channel FAILED + +**Test ID**: `realtime/integration/RTL14/insufficient-capability-failed-0` + +| Spec | Requirement | +|------|-------------| +| RTL14 | A channel-scoped ERROR transitions the channel to FAILED | + +**Spec requirement:** When a client with restricted capabilities attempts to +attach to a channel for which it lacks permission, the server responds with a +channel-scoped ERROR and the channel transitions to FAILED with the appropriate +error code. + +### Setup +```pseudo +channel_name = "publish-not-allowed-" + random_id() + +# Use key with subscribe-only capability +client = Realtime(options: ClientOptions( + key: subscribe_only_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: false +)) + +client.connect() +AWAIT_STATE client.connection.state == CONNECTED +``` + +### Test Steps +```pseudo +channel = client.channels.get(channel_name) + +# Attach succeeds (subscribe-only key can attach to any channel) +AWAIT channel.attach() +ASSERT channel.state == ATTACHED + +# Publish should fail — key lacks publish capability +AWAIT channel.publish(name: "test", data: "data") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT NULL +ASSERT error.code == 40160 +ASSERT error.statusCode == 401 + +# Connection should remain connected (channel-scoped error) +ASSERT client.connection.state == CONNECTED + +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/integration/channels/channel_publish_test.md b/uts/realtime/integration/channels/channel_publish_test.md new file mode 100644 index 000000000..43be9f0b3 --- /dev/null +++ b/uts/realtime/integration/channels/channel_publish_test.md @@ -0,0 +1,378 @@ +# Realtime Channel Publish Integration Tests + +Spec points: `RTL6`, `RTL6f`, `RSL4`, `RSL6`, `RSL6a2` + +## Test Type +Integration test against Ably sandbox + +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + +## Purpose + +End-to-end verification that messages published on one realtime connection are +received by a subscriber on a different connection with data integrity preserved. +Covers string, JSON object, and binary payloads to exercise the full encoding +pipeline (RSL4/RSL6), and verifies message metadata (RTL6f). + +## Sandbox Setup + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RTL6, RSL4d2 - String data round-trip + +**Test ID**: `realtime/integration/RTL6/string-data-roundtrip-0` + +| Spec | Requirement | +|------|-------------| +| RTL6 | RealtimeChannel#publish sends messages to Ably | +| RSL4d2 | A string message payload is represented as a JSON string | + +**Spec requirement:** A string published on one connection is received with +identical data on a subscriber connection. + +### Setup +```pseudo +channel_name = "publish-string-" + random_id() + +publisher = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +subscriber = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +publisher.connect() +subscriber.connect() +AWAIT_STATE publisher.connection.state == CONNECTED +AWAIT_STATE subscriber.connection.state == CONNECTED + +pub_channel = publisher.channels.get(channel_name) +sub_channel = subscriber.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Subscribe first, then publish +received = [] +AWAIT sub_channel.subscribe((msg) => received.append(msg)) +AWAIT pub_channel.attach() + +AWAIT pub_channel.publish(name: "string-event", data: "hello world") + +poll_until( + condition: FUNCTION() => received.length >= 1, + interval: 200ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +ASSERT received.length == 1 +ASSERT received[0].name == "string-event" +ASSERT received[0].data == "hello world" +ASSERT received[0].data IS String + +CLOSE_CLIENT(publisher) +CLOSE_CLIENT(subscriber) +``` + +--- + +## RTL6, RSL4d3 - JSON object data round-trip + +**Test ID**: `realtime/integration/RTL6/json-data-roundtrip-1` + +| Spec | Requirement | +|------|-------------| +| RTL6 | RealtimeChannel#publish sends messages to Ably | +| RSL4d3 | A JSON message payload is stringified as a JSON Object or Array | + +**Spec requirement:** A JSON object published on one connection is received as +an equivalent object on a subscriber connection. + +### Setup +```pseudo +channel_name = "publish-json-" + random_id() + +publisher = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +subscriber = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +publisher.connect() +subscriber.connect() +AWAIT_STATE publisher.connection.state == CONNECTED +AWAIT_STATE subscriber.connection.state == CONNECTED + +pub_channel = publisher.channels.get(channel_name) +sub_channel = subscriber.channels.get(channel_name) +``` + +### Test Steps +```pseudo +json_data = {"key": "value", "nested": {"count": 42}, "list": [1, 2, 3]} + +received = [] +AWAIT sub_channel.subscribe((msg) => received.append(msg)) +AWAIT pub_channel.attach() + +AWAIT pub_channel.publish(name: "json-event", data: json_data) + +poll_until( + condition: FUNCTION() => received.length >= 1, + interval: 200ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +ASSERT received.length == 1 +ASSERT received[0].name == "json-event" +ASSERT received[0].data["key"] == "value" +ASSERT received[0].data["nested"]["count"] == 42 +ASSERT received[0].data["list"] == [1, 2, 3] + +CLOSE_CLIENT(publisher) +CLOSE_CLIENT(subscriber) +``` + +--- + +## RTL6, RSL4d1 - Binary data round-trip + +**Test ID**: `realtime/integration/RTL6/binary-data-roundtrip-2` + +| Spec | Requirement | +|------|-------------| +| RTL6 | RealtimeChannel#publish sends messages to Ably | +| RSL4d1 | A binary message payload is encoded as Base64 | +| RSL6a | Received messages are decoded automatically based on encoding field | + +**Spec requirement:** A binary payload published on one connection is received as +an equivalent binary payload on a subscriber connection, with the encoding layer +handling Base64 encoding/decoding transparently. + +### Setup +```pseudo +channel_name = "publish-binary-" + random_id() + +publisher = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +subscriber = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +publisher.connect() +subscriber.connect() +AWAIT_STATE publisher.connection.state == CONNECTED +AWAIT_STATE subscriber.connection.state == CONNECTED + +pub_channel = publisher.channels.get(channel_name) +sub_channel = subscriber.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Create a binary payload with known content +binary_data = byte_array([0, 1, 2, 255, 128, 64]) + +received = [] +AWAIT sub_channel.subscribe((msg) => received.append(msg)) +AWAIT pub_channel.attach() + +AWAIT pub_channel.publish(name: "binary-event", data: binary_data) + +poll_until( + condition: FUNCTION() => received.length >= 1, + interval: 200ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +ASSERT received.length == 1 +ASSERT received[0].name == "binary-event" +ASSERT received[0].data IS Binary +ASSERT received[0].data == binary_data + +CLOSE_CLIENT(publisher) +CLOSE_CLIENT(subscriber) +``` + +--- + +## RTL6f - connectionId matches publisher + +**Test ID**: `realtime/integration/RTL6f/connectionid-matches-publisher-0` + +| Spec | Requirement | +|------|-------------| +| RTL6f | Message#connectionId should match the current Connection#id for all published messages | + +**Spec requirement:** The connectionId of received messages matches the +publisher's Connection#id. + +### Setup +```pseudo +channel_name = "publish-connid-" + random_id() + +publisher = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +subscriber = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +publisher.connect() +subscriber.connect() +AWAIT_STATE publisher.connection.state == CONNECTED +AWAIT_STATE subscriber.connection.state == CONNECTED + +pub_channel = publisher.channels.get(channel_name) +sub_channel = subscriber.channels.get(channel_name) +``` + +### Test Steps +```pseudo +publisher_connection_id = publisher.connection.id + +received = [] +AWAIT sub_channel.subscribe((msg) => received.append(msg)) +AWAIT pub_channel.attach() + +AWAIT pub_channel.publish(name: "connid-test", data: "data") + +poll_until( + condition: FUNCTION() => received.length >= 1, + interval: 200ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +ASSERT received[0].connectionId == publisher_connection_id +ASSERT received[0].connectionId != subscriber.connection.id + +CLOSE_CLIENT(publisher) +CLOSE_CLIENT(subscriber) +``` + +--- + +## RSL6a2 - Message extras round-trip + +**Test ID**: `realtime/integration/RSL6a2/message-extras-roundtrip-0` + +| Spec | Requirement | +|------|-------------| +| RSL6a2 | Tests must exist to ensure interoperability for the extras field | + +**Spec requirement:** A message published with an `extras` object is received +with an equivalent JSON-encodable object. + +### Setup +```pseudo +channel_name = "pushenabled:publish-extras-" + random_id() + +publisher = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +subscriber = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +publisher.connect() +subscriber.connect() +AWAIT_STATE publisher.connection.state == CONNECTED +AWAIT_STATE subscriber.connection.state == CONNECTED + +pub_channel = publisher.channels.get(channel_name) +sub_channel = subscriber.channels.get(channel_name) +``` + +### Test Steps +```pseudo +extras = {"push": {"notification": {"title": "Testing"}}} + +received = [] +AWAIT sub_channel.subscribe((msg) => received.append(msg)) +AWAIT pub_channel.attach() + +AWAIT pub_channel.publish(Message(name: "extras-test", data: "payload", extras: extras)) + +poll_until( + condition: FUNCTION() => received.length >= 1, + interval: 200ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +ASSERT received[0].extras IS NOT NULL +ASSERT received[0].extras["push"]["notification"]["title"] == "Testing" + +CLOSE_CLIENT(publisher) +CLOSE_CLIENT(subscriber) +``` diff --git a/uts/realtime/integration/channels/channel_subscribe_test.md b/uts/realtime/integration/channels/channel_subscribe_test.md new file mode 100644 index 000000000..4ce24dd71 --- /dev/null +++ b/uts/realtime/integration/channels/channel_subscribe_test.md @@ -0,0 +1,257 @@ +# Realtime Channel Subscribe Integration Tests + +Spec points: `RTL7`, `RTL7a`, `RTL7b`, `RTL7d` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +End-to-end verification that realtime message subscription delivers messages +correctly between connections. Complements the publish tests by verifying +subscribe-specific behavior: filtered subscriptions by message name, and +bidirectional message flow. + +## Sandbox Setup + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RTL7a - Subscribe with no name filter receives all messages + +**Test ID**: `realtime/integration/RTL7a/subscribe-all-messages-0` + +| Spec | Requirement | +|------|-------------| +| RTL7a | Subscribe with a single listener argument subscribes to all messages | + +**Spec requirement:** A subscriber with no name filter receives messages +regardless of their name. + +### Setup +```pseudo +channel_name = "subscribe-all-" + random_id() + +publisher = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: false +)) + +subscriber = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: false +)) + +publisher.connect() +subscriber.connect() +AWAIT_STATE publisher.connection.state == CONNECTED +AWAIT_STATE subscriber.connection.state == CONNECTED + +pub_channel = publisher.channels.get(channel_name) +sub_channel = subscriber.channels.get(channel_name) +``` + +### Test Steps +```pseudo +received = [] +AWAIT sub_channel.subscribe((msg) => received.append(msg)) +AWAIT pub_channel.attach() + +AWAIT pub_channel.publish(name: "event-a", data: "data-a") +AWAIT pub_channel.publish(name: "event-b", data: "data-b") +AWAIT pub_channel.publish(name: "event-c", data: "data-c") + +poll_until( + condition: FUNCTION() => received.length >= 3, + interval: 200ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +ASSERT received.length == 3 + +names = received.map(m => m.name) +ASSERT "event-a" IN names +ASSERT "event-b" IN names +ASSERT "event-c" IN names + +CLOSE_CLIENT(publisher) +CLOSE_CLIENT(subscriber) +``` + +--- + +## RTL7b - Subscribe with name filter receives only matching messages + +**Test ID**: `realtime/integration/RTL7b/subscribe-filtered-by-name-0` + +| Spec | Requirement | +|------|-------------| +| RTL7b | Subscribe with a name argument subscribes only to messages with that name | + +**Spec requirement:** A subscriber with a name filter receives only messages +whose name matches the filter. + +### Setup +```pseudo +channel_name = "subscribe-filtered-" + random_id() + +publisher = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: false +)) + +subscriber = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: false +)) + +publisher.connect() +subscriber.connect() +AWAIT_STATE publisher.connection.state == CONNECTED +AWAIT_STATE subscriber.connection.state == CONNECTED + +pub_channel = publisher.channels.get(channel_name) +sub_channel = subscriber.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Subscribe only for "target" events +target_received = [] +AWAIT sub_channel.subscribe(name: "target", (msg) => target_received.append(msg)) + +# Also subscribe to all events to know when publishing is complete +all_received = [] +sub_channel.subscribe((msg) => all_received.append(msg)) + +AWAIT pub_channel.attach() + +AWAIT pub_channel.publish(name: "other", data: "ignored") +AWAIT pub_channel.publish(name: "target", data: "wanted-1") +AWAIT pub_channel.publish(name: "other", data: "ignored") +AWAIT pub_channel.publish(name: "target", data: "wanted-2") + +poll_until( + condition: FUNCTION() => all_received.length >= 4, + interval: 200ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +# All 4 messages arrived +ASSERT all_received.length == 4 + +# Filtered subscription received only "target" messages +ASSERT target_received.length == 2 +ASSERT target_received[0].name == "target" +ASSERT target_received[0].data == "wanted-1" +ASSERT target_received[1].name == "target" +ASSERT target_received[1].data == "wanted-2" + +CLOSE_CLIENT(publisher) +CLOSE_CLIENT(subscriber) +``` + +--- + +## RTL7 - Bidirectional message flow + +**Test ID**: `realtime/integration/RTL7/bidirectional-message-flow-0` + +| Spec | Requirement | +|------|-------------| +| RTL7 | RealtimeChannel#subscribe receives messages from any publisher on the channel | + +**Spec requirement:** Two clients on the same channel can both publish and +subscribe. Messages from each client are received by the other. + +### Setup +```pseudo +channel_name = "subscribe-bidir-" + random_id() + +client_a = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: false, + clientId: "client-a" +)) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: false, + clientId: "client-b" +)) + +client_a.connect() +client_b.connect() +AWAIT_STATE client_a.connection.state == CONNECTED +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name) +channel_b = client_b.channels.get(channel_name) +``` + +### Test Steps +```pseudo +received_by_a = [] +received_by_b = [] + +AWAIT channel_a.subscribe((msg) => received_by_a.append(msg)) +AWAIT channel_b.subscribe((msg) => received_by_b.append(msg)) + +# A publishes, B should receive +AWAIT channel_a.publish(name: "from-a", data: "hello from a") + +# B publishes, A should receive +AWAIT channel_b.publish(name: "from-b", data: "hello from b") + +poll_until( + condition: FUNCTION() => received_by_a.length >= 2 AND received_by_b.length >= 2, + interval: 200ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +# Both clients receive messages from both publishers (including their own echoes) +a_names = received_by_a.map(m => m.name) +b_names = received_by_b.map(m => m.name) + +ASSERT "from-a" IN a_names +ASSERT "from-b" IN a_names +ASSERT "from-a" IN b_names +ASSERT "from-b" IN b_names + +CLOSE_CLIENT(client_a) +CLOSE_CLIENT(client_b) +``` diff --git a/uts/realtime/integration/connection/connection_failures_test.md b/uts/realtime/integration/connection/connection_failures_test.md new file mode 100644 index 000000000..5f370f9f7 --- /dev/null +++ b/uts/realtime/integration/connection/connection_failures_test.md @@ -0,0 +1,116 @@ +# Realtime Connection Failures Integration Tests + +Spec points: `RTN14a`, `RTN14g` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +End-to-end verification that the server rejects invalid credentials with the +correct error codes and the SDK transitions to the expected state. Complements +the unit tests which verify client-side state machine behavior with mocked +transports. + +## Sandbox Setup + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RTN14a - Invalid API key causes FAILED + +**Test ID**: `realtime/integration/RTN14a/invalid-key-failed-0` + +| Spec | Requirement | +|------|-------------| +| RTN14a | If an API key is invalid, the connection transitions to FAILED | + +**Spec requirement:** When connecting with an invalid API key, the server sends +an ERROR ProtocolMessage and the connection transitions to the FAILED state with +the error set on Connection#errorReason. + +### Setup +```pseudo +client = Realtime(options: ClientOptions( + key: "invalid.key:secret", + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == FAILED + WITH timeout: 15s +``` + +### Assertions +```pseudo +ASSERT client.connection.state == FAILED +ASSERT client.connection.errorReason IS NOT NULL +ASSERT client.connection.errorReason.code == 40005 OR client.connection.errorReason.code == 40101 +ASSERT client.connection.errorReason.statusCode == 401 OR client.connection.errorReason.statusCode == 404 + +CLOSE_CLIENT(client) +``` + +--- + +## RTN14g - Revoked key causes FAILED + +**Test ID**: `realtime/integration/RTN14g/revoked-key-failed-0` + +| Spec | Requirement | +|------|-------------| +| RTN14g | An ERROR ProtocolMessage with an empty channel for reasons other than token error causes FAILED | + +**Spec requirement:** When connecting with a key that has been revoked or belongs +to a deleted app, the server sends a non-token ERROR and the connection transitions +to FAILED. + +Note: This test uses a syntactically valid but non-existent app ID. The server +rejects the connection with a 404 or 401 error, which is not a token error +(not in the 40140-40149 range), so RTN14g applies. + +### Setup +```pseudo +# Use a key with a valid format but non-existent app +client = Realtime(options: ClientOptions( + key: "nonexistent.keyname:keysecret", + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == FAILED + WITH timeout: 15s +``` + +### Assertions +```pseudo +ASSERT client.connection.state == FAILED +ASSERT client.connection.errorReason IS NOT NULL +# Server returns 40005 (invalid key) or similar non-token error +ASSERT client.connection.errorReason.code < 40140 OR client.connection.errorReason.code >= 40150 + +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/integration/connection_lifecycle_test.md b/uts/realtime/integration/connection_lifecycle_test.md new file mode 100644 index 000000000..4cd36949f --- /dev/null +++ b/uts/realtime/integration/connection_lifecycle_test.md @@ -0,0 +1,238 @@ +# Realtime Connection Lifecycle Integration Tests + +Spec points: `RTN4b`, `RTN4c`, `RTN11`, `RTN12`, `RTN12a`, `RTN21` + +## Test Type +Integration test against Ably Sandbox endpoint + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + # Provision test app + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + # Clean up test app + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RTN4b, RTN21 - Successful connection establishment + +**Test ID**: `realtime/integration/RTN4b/successful-connection-0` + +| Spec | Requirement | +|------|-------------| +| RTN4b | When a connection is initiated, it transitions INITIALIZED → CONNECTING → CONNECTED | +| RTN21 | Connections are initiated via WebSocket transport | + +Tests that a Realtime client can successfully connect to Ably via WebSocket. + +### Setup + +```pseudo +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) +``` + +### Test Steps + +```pseudo +# Client starts in INITIALIZED state +ASSERT client.connection.state == ConnectionState.initialized + +# Start connection +client.connect() + +# Wait for CONNECTING state +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Wait for CONNECTED state (with timeout) +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +# Verify connection properties are set +ASSERT client.connection.id IS NOT null +ASSERT client.connection.key IS NOT null +``` + +### Assertions + +```pseudo +# Final state is CONNECTED +ASSERT client.connection.state == ConnectionState.connected + +# Connection ID is a non-empty string +ASSERT client.connection.id matches "[a-zA-Z0-9_-]+" + +# Connection key is a non-empty string +ASSERT client.connection.key matches "[a-zA-Z0-9_!-]+" + +# No error reason +ASSERT client.connection.errorReason IS null + +CLOSE_CLIENT(client) +``` + +--- + +## RTN4c, RTN12, RTN12a - Graceful connection close + +**Test ID**: `realtime/integration/RTN4c/graceful-close-0` + +| Spec | Requirement | +|------|-------------| +| RTN4c | Normal disconnection: CONNECTED → CLOSING → CLOSED | +| RTN12 | Connection.close() initiates close sequence | +| RTN12a | Sends CLOSE message and waits for confirmation | + +Tests that a connected client can gracefully close the connection. + +### Setup + +```pseudo +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) + +# Establish connection first +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Test Steps + +```pseudo +# Close the connection +client.connection.close() + +# Should transition through CLOSING +AWAIT_STATE client.connection.state == ConnectionState.closing + +# Should reach CLOSED +AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Final state is CLOSED +ASSERT client.connection.state == ConnectionState.closed + +# No error reason (clean close) +ASSERT client.connection.errorReason IS null + +# Connection ID is cleared +ASSERT client.connection.id IS null + +# Connection key is cleared +ASSERT client.connection.key IS null +``` + +--- + +## RTN11, RTN4b - Connect and reconnect cycle + +**Test ID**: `realtime/integration/RTN11/connect-reconnect-cycle-0` + +| Spec | Requirement | +|------|-------------| +| RTN11 | Connection.connect() explicitly opens connection | +| RTN4b | Each connection follows CONNECTING → CONNECTED flow | + +Tests that a client can be closed and reconnected multiple times. + +### Setup + +```pseudo +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false # Don't connect automatically +)) +``` + +### Test Steps + +```pseudo +# Initial state +ASSERT client.connection.state == ConnectionState.initialized + +# First connection +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +first_connection_id = client.connection.id + +# Close connection +client.connection.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + +# Reconnect +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +second_connection_id = client.connection.id +``` + +### Assertions + +```pseudo +# Successfully connected twice +ASSERT second_connection_id IS NOT null + +# Each connection gets a new ID (not a resume) +ASSERT first_connection_id != second_connection_id + +# No errors +ASSERT client.connection.errorReason IS null + +CLOSE_CLIENT(client) +``` + +--- + +## Integration Test Notes + +### Timeout Handling + +All `AWAIT_STATE` calls should have reasonable timeouts: +- CONNECTING → CONNECTED: 10 seconds (allows for auth + transport setup) +- CONNECTED → CLOSING: 1 second (immediate transition) +- CLOSING → CLOSED: 5 seconds (allows for CLOSE message roundtrip) + +### Error Handling + +If any connection fails to reach CONNECTED state: +- Log the connection errorReason +- Log any emitted state changes with reasons +- Fail the test with diagnostic information + +### Cleanup + +Always close connections in test cleanup: + +```pseudo +AFTER EACH TEST: + IF client IS NOT null AND client.connection.state IN [CONNECTED, CONNECTING]: + client.connection.close() + # Wait briefly for close to complete + AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10 seconds +``` diff --git a/uts/realtime/integration/delta_decoding_test.md b/uts/realtime/integration/delta_decoding_test.md new file mode 100644 index 000000000..368851227 --- /dev/null +++ b/uts/realtime/integration/delta_decoding_test.md @@ -0,0 +1,565 @@ +# Delta Decoding Integration Tests + +Spec points: `PC3`, `PC3a`, `RTL18`, `RTL18b`, `RTL18c`, `RTL19b`, `RTL20` + +## Test Type +Integration test against Ably sandbox + +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + +## Purpose + +End-to-end verification of vcdiff delta decoding using real connections against the +Ably sandbox. The server generates vcdiff-encoded deltas when a channel is attached +with `params: { delta: 'vcdiff' }`. These tests verify that the SDK correctly +decodes those deltas using a real vcdiff decoder plugin. + +These tests complement the unit tests (which use a mock vcdiff encoder/decoder) by +exercising the full pipeline: publish → server generates delta → SDK decodes with +real vcdiff decoder → subscriber receives original data. + +## Dependencies + +These tests require a real VCDiff decoder that implements the `VCDiffDecoder` +interface (`VD2a`). The decoder must accept `(delta: byte[], base: byte[]) -> byte[]`. + +Concrete implementations should adapt whichever vcdiff library is available for their +platform. For example, the Dart SDK uses the `vcdiff` package which exposes +`decode(Uint8List source, Uint8List delta) -> Uint8List` — note the swapped argument +order compared to `VD2a`. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. + +**Note:** `useBinaryProtocol: PROTOCOL == "msgpack"` is used so tests run with both protocols (see Protocol Variants). + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Test Data + +All tests that publish multiple messages use the same dataset: + +```pseudo +test_data = [ + { foo: "bar", count: 1, status: "active" }, + { foo: "bar", count: 2, status: "active" }, + { foo: "bar", count: 2, status: "inactive" }, + { foo: "bar", count: 3, status: "inactive" }, + { foo: "bar", count: 3, status: "active" } +] +``` + +The data is intentionally similar between messages so that the server generates +small vcdiff deltas rather than sending full messages. + +--- + +## PC3 - Delta plugin decodes messages end-to-end + +**Test ID**: `realtime/integration/PC3/delta-decode-end-to-end-0` + +**Spec requirement:** A plugin provided with the PluginType key `vcdiff` should be +capable of decoding vcdiff-encoded messages. + +Tests that with a real vcdiff decoder plugin and a channel configured for delta +mode, all published messages are received with correct data, and that the decoder +was invoked for the delta messages (all except the first). + +### Setup +```pseudo +channel_name = "delta-PC3-" + random_id() + +# Use a wrapping decoder that counts invocations +decode_count = 0 +counting_decoder = VCDiffDecoder( + onDecode: (delta, base) => { + decode_count++ + } +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack", + plugins: { vcdiff: counting_decoder } +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +channel = client.channels.get(channel_name, options: ChannelOptions( + params: { "delta": "vcdiff" } +)) + +AWAIT channel.attach() + +received_messages = [] + +# Fail the test if the channel reattaches (decode failure) +channel.on(ChannelEvent.attaching, (change) => { + FAIL("Channel reattaching due to decode failure: " + change.reason) +}) + +channel.subscribe((msg) => received_messages.append(msg)) + +# Publish all messages sequentially +FOR i IN 0..length(test_data) - 1: + AWAIT channel.publish(str(i), test_data[i]) + +# Wait for all messages to be received +WAIT UNTIL length(received_messages) == length(test_data) + WITH timeout: 15 seconds +``` + +### Assertions +```pseudo +FOR i IN 0..length(test_data) - 1: + ASSERT received_messages[i].name == str(i) + ASSERT received_messages[i].data == test_data[i] + +# First message is sent as full payload, rest as deltas +ASSERT decode_count == length(test_data) - 1 +``` + +### Cleanup +```pseudo +client.close() +``` + +--- + +## RTL19b - Dissimilar payloads received without delta encoding + +**Test ID**: `realtime/integration/RTL19b/dissimilar-payloads-no-delta-0` + +**Spec requirement:** In the case of a non-delta message, the resulting `data` value +is stored as the base payload. + +Tests that when a channel is configured for delta mode but successive messages have +completely dissimilar payloads (random binary data), the server is expected to send +full messages rather than deltas. The SDK must handle this correctly — each non-delta +message updates the stored base payload and is delivered to subscribers. + +If the server nonetheless chooses to generate a delta, the test does not fail; it +verifies correct behaviour regardless of whether deltas are used. The assertion on +decode count is skipped if deltas were generated. + +### Setup +```pseudo +channel_name = "delta-dissimilar-" + random_id() +message_count = 5 + +decode_count = 0 +counting_decoder = VCDiffDecoder( + onDecode: (delta, base) => { + decode_count++ + } +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack", + plugins: { vcdiff: counting_decoder } +)) + +# Generate random binary payloads — 1KB each, completely dissimilar +payloads = [] +FOR i IN 0..message_count - 1: + payloads.append(random_bytes(1024)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +channel = client.channels.get(channel_name, options: ChannelOptions( + params: { "delta": "vcdiff" } +)) + +AWAIT channel.attach() + +received_messages = [] + +# Fail the test if the channel reattaches (decode failure) +channel.on(ChannelEvent.attaching, (change) => { + FAIL("Channel reattaching due to decode failure: " + change.reason) +}) + +channel.subscribe((msg) => received_messages.append(msg)) + +FOR i IN 0..message_count - 1: + AWAIT channel.publish(str(i), payloads[i]) + +WAIT UNTIL length(received_messages) == message_count + WITH timeout: 15 seconds +``` + +### Assertions +```pseudo +# All messages received with correct data +FOR i IN 0..message_count - 1: + ASSERT received_messages[i].name == str(i) + ASSERT received_messages[i].data == payloads[i] + +# The server is expected to send full messages (no deltas) for dissimilar +# random binary payloads. If so, the decoder should not have been called. +# However, the server may still choose to generate deltas, so we only log +# the decode count rather than asserting it is zero. +LOG "Decoder was called " + str(decode_count) + " times for " + str(message_count) + " dissimilar messages" +``` + +### Cleanup +```pseudo +client.close() +``` + +--- + +## PC3 - No deltas without delta channel param + +**Test ID**: `realtime/integration/PC3/no-deltas-without-param-1` + +**Spec requirement:** The vcdiff plugin is only used when the channel is configured to +request delta compression from the server. + +Tests that when a channel is attached without `params: { delta: 'vcdiff' }`, the +server sends full messages and the vcdiff decoder is never called. + +### Setup +```pseudo +channel_name = "delta-no-param-" + random_id() + +decode_count = 0 +counting_decoder = VCDiffDecoder( + onDecode: (delta, base) => { + decode_count++ + } +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack", + plugins: { vcdiff: counting_decoder } +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach WITHOUT delta params +channel = client.channels.get(channel_name) + +AWAIT channel.attach() + +received_messages = [] +channel.subscribe((msg) => received_messages.append(msg)) + +FOR i IN 0..length(test_data) - 1: + AWAIT channel.publish(str(i), test_data[i]) + +WAIT UNTIL length(received_messages) == length(test_data) + WITH timeout: 15 seconds +``` + +### Assertions +```pseudo +FOR i IN 0..length(test_data) - 1: + ASSERT received_messages[i].name == str(i) + ASSERT received_messages[i].data == test_data[i] + +# No deltas — decoder was never called +ASSERT decode_count == 0 +``` + +### Cleanup +```pseudo +client.close() +``` + +--- + +## RTL18, RTL18b, RTL18c, RTL20 - Recovery after last message ID mismatch + +**Test ID**: `realtime/integration/RTL18/recovery-message-id-mismatch-0` + +| Spec | Requirement | +|------|-------------| +| RTL18 | Decode failure triggers automatic recovery | +| RTL18b | The failed message is discarded | +| RTL18c | ATTACH sent with channelSerial, channel transitions to ATTACHING with error 40018 | +| RTL20 | Delta reference ID must match stored last message ID | + +Tests that when the stored last message ID is cleared (simulating a gap), the next +delta message fails the RTL20 base reference check, triggering the RTL18 recovery +procedure. After recovery the channel reattaches and remaining messages are delivered. + +**Note:** This test manipulates internal SDK state (the stored last message ID) to +simulate a message gap. The mechanism for doing this is implementation-specific. + +### Setup +```pseudo +channel_name = "delta-recovery-mismatch-" + random_id() + +decode_count = 0 +counting_decoder = VCDiffDecoder( + onDecode: (delta, base) => { + decode_count++ + } +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack", + plugins: { vcdiff: counting_decoder } +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +channel = client.channels.get(channel_name, options: ChannelOptions( + params: { "delta": "vcdiff" } +)) + +AWAIT channel.attach() + +received_messages = [] +attaching_reasons = [] + +channel.on(ChannelEvent.attaching, (change) => { + attaching_reasons.append(change.reason) +}) + +channel.subscribe((msg) => received_messages.append(msg)) + +# Publish first batch of messages and wait for them to arrive. +# Publishing in two batches ensures the server has sent and the client has +# processed the first batch before we clear the stored ID. If all messages +# were published at once, they could all arrive in a single ProtocolMessage +# before clearLastPayloadMessageId takes effect. +FOR i IN 0..2: + AWAIT channel.publish(str(i), test_data[i]) + +WAIT UNTIL length(received_messages) >= 3 + WITH timeout: 15 seconds + +# Simulate a message gap by clearing the stored last message ID. +# The next delta will fail the RTL20 check. +# (Implementation-specific: access internal _lastPayload.messageId or equivalent) +CLEAR channel._lastPayload.messageId + +# Publish remaining messages — the server should send these as deltas, +# which will fail the RTL20 check and trigger recovery +FOR i IN 3..length(test_data) - 1: + AWAIT channel.publish(str(i), test_data[i]) + +# Wait for all messages to be received — recovery will reattach and +# the server will resend from the channelSerial +WAIT UNTIL (unique message names in received_messages) covers all 0..length(test_data)-1 + WITH timeout: 30 seconds +``` + +### Assertions +```pseudo +# All messages were eventually received with correct data (may have duplicates +# from the server resending after recovery) +FOR i IN 0..length(test_data) - 1: + msg = FIND received_messages WHERE name == str(i) + ASSERT msg IS NOT null + ASSERT msg.data == test_data[i] + +# RTL18c: Recovery was triggered with error code 40018 +ASSERT length(attaching_reasons) >= 1 +ASSERT attaching_reasons[0].code == 40018 +``` + +### Cleanup +```pseudo +client.close() +``` + +--- + +## RTL18, RTL18c - Recovery after decode failure + +**Test ID**: `realtime/integration/RTL18/recovery-decode-failure-1` + +| Spec | Requirement | +|------|-------------| +| RTL18 | Decode failure triggers automatic recovery | +| RTL18c | ATTACH sent with channelSerial, channel transitions to ATTACHING with error 40018 | + +Tests that when the vcdiff decoder throws an error, the channel transitions to +ATTACHING with error 40018 and recovers by reattaching. After recovery, remaining +messages are delivered (the server resends from the channelSerial as non-deltas +since the decode context is lost). + +### Setup +```pseudo +channel_name = "delta-recovery-decode-" + random_id() + +# Decoder that always fails +failing_decoder = FailingVCDiffDecoder() + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack", + plugins: { vcdiff: failing_decoder } +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +channel = client.channels.get(channel_name, options: ChannelOptions( + params: { "delta": "vcdiff" } +)) + +AWAIT channel.attach() + +received_messages = [] +attaching_reasons = [] + +channel.on(ChannelEvent.attaching, (change) => { + attaching_reasons.append(change.reason) +}) + +channel.subscribe((msg) => received_messages.append(msg)) + +FOR i IN 0..length(test_data) - 1: + AWAIT channel.publish(str(i), test_data[i]) + +# Wait for all messages — first arrives as non-delta, second triggers decode +# failure and recovery, then remaining messages arrive after reattach +WAIT UNTIL length(received_messages) >= length(test_data) + WITH timeout: 30 seconds +``` + +### Assertions +```pseudo +# All messages eventually received with correct data +FOR i IN 0..length(test_data) - 1: + msg = FIND received_messages WHERE name == str(i) + ASSERT msg IS NOT null + ASSERT msg.data == test_data[i] + +# RTL18c: At least one recovery was triggered +ASSERT length(attaching_reasons) >= 1 +ASSERT attaching_reasons[0].code == 40018 +``` + +### Cleanup +```pseudo +client.close() +``` + +--- + +## PC3 - No plugin causes FAILED state + +**Test ID**: `realtime/integration/PC3/no-plugin-causes-failed-2` + +**Spec requirement:** Without a vcdiff decoder plugin, vcdiff-encoded messages cannot +be decoded and the channel should transition to FAILED. + +Tests that when a channel is configured for delta mode but no vcdiff plugin is +registered, receiving a delta-encoded message causes the channel to transition to +FAILED with error code 40019. + +**Note:** This test uses a separate publisher client because the subscribing client's +channel transitions to FAILED when it receives a delta it cannot decode. If the same +client were used for both publishing and subscribing, subsequent `publish()` calls +would fail with a "channel is FAILED" error, and pending publish ACKs could also +fail. Using a separate publisher avoids these complications. + +### Setup +```pseudo +channel_name = "delta-no-plugin-" + random_id() + +# Subscriber — no vcdiff plugin, but requests delta channel param +subscriber = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +# Publisher — separate connection, publishes without delta param +publisher = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) +``` + +### Test Steps +```pseudo +subscriber.connect() +publisher.connect() +AWAIT_STATE subscriber.connection.state == ConnectionState.connected +AWAIT_STATE publisher.connection.state == ConnectionState.connected + +sub_channel = subscriber.channels.get(channel_name, options: ChannelOptions( + params: { "delta": "vcdiff" } +)) + +AWAIT sub_channel.attach() + +# Publisher uses a plain channel (no delta param) +pub_channel = publisher.channels.get(channel_name) +AWAIT pub_channel.attach() + +# Publish enough messages to trigger delta encoding on subscriber +FOR i IN 0..length(test_data) - 1: + AWAIT pub_channel.publish(str(i), test_data[i]) + +# Subscriber channel should transition to FAILED when it receives a delta +# it cannot decode (no vcdiff plugin registered) +WAIT UNTIL sub_channel.state == ChannelState.failed + WITH timeout: 15 seconds +``` + +### Assertions +```pseudo +ASSERT sub_channel.state == ChannelState.failed +ASSERT sub_channel.errorReason.code == 40019 +``` + +### Cleanup +```pseudo +subscriber.close() +publisher.close() +``` diff --git a/uts/realtime/integration/helpers/proxy.md b/uts/realtime/integration/helpers/proxy.md new file mode 100644 index 000000000..303d8ea74 --- /dev/null +++ b/uts/realtime/integration/helpers/proxy.md @@ -0,0 +1,232 @@ +# Proxy Infrastructure for Integration Tests + +## Overview + +The Ably test proxy is a programmable HTTP/WebSocket proxy ([ably/uts-proxy](https://github.com/ably/uts-proxy)) that sits between the SDK under test and the Ably sandbox. It transparently forwards traffic by default, but can be configured with rules to inject faults — dropped connections, modified responses, injected protocol messages, delayed frames, etc. + +Proxy integration tests use this to verify fault-handling behaviour against the real Ably backend, providing higher confidence than unit tests with mocked transports. + +## When to Use Proxy Tests vs Unit Tests vs Direct Sandbox Tests + +| Test type | When to use | +|-----------|-------------| +| **Unit test** (mock HTTP/WebSocket) | Testing client-side logic, state machines, request formation, error parsing. Fast, deterministic. | +| **Direct sandbox integration** | Testing happy-path behaviour: connect, publish, subscribe, presence. No fault injection needed. | +| **Proxy integration test** | Testing fault behaviour against the real backend: connection failures, resume, heartbeat starvation, token renewal under network errors, channel error injection. | + +## Proxy Session Lifecycle + +```pseudo +# 1. Create a proxy session with rules +session = create_proxy_session( + endpoint: "nonprod:sandbox", + rules: [ ...rules... ] +) + +# 2. Connect SDK through proxy +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", # REC1b2: sets both restHost and realtimeHost + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, # Required: Dart SDK doesn't implement msgpack + autoConnect: false + # Note: explicit hostname endpoint automatically disables fallback hosts (REC2c2) +)) + +# 3. Run test scenario +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# 4. (Optional) Add rules dynamically or trigger imperative actions +session.add_rules(new_rules, position: "prepend") +session.trigger_action({ type: "disconnect" }) + +# 5. (Optional) Verify proxy event log +log = session.get_log() +ASSERT log CONTAINS event WHERE type == "ws_connect" AND queryParams.resume IS NOT null + +# 6. Clean up +client.close() +session.close() +``` + +## Proxy Session Interface + +```pseudo +interface ProxySession: + session_id: String + proxy_host: String # Always "localhost" + proxy_port: Int # Auto-assigned by proxy, or explicit if specified + + add_rules(rules: List, position?: "append"|"prepend") + trigger_action(action: ActionRequest) + get_log(): List + close() + +function create_proxy_session( + endpoint: String, # e.g. "nonprod:sandbox" → resolves to sandbox.realtime.ably-nonprod.net + port?: Int, # Optional; proxy auto-assigns a free port if omitted + rules?: List, + timeoutMs?: Int # Session auto-cleanup timeout (default 30000) +): ProxySession +``` + +## Rule Format + +Each rule has a **match** condition, an **action** to perform, and an optional **times** limit: + +```json +{ + "match": { ... }, + "action": { ... }, + "times": 1, + "comment": "human-readable label" +} +``` + +Rules are evaluated in order. First matching rule wins. Unmatched traffic passes through unchanged. When `times` is specified, the rule auto-removes after that many firings. + +### Match Conditions + +```json +// WebSocket connection attempt +{ "type": "ws_connect" } +{ "type": "ws_connect", "count": 2 } +{ "type": "ws_connect", "queryContains": { "resume": "*" } } + +// WebSocket frame: server → client +{ "type": "ws_frame_to_client", "action": "CONNECTED" } +{ "type": "ws_frame_to_client", "action": "ATTACHED", "channel": "my-channel" } + +// WebSocket frame: client → server +{ "type": "ws_frame_to_server", "action": "ATTACH", "channel": "my-channel" } + +// HTTP request +{ "type": "http_request", "pathContains": "/channels/" } +{ "type": "http_request", "method": "POST", "pathContains": "/keys/" } + +// Temporal trigger (fires once after delay from WS connect) +{ "type": "delay_after_ws_connect", "delayMs": 5000 } +``` + +**`count`**: 1-based occurrence counter. `count: 2` matches only the 2nd occurrence. + +### Actions + +```json +// Passthrough (default for unmatched traffic) +{ "type": "passthrough" } + +// Connection-level +{ "type": "refuse_connection" } +{ "type": "accept_and_close", "closeCode": 1011 } +{ "type": "disconnect" } +{ "type": "close", "closeCode": 1000 } + +// Frame manipulation +{ "type": "suppress" } +{ "type": "delay", "delayMs": 2000 } +{ "type": "inject_to_client", "message": { "action": 6, ... } } +{ "type": "inject_to_client_and_close", "message": { "action": 6, ... }, "closeCode": 1000 } +{ "type": "replace", "message": { "action": 4, ... } } +{ "type": "suppress_onwards" } + +// HTTP +{ "type": "http_respond", "status": 401, "body": { ... } } +{ "type": "http_delay", "delayMs": 5000 } +{ "type": "http_drop" } +``` + +### Imperative Actions + +For cases where timed rules are awkward (e.g. "drop the connection NOW"): + +```json +{ "type": "disconnect" } +{ "type": "close", "closeCode": 1000 } +{ "type": "inject_to_client", "message": { ... } } +{ "type": "inject_to_client_and_close", "message": { ... } } +``` + +## Event Log + +The proxy records all traffic through a session. The log can be retrieved to verify test assertions. + +```json +{ + "events": [ + { "type": "ws_connect", "url": "ws://...", "queryParams": { "key": "..." } }, + { "type": "ws_frame", "direction": "server_to_client", "message": { "action": 4, ... } }, + { "type": "ws_disconnect", "initiator": "proxy", "closeCode": 1006 }, + { "type": "http_request", "method": "GET", "path": "/channels/test/messages" }, + { "type": "http_response", "status": 200 } + ] +} +``` + +### Common Log Assertions + +```pseudo +# Verify resume was attempted +log = session.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +ASSERT ws_connects.length >= 2 +ASSERT ws_connects[1].queryParams["resume"] IS NOT null + +# Verify a specific frame was sent +frames = log.filter(e => e.type == "ws_frame" AND e.direction == "client_to_server") +attach_frames = frames.filter(f => f.message.action == 10) # ATTACH +ASSERT attach_frames.length == 1 +``` + +## Protocol Message Action Numbers + +| Name | Number | Direction | +|------|--------|-----------| +| HEARTBEAT | 0 | Both | +| ACK | 1 | Server → Client | +| NACK | 2 | Server → Client | +| CONNECT | 3 | Client → Server | +| CONNECTED | 4 | Server → Client | +| DISCONNECT | 5 | Client → Server | +| DISCONNECTED | 6 | Server → Client | +| CLOSE | 7 | Client → Server | +| CLOSED | 8 | Server → Client | +| ERROR | 9 | Server → Client | +| ATTACH | 10 | Client → Server | +| ATTACHED | 11 | Server → Client | +| DETACH | 12 | Client → Server | +| DETACHED | 13 | Server → Client | +| PRESENCE | 14 | Both | +| MESSAGE | 15 | Both | +| SYNC | 16 | Server → Client | +| AUTH | 17 | Client → Server | + +## SDK ClientOptions for Proxy Tests + +All proxy integration tests should configure the SDK with: + +```pseudo +ClientOptions( + key: api_key, + endpoint: "localhost", # REC1b2: sets both restHost and realtimeHost to "localhost" + port: proxy_port, # The proxy session's assigned port + tls: false, # Proxy serves plain HTTP/WS; TLS only upstream + useBinaryProtocol: false, # Required: SDK doesn't implement msgpack + autoConnect: false # Explicit connect for test control + # fallbackHosts: not needed — endpoint="localhost" auto-disables fallbacks (REC2c2) +) +``` + +## Conventions for Proxy Integration Test Specs + +1. Each test references the spec point AND the corresponding unit test +2. Tests use `create_proxy_session()` with rules, then connect SDK through the proxy +3. Tests use `AWAIT_STATE` for state assertions and record state changes for sequence verification +4. Tests verify behaviour via SDK state AND proxy event log where useful +5. All tests use `useBinaryProtocol: false` (SDK doesn't implement msgpack) +6. All tests use `endpoint: "localhost"` which auto-disables fallback hosts (REC2c2) +7. Timeouts are generous (10-30s) since real network is involved +8. Each test file provisions a sandbox app in `BEFORE ALL TESTS` and cleans up in `AFTER ALL TESTS` +9. Each test creates its own proxy session and cleans it up after diff --git a/uts/realtime/integration/mutable_messages_test.md b/uts/realtime/integration/mutable_messages_test.md new file mode 100644 index 000000000..78989a478 --- /dev/null +++ b/uts/realtime/integration/mutable_messages_test.md @@ -0,0 +1,862 @@ +# Realtime Mutable Messages & Annotations Integration Tests + +Spec points: `RTL28`, `RTL31`, `RTL32`, `RTAN1`, `RTAN2`, `RTAN4` + +## Test Type +Integration test against Ably sandbox + +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + +## Purpose + +End-to-end verification of mutable messages and annotations over realtime +(WebSocket) connections against the Ably sandbox. These tests complement the REST +integration tests (`rest/integration/mutable_messages.md`) by verifying: + +- Update/delete/append via MESSAGE ProtocolMessage (RTL32) rather than HTTP PATCH (RSL15) +- Real-time delivery of mutation events to subscribers +- Annotation publish/delete via ANNOTATION ProtocolMessage (RTAN1/RTAN2) rather than HTTP POST (RSAN1/RSAN2) +- Real-time delivery of annotations to subscribers (RTAN4) +- getMessage and getMessageVersions work from a RealtimeChannel instance (RTL28/RTL31) + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. + +**Note:** `useBinaryProtocol: PROTOCOL == "msgpack"` is used so tests run with both protocols (see Protocol Variants). + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Notes +- All clients use `useBinaryProtocol: PROTOCOL == "msgpack"` (see Protocol Variants) +- All clients use `endpoint: "nonprod:sandbox"` +- All channel names use the `mutable:` namespace prefix — the test app setup configures + the `mutable` namespace with `mutableMessages: true` + +--- + +## RTL32 — Update a message via realtime and observe on subscriber + +**Test ID**: `realtime/integration/RTL32/update-message-observed-0` + +**Spec requirement:** RTL32b1 — `updateMessage()` sends a MESSAGE ProtocolMessage +with `MESSAGE_UPDATE` action. RTL32d — returns `UpdateDeleteResult` from ACK. + +Tests that a message published via realtime can be updated via a realtime channel, +and the update event is delivered in real-time to a subscriber on a separate connection. + +### Setup +```pseudo +channel_name = "mutable:rt-update-" + random_id() + +client_a = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +client_a.connect() +client_b.connect() + +AWAIT_STATE client_a.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +AWAIT_STATE client_b.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +channel_a = client_a.channels.get(channel_name) +channel_b = client_b.channels.get(channel_name) + +AWAIT channel_b.attach() + +# Collect all messages on client B +received_messages = [] +channel_b.subscribe((msg) => { + received_messages.append(msg) +}) + +AWAIT channel_a.attach() +``` + +### Test Steps +```pseudo +# Publish original message via realtime +AWAIT channel_a.publish(name: "original", data: "v1") + +# Wait for client B to receive the original +poll_until( + condition: FUNCTION() => received_messages.length >= 1, + interval: 200ms, + timeout: 10s +) + +# Get the serial from the received message +serial = received_messages[0].serial + +# Update via realtime +update_result = AWAIT channel_a.updateMessage( + Message(serial: serial, name: "updated", data: "v2"), + operation: MessageOperation(description: "edited") +) + +# Wait for client B to receive the update event +poll_until( + condition: FUNCTION() => received_messages.length >= 2, + interval: 200ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +# Update returned a result +ASSERT update_result IS UpdateDeleteResult +ASSERT update_result.versionSerial IS String +ASSERT update_result.versionSerial.length > 0 + +# Client B received the original +ASSERT received_messages[0].action == MessageAction.MESSAGE_CREATE +ASSERT received_messages[0].name == "original" +ASSERT received_messages[0].data == "v1" +ASSERT received_messages[0].serial IS String +ASSERT received_messages[0].serial.length > 0 + +# Client B received the update in real-time +update_msg = received_messages[1] +ASSERT update_msg.action == MessageAction.MESSAGE_UPDATE +ASSERT update_msg.name == "updated" +ASSERT update_msg.data == "v2" +ASSERT update_msg.serial == serial +``` + +### Cleanup +```pseudo +AWAIT client_a.close() +AWAIT client_b.close() +``` + +--- + +## RTL32 — Delete a message via realtime and observe on subscriber + +**Test ID**: `realtime/integration/RTL32/delete-message-observed-1` + +**Spec requirement:** RTL32b1 — `deleteMessage()` sends a MESSAGE ProtocolMessage +with `MESSAGE_DELETE` action. + +Tests that a published message can be deleted via a realtime channel and the delete +event is delivered in real-time to a subscriber. + +### Setup +```pseudo +channel_name = "mutable:rt-delete-" + random_id() + +client_a = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +client_a.connect() +client_b.connect() + +AWAIT_STATE client_a.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +AWAIT_STATE client_b.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +channel_a = client_a.channels.get(channel_name) +channel_b = client_b.channels.get(channel_name) + +AWAIT channel_b.attach() + +received_messages = [] +channel_b.subscribe((msg) => { + received_messages.append(msg) +}) + +AWAIT channel_a.attach() +``` + +### Test Steps +```pseudo +# Publish original +AWAIT channel_a.publish(name: "to-delete", data: "ephemeral") + +poll_until( + condition: FUNCTION() => received_messages.length >= 1, + interval: 200ms, + timeout: 10s +) + +serial = received_messages[0].serial + +# Delete via realtime +delete_result = AWAIT channel_a.deleteMessage(Message(serial: serial)) + +# Wait for delete event +poll_until( + condition: FUNCTION() => received_messages.length >= 2, + interval: 200ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +ASSERT delete_result IS UpdateDeleteResult +ASSERT delete_result.versionSerial IS String +ASSERT delete_result.versionSerial.length > 0 + +# Client B received the delete event +delete_msg = received_messages[1] +ASSERT delete_msg.action == MessageAction.MESSAGE_DELETE +ASSERT delete_msg.serial == serial +``` + +### Cleanup +```pseudo +AWAIT client_a.close() +AWAIT client_b.close() +``` + +--- + +## RTL32 — Append to a message via realtime and observe on subscriber + +**Test ID**: `realtime/integration/RTL32/append-message-observed-2` + +**Spec requirement:** RTL32b1 — `appendMessage()` sends a MESSAGE ProtocolMessage +with `MESSAGE_APPEND` action. + +### Setup +```pseudo +channel_name = "mutable:rt-append-" + random_id() + +client_a = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +client_a.connect() +client_b.connect() + +AWAIT_STATE client_a.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +AWAIT_STATE client_b.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +channel_a = client_a.channels.get(channel_name) +channel_b = client_b.channels.get(channel_name) + +AWAIT channel_b.attach() + +received_messages = [] +channel_b.subscribe((msg) => { + received_messages.append(msg) +}) + +AWAIT channel_a.attach() +``` + +### Test Steps +```pseudo +# Publish original +AWAIT channel_a.publish(name: "appendable", data: "original") + +poll_until( + condition: FUNCTION() => received_messages.length >= 1, + interval: 200ms, + timeout: 10s +) + +serial = received_messages[0].serial + +# Append via realtime +append_result = AWAIT channel_a.appendMessage( + Message(serial: serial, data: "appended-data"), + operation: MessageOperation(description: "thread reply") +) + +# Wait for append event +poll_until( + condition: FUNCTION() => received_messages.length >= 2, + interval: 200ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +ASSERT append_result IS UpdateDeleteResult +ASSERT append_result.versionSerial IS String +ASSERT append_result.versionSerial.length > 0 + +# Client B received the append event +append_msg = received_messages[1] +ASSERT append_msg.action == MessageAction.MESSAGE_APPEND +ASSERT append_msg.data == "appended-data" +ASSERT append_msg.serial == serial +``` + +### Cleanup +```pseudo +AWAIT client_a.close() +AWAIT client_b.close() +``` + +--- + +## RTL32 — Full mutation lifecycle: update, append, delete observed in sequence + +**Test ID**: `realtime/integration/RTL32/full-mutation-lifecycle-3` + +**Spec requirement:** RTL32b1, RTL32d — all three mutation types delivered in order. + +Tests that a subscriber receives the complete sequence of mutation events +(create → update → append → delete) in the correct order with correct actions. + +### Setup +```pseudo +channel_name = "mutable:rt-lifecycle-" + random_id() + +client_a = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +client_a.connect() +client_b.connect() + +AWAIT_STATE client_a.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +AWAIT_STATE client_b.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +channel_a = client_a.channels.get(channel_name) +channel_b = client_b.channels.get(channel_name) + +AWAIT channel_b.attach() + +received_messages = [] +channel_b.subscribe((msg) => { + received_messages.append(msg) +}) + +AWAIT channel_a.attach() +``` + +### Test Steps +```pseudo +# 1. Publish original +AWAIT channel_a.publish(name: "lifecycle", data: "v1") + +poll_until( + condition: FUNCTION() => received_messages.length >= 1, + interval: 200ms, + timeout: 10s +) + +serial = received_messages[0].serial + +# 2. Update +AWAIT channel_a.updateMessage( + Message(serial: serial, name: "lifecycle", data: "v2"), + operation: MessageOperation(description: "edit 1") +) + +poll_until( + condition: FUNCTION() => received_messages.length >= 2, + interval: 200ms, + timeout: 10s +) + +# 3. Append +AWAIT channel_a.appendMessage( + Message(serial: serial, data: "reply-data"), + operation: MessageOperation(description: "thread reply") +) + +poll_until( + condition: FUNCTION() => received_messages.length >= 3, + interval: 200ms, + timeout: 10s +) + +# 4. Delete +AWAIT channel_a.deleteMessage(Message(serial: serial)) + +poll_until( + condition: FUNCTION() => received_messages.length >= 4, + interval: 200ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +ASSERT received_messages.length == 4 + +# Create +ASSERT received_messages[0].action == MessageAction.MESSAGE_CREATE +ASSERT received_messages[0].name == "lifecycle" +ASSERT received_messages[0].data == "v1" +ASSERT received_messages[0].serial == serial + +# Update +ASSERT received_messages[1].action == MessageAction.MESSAGE_UPDATE +ASSERT received_messages[1].name == "lifecycle" +ASSERT received_messages[1].data == "v2" +ASSERT received_messages[1].serial == serial + +# Append +ASSERT received_messages[2].action == MessageAction.MESSAGE_APPEND +ASSERT received_messages[2].data == "reply-data" +ASSERT received_messages[2].serial == serial + +# Delete +ASSERT received_messages[3].action == MessageAction.MESSAGE_DELETE +ASSERT received_messages[3].serial == serial +``` + +### Cleanup +```pseudo +AWAIT client_a.close() +AWAIT client_b.close() +``` + +--- + +## RTL28, RTL31 — getMessage and getMessageVersions from realtime channel + +**Test ID**: `realtime/integration/RTL28/get-message-and-versions-0` + +**Spec requirement:** RTL28 — `RealtimeChannel#getMessage` same as `RestChannel#getMessage`. +RTL31 — `RealtimeChannel#getMessageVersions` same as `RestChannel#getMessageVersions`. + +Tests that getMessage and getMessageVersions work when called on a RealtimeChannel +after publishing and updating a message via realtime. + +### Setup +```pseudo +channel_name = "mutable:rt-get-versions-" + random_id() + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +channel = client.channels.get(channel_name) +AWAIT channel.attach() + +# Use subscribe to capture the serial from the published message +received_messages = [] +channel.subscribe((msg) => { + received_messages.append(msg) +}) +``` + +### Test Steps +```pseudo +# Publish original +AWAIT channel.publish(name: "versioned", data: "v1") + +poll_until( + condition: FUNCTION() => received_messages.length >= 1, + interval: 200ms, + timeout: 10s +) + +serial = received_messages[0].serial + +# Update twice +AWAIT channel.updateMessage( + Message(serial: serial, data: "v2"), + operation: MessageOperation(description: "first edit") +) +AWAIT channel.updateMessage( + Message(serial: serial, data: "v3"), + operation: MessageOperation(description: "second edit") +) + +# Wait for propagation before HTTP-based reads +wait_for_propagation(2 seconds) + +# getMessage — should return latest version +msg = AWAIT channel.getMessage(serial) + +# getMessageVersions — should return version history +versions = AWAIT channel.getMessageVersions(serial) +``` + +### Assertions +```pseudo +# getMessage returns the latest state +ASSERT msg IS Message +ASSERT msg.serial == serial +ASSERT msg.data == "v3" +ASSERT msg.action == MessageAction.MESSAGE_UPDATE + +# getMessageVersions returns history +ASSERT versions IS PaginatedResult +ASSERT versions.items.length >= 3 # original + 2 updates + +FOR item IN versions.items: + ASSERT item IS Message + ASSERT item.serial == serial +``` + +### Cleanup +```pseudo +AWAIT client.close() +``` + +--- + +## RTAN1, RTAN2, RTAN4 — Annotation publish, subscribe, and delete via realtime + +**Test ID**: `realtime/integration/RTAN1/annotation-publish-delete-0` + +**Spec requirement:** RTAN1c — publish sends ANNOTATION ProtocolMessage. +RTAN2a — delete sends ANNOTATION_DELETE. RTAN4b — annotations delivered to subscribers. + +Tests that annotations published via a realtime channel are delivered in real-time +to a subscriber on a separate connection, and that annotation delete events are +also delivered. + +### Setup +```pseudo +channel_name = "mutable:rt-annotations-" + random_id() + +client_a = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +client_a.connect() +client_b.connect() + +AWAIT_STATE client_a.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +AWAIT_STATE client_b.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +channel_a = client_a.channels.get(channel_name, + options: ChannelOptions(modes: [PUBLISH, SUBSCRIBE, ANNOTATION_PUBLISH, ANNOTATION_SUBSCRIBE]) +) +channel_b = client_b.channels.get(channel_name, + options: ChannelOptions(modes: [SUBSCRIBE, ANNOTATION_SUBSCRIBE]) +) + +AWAIT channel_b.attach() + +# Subscribe to annotations on client B +received_annotations = [] +channel_b.annotations.subscribe((ann) => { + received_annotations.append(ann) +}) + +# Also subscribe to messages to capture the serial +received_messages = [] +channel_a.subscribe((msg) => { + received_messages.append(msg) +}) + +AWAIT channel_a.attach() +``` + +### Test Steps +```pseudo +# Publish a message to annotate +AWAIT channel_a.publish(name: "annotatable", data: "content") + +poll_until( + condition: FUNCTION() => received_messages.length >= 1, + interval: 200ms, + timeout: 10s +) + +serial = received_messages[0].serial + +# Publish an annotation via realtime +AWAIT channel_a.annotations.publish(serial, Annotation( + type: "com.ably.reactions", + name: "like" +)) + +# Wait for annotation to arrive on client B +poll_until( + condition: FUNCTION() => received_annotations.length >= 1, + interval: 200ms, + timeout: 10s +) + +# Delete the annotation via realtime +AWAIT channel_a.annotations.delete(serial, Annotation( + type: "com.ably.reactions", + name: "like" +)) + +# Wait for delete event on client B +poll_until( + condition: FUNCTION() => received_annotations.length >= 2, + interval: 200ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +ASSERT received_annotations.length == 2 + +# Create event +create_ann = received_annotations[0] +ASSERT create_ann.action == AnnotationAction.ANNOTATION_CREATE +ASSERT create_ann.type == "com.ably.reactions" +ASSERT create_ann.name == "like" +ASSERT create_ann.messageSerial == serial + +# Delete event +delete_ann = received_annotations[1] +ASSERT delete_ann.action == AnnotationAction.ANNOTATION_DELETE +ASSERT delete_ann.type == "com.ably.reactions" +ASSERT delete_ann.name == "like" +ASSERT delete_ann.messageSerial == serial +``` + +### Cleanup +```pseudo +AWAIT client_a.close() +AWAIT client_b.close() +``` + +--- + +## RTAN4c — Annotation subscribe with type filtering + +**Test ID**: `realtime/integration/RTAN4c/annotation-type-filtering-0` + +**Spec requirement:** RTAN4c — subscribe with a `type` filter delivers only +annotations whose type matches. + +Tests that a subscriber filtering by annotation type only receives matching +annotations when multiple types are published. + +### Setup +```pseudo +channel_name = "mutable:rt-ann-filter-" + random_id() + +client_a = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +client_a.connect() +client_b.connect() + +AWAIT_STATE client_a.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +AWAIT_STATE client_b.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +channel_a = client_a.channels.get(channel_name, + options: ChannelOptions(modes: [PUBLISH, SUBSCRIBE, ANNOTATION_PUBLISH, ANNOTATION_SUBSCRIBE]) +) +channel_b = client_b.channels.get(channel_name, + options: ChannelOptions(modes: [SUBSCRIBE, ANNOTATION_SUBSCRIBE]) +) + +AWAIT channel_b.attach() + +# Subscribe only to "com.ably.reactions" type +filtered_annotations = [] +channel_b.annotations.subscribe(type: "com.ably.reactions", (ann) => { + filtered_annotations.append(ann) +}) + +# Also subscribe to all annotations to know when all have been delivered +all_annotations = [] +channel_b.annotations.subscribe((ann) => { + all_annotations.append(ann) +}) + +received_messages = [] +channel_a.subscribe((msg) => { + received_messages.append(msg) +}) + +AWAIT channel_a.attach() +``` + +### Test Steps +```pseudo +# Publish a message +AWAIT channel_a.publish(name: "multi-type", data: "content") + +poll_until( + condition: FUNCTION() => received_messages.length >= 1, + interval: 200ms, + timeout: 10s +) + +serial = received_messages[0].serial + +# Publish annotations of different types +AWAIT channel_a.annotations.publish(serial, Annotation( + type: "com.ably.reactions", + name: "like" +)) +AWAIT channel_a.annotations.publish(serial, Annotation( + type: "com.example.comments", + name: "comment" +)) +AWAIT channel_a.annotations.publish(serial, Annotation( + type: "com.ably.reactions", + name: "heart" +)) + +# Wait for all 3 annotations to arrive on client B (unfiltered listener) +poll_until( + condition: FUNCTION() => all_annotations.length >= 3, + interval: 200ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +# Unfiltered listener got all 3 +ASSERT all_annotations.length == 3 + +# Filtered listener got only the 2 "com.ably.reactions" annotations +ASSERT filtered_annotations.length == 2 +ASSERT filtered_annotations[0].type == "com.ably.reactions" +ASSERT filtered_annotations[0].name == "like" +ASSERT filtered_annotations[1].type == "com.ably.reactions" +ASSERT filtered_annotations[1].name == "heart" +``` + +### Cleanup +```pseudo +AWAIT client_a.close() +AWAIT client_b.close() +``` + +--- + +## RTAN4d — Annotation subscribe implicitly attaches channel + +**Test ID**: `realtime/integration/RTAN4d/annotation-implicit-attach-0` + +**Spec requirement:** RTAN4d — subscribe has the same connection and channel state +preconditions as `RealtimeChannel#subscribe`, including implicit attach. + +Tests that calling `annotations.subscribe()` on a channel that is not attached +causes it to implicitly attach. + +### Setup +```pseudo +channel_name = "mutable:rt-ann-implicit-attach-" + random_id() + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +channel = client.channels.get(channel_name, + options: ChannelOptions(modes: [ANNOTATION_SUBSCRIBE]) +) +``` + +### Test Steps +```pseudo +# Channel should be initialized (not attached) +ASSERT channel.state == ChannelState.initialized + +# Subscribe to annotations — should trigger implicit attach +channel.annotations.subscribe((ann) => { + # no-op +}) + +# Wait for channel to become attached +AWAIT_STATE channel.state == ChannelState.attached + WITH timeout: 10 seconds +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +``` + +### Cleanup +```pseudo +AWAIT client.close() +``` diff --git a/uts/realtime/integration/presence/presence_sync_test.md b/uts/realtime/integration/presence/presence_sync_test.md new file mode 100644 index 000000000..e7f30ee62 --- /dev/null +++ b/uts/realtime/integration/presence/presence_sync_test.md @@ -0,0 +1,168 @@ +# Realtime Presence Sync Integration Tests + +Spec points: `RTP2`, `RTP11a` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +End-to-end verification that the presence SYNC protocol delivers the correct +member set to a client that attaches to a channel with existing presence members. + +The existing `presence_lifecycle_test.md` tests subscribe-time delivery of +enter/update/leave events. This test specifically targets the SYNC path: client B +attaches *after* client A has already entered, so B receives members via the +server-initiated SYNC rather than via real-time PRESENCE messages. + +## Sandbox Setup + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RTP2, RTP11a - Presence SYNC delivers existing members + +**Test ID**: `realtime/integration/RTP2/sync-delivers-members-0` + +| Spec | Requirement | +|------|-------------| +| RTP2 | A PresenceMap is maintained via SYNC | +| RTP11a | presence.get() returns the list of current members, waiting for SYNC to complete | + +**Spec requirement:** When client B attaches to a channel where client A is +already present, the server initiates a SYNC that delivers client A's presence +to client B. After SYNC completes, `presence.get()` returns the correct member list. + +### Setup +```pseudo +channel_name = "presence-sync-" + random_id() + +client_a = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + clientId: "sync-member-a", + autoConnect: false, + useBinaryProtocol: false +)) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps +```pseudo +# Connect client A and enter presence +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name) +AWAIT channel_a.attach() +AWAIT channel_a.presence.enter(data: "sync-data") + +# Now connect client B — client A is already present +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_b = client_b.channels.get(channel_name) +AWAIT channel_b.attach() + +# presence.get() waits for SYNC to complete (RTP11a) +members = AWAIT channel_b.presence.get() +``` + +### Assertions +```pseudo +ASSERT members.length == 1 +ASSERT members[0].clientId == "sync-member-a" +ASSERT members[0].data == "sync-data" +ASSERT members[0].action == PRESENT + +CLOSE_CLIENT(client_a) +CLOSE_CLIENT(client_b) +``` + +--- + +## RTP2 - Presence SYNC with multiple members + +**Test ID**: `realtime/integration/RTP2/sync-multiple-members-1` + +| Spec | Requirement | +|------|-------------| +| RTP2 | PresenceMap maintained via SYNC contains all present members | + +**Spec requirement:** When multiple members are present on a channel before +client B attaches, the SYNC delivers all of them. + +### Setup +```pseudo +channel_name = "presence-sync-multi-" + random_id() +member_count = 10 + +client_a = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: false +)) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps +```pseudo +# Connect client A and enter multiple members +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name) +AWAIT channel_a.attach() + +FOR i IN 0..member_count-1: + AWAIT channel_a.presence.enterClient("sync-user-" + i, data: "data-" + i) + +# Now connect client B +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_b = client_b.channels.get(channel_name) +AWAIT channel_b.attach() + +# presence.get() waits for SYNC to complete +members = AWAIT channel_b.presence.get() +``` + +### Assertions +```pseudo +ASSERT members.length == member_count + +FOR i IN 0..member_count-1: + member = members.find(m => m.clientId == "sync-user-" + i) + ASSERT member IS NOT NULL + ASSERT member.data == "data-" + i + +CLOSE_CLIENT(client_a) +CLOSE_CLIENT(client_b) +``` diff --git a/uts/realtime/integration/presence_lifecycle_test.md b/uts/realtime/integration/presence_lifecycle_test.md new file mode 100644 index 000000000..834805cd0 --- /dev/null +++ b/uts/realtime/integration/presence_lifecycle_test.md @@ -0,0 +1,257 @@ +# Realtime Presence Lifecycle Integration Tests + +Spec points: `RTP4`, `RTP6`, `RTP8`, `RTP9`, `RTP10`, `RTP11a` + +## Test Type +Integration test against Ably sandbox + +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + +## Purpose + +End-to-end verification of the realtime presence lifecycle using two connections +against the Ably sandbox. Client A enters/updates/leaves members, Client B observes +presence events via subscribe and verifies member state via get(). + +These tests complement the unit tests by verifying that the real server correctly +broadcasts presence events, delivers SYNC data, and maintains presence state. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. + +**Note:** `useBinaryProtocol: PROTOCOL == "msgpack"` is used so tests run with both protocols (see Protocol Variants). + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RTP4, RTP6, RTP11a - Bulk enterClient observed on different connection + +**Test ID**: `realtime/integration/RTP4/bulk-enter-observed-0` + +**Spec requirement:** Enter multiple members on connection A, verify they are observed +on connection B via subscribe (RTP6) and get() after sync (RTP11a). This is the +integration equivalent of the RTP4 unit test. + +Note: The spec says 250 but we use 50 as a practical test size that validates the +same behavior without excessive test runtime. + +### Setup +```pseudo +channel_name = "presence-bulk-" + random_id() +member_count = 50 + +client_a = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) +``` + +### Test Steps +```pseudo +# Connect both clients +client_a.connect() +AWAIT_STATE client_a.connection.state == ConnectionState.connected + +client_b.connect() +AWAIT_STATE client_b.connection.state == ConnectionState.connected + +# Attach both to the channel +channel_a = client_a.channels.get(channel_name) +channel_b = client_b.channels.get(channel_name) +AWAIT channel_b.attach() + +# Subscribe on client B before client A enters +received_enters = [] +channel_b.presence.subscribe(action: ENTER, (event) => { + received_enters.append(event) +}) + +# Attach client A (after B is attached and subscribed) +AWAIT channel_a.attach() + +# Client A enters members in parallel +futures = [] +FOR i IN 0..member_count-1: + futures.append(channel_a.presence.enterClient("user-${i}", data: "data-${i}")) +AWAIT_ALL futures + +# Wait for client B to receive all ENTER events +poll_until( + condition: FUNCTION() => received_enters.length >= member_count, + interval: 200ms, + timeout: 15s +) + +# Client B gets all members +members = AWAIT channel_b.presence.get() +``` + +### Assertions +```pseudo +# Client B received all ENTER events via subscribe +ASSERT received_enters.length == member_count + +# All members present via get() +ASSERT members.length == member_count + +# Verify each member has correct clientId and data +FOR i IN 0..member_count-1: + member = members.find(m => m.clientId == "user-${i}") + ASSERT member IS NOT null + ASSERT member.data == "data-${i}" +``` + +### Cleanup +```pseudo +AWAIT client_a.close() +AWAIT client_b.close() +``` + +--- + +## RTP8, RTP9, RTP10 - Enter, update, leave lifecycle + +**Test ID**: `realtime/integration/RTP8/enter-update-leave-lifecycle-0` + +**Spec requirement:** Verify the complete presence lifecycle: enter populates the +presence set (RTP8), update modifies the data (RTP9), and leave removes the member +(RTP10). All transitions are observed on a separate connection. + +### Setup +```pseudo +channel_name = "presence-lifecycle-" + random_id() + +client_a = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + clientId: "lifecycle-client", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) +``` + +### Test Steps +```pseudo +# Connect and attach both clients +client_a.connect() +AWAIT_STATE client_a.connection.state == ConnectionState.connected +client_b.connect() +AWAIT_STATE client_b.connection.state == ConnectionState.connected + +channel_a = client_a.channels.get(channel_name) +channel_b = client_b.channels.get(channel_name) +AWAIT channel_b.attach() + +# Collect all presence events on client B +all_events = [] +channel_b.presence.subscribe((event) => { + all_events.append(event) +}) + +AWAIT channel_a.attach() + +# --- Phase 1: Enter --- +AWAIT channel_a.presence.enter(data: "hello") + +# Wait for ENTER event on client B +poll_until( + condition: FUNCTION() => all_events.length >= 1, + interval: 200ms, + timeout: 10s +) + +# Verify member is present via get() +members_after_enter = AWAIT channel_b.presence.get() +ASSERT members_after_enter.length == 1 +ASSERT members_after_enter[0].clientId == "lifecycle-client" +ASSERT members_after_enter[0].data == "hello" + +# --- Phase 2: Update --- +AWAIT channel_a.presence.update(data: "world") + +# Wait for UPDATE event on client B +poll_until( + condition: FUNCTION() => all_events.length >= 2, + interval: 200ms, + timeout: 10s +) + +# Verify member data updated via get() +members_after_update = AWAIT channel_b.presence.get() +ASSERT members_after_update.length == 1 +ASSERT members_after_update[0].data == "world" + +# --- Phase 3: Leave --- +AWAIT channel_a.presence.leave(data: "goodbye") + +# Wait for LEAVE event on client B +poll_until( + condition: FUNCTION() => all_events.length >= 3, + interval: 200ms, + timeout: 10s +) + +# Verify member is gone via get() +members_after_leave = AWAIT channel_b.presence.get() +ASSERT members_after_leave.length == 0 +``` + +### Assertions +```pseudo +# Verify the sequence of events +ASSERT all_events.length >= 3 + +enter_event = all_events[0] +ASSERT enter_event.action == ENTER +ASSERT enter_event.clientId == "lifecycle-client" +ASSERT enter_event.data == "hello" + +update_event = all_events[1] +ASSERT update_event.action == UPDATE +ASSERT update_event.clientId == "lifecycle-client" +ASSERT update_event.data == "world" + +leave_event = all_events[2] +ASSERT leave_event.action == LEAVE +ASSERT leave_event.clientId == "lifecycle-client" +ASSERT leave_event.data == "goodbye" +``` + +### Cleanup +```pseudo +AWAIT client_a.close() +AWAIT client_b.close() +``` diff --git a/uts/realtime/integration/proxy/auth_reauth.md b/uts/realtime/integration/proxy/auth_reauth.md new file mode 100644 index 000000000..edb4d9894 --- /dev/null +++ b/uts/realtime/integration/proxy/auth_reauth.md @@ -0,0 +1,191 @@ +# Auth Re-authorization Proxy Integration Tests + +Spec points: `RTN22`, `RTC8a` + +## Test Type + +Proxy integration test against Ably Sandbox endpoint. + +Uses the programmable proxy (`uts/test/proxy/`) to inject transport-level faults while the SDK communicates with the real Ably backend. See `uts/test/realtime/integration/helpers/proxy.md` for proxy infrastructure details. + +Corresponding unit tests: +- `uts/test/realtime/unit/connection/server_initiated_reauth_test.md` (RTN22, RTN22a) +- `uts/test/realtime/unit/auth/realtime_authorize.md` (RTC8a, RTC8a1) +- `uts/test/realtime/unit/auth/connection_auth_test.md` (RSA4c3 covers RTN22 reauth failure while CONNECTED) + +## Sandbox Setup + +```pseudo +BEFORE ALL TESTS: + # Provision test app + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + # Clean up test app + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## Test 26: RTN22/RTC8a -- Server-initiated re-authentication + +**Test ID**: `realtime/proxy/RTN22/server-initiated-reauth-0` + +| Spec | Requirement | +|------|-------------| +| RTN22 | Ably can request that a connected client re-authenticates by sending an AUTH ProtocolMessage. The client must then immediately start a new authentication process. | +| RTC8a | If the connection is CONNECTED and Ably requests re-authentication, the client must obtain a new token, then send an AUTH ProtocolMessage to Ably with an auth attribute containing an AuthDetails object with the token string. | + +Tests that when the proxy injects a server-initiated AUTH ProtocolMessage (action 17) into an established connection, the SDK re-authenticates via the authCallback and sends an AUTH message back to the server, all while remaining CONNECTED. + +**Unit test counterpart:** `server_initiated_reauth_test.md` > RTN22 + +### Setup + +**Proxy rules:** None (passthrough). The AUTH injection is triggered imperatively after the SDK connects. + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + rules: [] +) +``` + +**SDK config:** Use authCallback so re-authentication can be observed. The callback generates a JWT token from the sandbox key parts. + +```pseudo +auth_callback_count = 0 +key_name, key_secret = get_key_parts(api_key) + +auth_callback = FUNCTION(params, callback): + auth_callback_count = auth_callback_count + 1 + # Generate a JWT token signed with the sandbox key + jwt = generate_jwt(key_name: key_name, key_secret: key_secret) + callback(null, jwt) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect through proxy +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s + +# Record identity and auth state before injection +original_connection_id = client.connection.id +original_auth_callback_count = auth_callback_count +ASSERT original_connection_id IS NOT null +ASSERT original_auth_callback_count >= 1 + +# Record state changes from this point +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +# Inject a server-initiated AUTH ProtocolMessage (action 17) +# This simulates Ably requesting re-authentication +AWAIT session.triggerAction({ + type: "inject_to_client", + message: { action: 17 } +}) + +# Wait for the SDK to process the AUTH and send its response +# The authCallback should be invoked, and the SDK should send AUTH back. +# Allow time for the token request round-trip to the sandbox. +AWAIT pollUntil( + CONDITION: auth_callback_count > original_auth_callback_count, + timeout: 15s +) +``` + +### Assertions + +```pseudo +# authCallback was called again (re-authentication triggered) +ASSERT auth_callback_count == original_auth_callback_count + 1 + +# Connection remains CONNECTED (re-auth does not disrupt the connection) +ASSERT client.connection.state == ConnectionState.connected + +# Connection ID is unchanged (no reconnection occurred) +ASSERT client.connection.id == original_connection_id + +# No state transitions away from CONNECTED occurred +non_connected_changes = state_changes.filter( + s => s != "connected" +) +ASSERT non_connected_changes.length == 0 + +# Proxy log shows the SDK sent an AUTH frame (action 17) from client to server +log = AWAIT session.getLog() +client_auth_frames = log.filter( + e => e.type == "ws_frame" + AND e.direction == "client_to_server" + AND (e.message.action == 17 OR e.message.action == "AUTH") + AND e.message.auth IS NOT null +) +ASSERT client_auth_frames.length >= 1 +``` + +### Cleanup + +```pseudo +client.connection.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10s +session.close() +``` + +### Note + +After the SDK sends the AUTH response, the server may respond with a CONNECTED message (connection update per RTN24). However, since the injected AUTH was not a genuine server request (it was injected by the proxy), the real Ably server may not respond as expected. The key assertions are that the SDK's auth machinery was triggered (authCallback invoked, AUTH frame sent) and that the connection was not disrupted. + +--- + +## Integration Test Notes + +### Timeout Handling + +All `AWAIT_STATE` calls use generous timeouts since real network traffic through the proxy is involved: +- Initial CONNECTED: 15 seconds (auth + transport setup through proxy) +- Auth callback re-invocation: 15 seconds (allows for token request round-trip to sandbox) +- CLOSED (cleanup): 10 seconds + +### Error Handling + +If any test fails to reach an expected state: +- Log the connection `errorReason` +- Log all recorded `state_changes` +- Retrieve and log the proxy session event log via `session.get_log()` +- Fail with diagnostic information + +### Cleanup + +Always clean up both the SDK client and the proxy session: + +```pseudo +AFTER EACH TEST: + IF client IS NOT null AND client.connection.state IN [connected, connecting, disconnected]: + client.connection.close() + AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10s + IF session IS NOT null: + session.close() +``` diff --git a/uts/realtime/integration/proxy/channel_faults.md b/uts/realtime/integration/proxy/channel_faults.md new file mode 100644 index 000000000..35b2da0fd --- /dev/null +++ b/uts/realtime/integration/proxy/channel_faults.md @@ -0,0 +1,801 @@ +# Channel Fault Proxy Integration Tests + +Spec points: `RTL4f`, `RTL5f`, `RTL13a`, `RTL14`, `RTL12`, `RTL3d` + +## Test Type + +Proxy integration test against Ably Sandbox endpoint + +## Proxy Infrastructure + +See `uts/test/realtime/integration/helpers/proxy.md` for the full proxy infrastructure specification. + +## Corresponding Unit Tests + +- `uts/test/realtime/unit/channels/channel_attach.md` -- RTL4f (attach timeout) +- `uts/test/realtime/unit/channels/channel_detach.md` -- RTL5f (detach timeout) +- `uts/test/realtime/unit/channels/channel_server_initiated_detach.md` -- RTL13a (unsolicited DETACHED triggers reattach) +- `uts/test/realtime/unit/channels/channel_error.md` -- RTL14 (channel ERROR transitions to FAILED) + +## Sandbox Setup + +Tests run against the Ably Sandbox via a programmable proxy. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + # Provision test app + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + # Clean up test app + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Common Cleanup + +```pseudo +AFTER EACH TEST: + IF client IS NOT null AND client.connection.state IN [connected, connecting, disconnected]: + client.connection.close() + AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10 seconds + IF session IS NOT null: + session.close() +``` + +--- + +## Test 13: RTL4f -- Attach timeout (server doesn't respond) + +**Test ID**: `realtime/proxy/RTL4f/attach-timeout-suppressed-0` + +| Spec | Requirement | +|------|-------------| +| RTL4f | If an ATTACHED ProtocolMessage is not received within realtimeRequestTimeout, the attach request should be treated as though it has failed and the channel should transition to the SUSPENDED state | + +Tests that when the proxy suppresses the client's ATTACH message so the server never sees it, the SDK's attach timer fires and the channel transitions to SUSPENDED. This verifies the same behaviour as the unit test but with a real Ably connection and real clock timing. + +### Setup + +```pseudo +channel_name = "test-RTL4f-${random_id()}" + +# Create proxy session that suppresses ATTACH messages for our channel +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "ws_frame_to_server", "action": "ATTACH", "channel": channel_name }, + "action": { "type": "suppress" }, + "comment": "RTL4f: Suppress ATTACH so server never responds" + }] +) + +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + realtimeRequestTimeout: 3000 +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps + +```pseudo +# Record channel state changes for sequence verification +channel_state_changes = [] +channel.on((change) => { + channel_state_changes.append(change.current) +}) + +# Connect through proxy -- connection itself is not faulted +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15 seconds + +# Start attach -- proxy will suppress the ATTACH, so server never responds +attach_future = channel.attach() + +# Channel should enter ATTACHING immediately +AWAIT_STATE channel.state == ChannelState.attaching + WITH timeout: 5 seconds + +# Wait for the channel to transition to SUSPENDED after realtimeRequestTimeout +AWAIT_STATE channel.state == ChannelState.suspended + WITH timeout: 15 seconds + +# The attach() call should have failed with a timeout error +AWAIT attach_future FAILS WITH error +``` + +### Assertions + +```pseudo +# Channel transitioned to SUSPENDED +ASSERT channel.state == ChannelState.suspended + +# Error indicates timeout +ASSERT error IS NOT null + +# State sequence: ATTACHING -> SUSPENDED +ASSERT channel_state_changes CONTAINS_IN_ORDER [ + ChannelState.attaching, + ChannelState.suspended +] + +# Connection remains CONNECTED (attach timeout is channel-scoped) +ASSERT client.connection.state == ConnectionState.connected + +# Proxy log confirms the ATTACH frames were received but suppressed +# Note: The proxy logs frames before applying rules, so suppressed frames still +# appear in the log with `ruleMatched` set. +log = session.get_log() +attach_frames = log.filter(e => + e.type == "ws_frame" AND + e.direction == "client_to_server" AND + e.message.action == 10 AND + e.message.channel == channel_name +) +ASSERT attach_frames.length >= 1 +# All ATTACH frames were caught by the suppress rule +FOR frame IN attach_frames: + ASSERT frame.ruleMatched IS NOT null +``` + +--- + +## Test 14: RTL14 -- Server responds with ERROR to ATTACH + +**Test ID**: `realtime/proxy/RTL14/error-on-attach-0` + +| Spec | Requirement | +|------|-------------| +| RTL14 | If an ERROR ProtocolMessage is received for this channel, the channel should immediately transition to the FAILED state and the RealtimeChannel.errorReason should be set | + +Tests that when the proxy replaces the server's ATTACHED response with a channel-scoped ERROR, the SDK transitions the channel to FAILED with the injected error. The connection should remain CONNECTED. + +### Setup + +```pseudo +channel_name = "test-RTL14-error-on-attach-${random_id()}" + +# Create proxy session that replaces ATTACHED with channel ERROR +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "ws_frame_to_client", "action": "ATTACHED", "channel": channel_name }, + "action": { + "type": "replace", + "message": { + "action": 9, + "channel": channel_name, + "error": { "code": 40160, "statusCode": 403, "message": "Not permitted" } + } + }, + "times": 1, + "comment": "RTL14: Replace ATTACHED with channel ERROR" + }] +) + +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps + +```pseudo +# Record channel state changes for sequence verification +channel_state_changes = [] +channel.on((change) => { + channel_state_changes.append(change.current) +}) + +# Connect through proxy +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15 seconds + +# Attach -- proxy replaces ATTACHED with ERROR +AWAIT channel.attach() FAILS WITH error + +# Channel should be in FAILED state +AWAIT_STATE channel.state == ChannelState.failed + WITH timeout: 10 seconds +``` + +### Assertions + +```pseudo +# Channel transitioned to FAILED +ASSERT channel.state == ChannelState.failed + +# Error reason matches the injected error +ASSERT channel.errorReason IS NOT null +ASSERT channel.errorReason.code == 40160 +ASSERT channel.errorReason.statusCode == 403 + +# The error returned from attach() matches +ASSERT error IS NOT null +ASSERT error.code == 40160 + +# State sequence: ATTACHING -> FAILED +ASSERT channel_state_changes CONTAINS_IN_ORDER [ + ChannelState.attaching, + ChannelState.failed +] + +# Connection remains CONNECTED (channel error does not affect connection) +ASSERT client.connection.state == ConnectionState.connected +``` + +--- + +## Test 15: RTL5f -- Detach timeout (server doesn't respond) + +**Test ID**: `realtime/proxy/RTL5f/detach-timeout-suppressed-0` + +| Spec | Requirement | +|------|-------------| +| RTL5f | If a DETACHED ProtocolMessage is not received within realtimeRequestTimeout, the detach request should be treated as though it has failed and the channel will return to its previous state | + +Tests that when the channel is attached normally and then the proxy suppresses the DETACH message, the SDK's detach timer fires and the channel reverts to ATTACHED. This requires a two-phase proxy configuration: first allow normal attach, then add a rule to suppress DETACH. + +### Setup + +```pseudo +channel_name = "test-RTL5f-${random_id()}" + +# Phase 1: Create proxy session with NO fault rules (clean passthrough) +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [] +) + +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + realtimeRequestTimeout: 3000 +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps + +```pseudo +# Record channel state changes for sequence verification +channel_state_changes = [] +channel.on((change) => { + channel_state_changes.append(change.current) +}) + +# Phase 1: Connect and attach normally through proxy +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15 seconds + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Clear state change history from the attach phase +channel_state_changes.clear() + +# Phase 2: Add rule to suppress DETACH messages +session.add_rules([{ + "match": { "type": "ws_frame_to_server", "action": "DETACH", "channel": channel_name }, + "action": { "type": "suppress" }, + "comment": "RTL5f: Suppress DETACH so server never responds" +}], position: "prepend") + +# Phase 3: Try to detach -- proxy suppresses DETACH, so server never sends DETACHED +detach_future = channel.detach() + +# Channel should enter DETACHING +AWAIT_STATE channel.state == ChannelState.detaching + WITH timeout: 5 seconds + +# Wait for the channel to revert to ATTACHED after realtimeRequestTimeout +AWAIT_STATE channel.state == ChannelState.attached + WITH timeout: 15 seconds + +# The detach() call should have failed with a timeout error +AWAIT detach_future FAILS WITH error +``` + +### Assertions + +```pseudo +# Channel reverted to ATTACHED (previous state) +ASSERT channel.state == ChannelState.attached + +# Error indicates timeout +ASSERT error IS NOT null + +# State sequence: DETACHING -> ATTACHED (revert) +ASSERT channel_state_changes CONTAINS_IN_ORDER [ + ChannelState.detaching, + ChannelState.attached +] + +# Connection remains CONNECTED +ASSERT client.connection.state == ConnectionState.connected +``` + +--- + +## Test 16: RTL13a -- Server sends unsolicited DETACHED, channel re-attaches + +**Test ID**: `realtime/proxy/RTL13a/unsolicited-detach-reattach-0` + +| Spec | Requirement | +|------|-------------| +| RTL13a | If the channel is ATTACHED and receives a server-initiated DETACHED, an immediate reattach attempt should be made by sending ATTACH, transitioning to ATTACHING with the error from the DETACHED message | + +Tests that when the proxy injects an unsolicited DETACHED message for an attached channel, the SDK automatically re-attaches. The proxy passes all normal traffic through, and the re-attach ATTACH/ATTACHED exchange completes against the real server. + +### Setup + +```pseudo +channel_name = "test-RTL13a-${random_id()}" + +# Create proxy session with clean passthrough (no fault rules initially) +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [] +) + +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps + +```pseudo +# Connect and attach normally through proxy +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15 seconds + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Record channel state changes from this point +channel_state_changes = [] +channel.on((change) => { + channel_state_changes.append(change.current) +}) + +# Inject an unsolicited DETACHED message with error via imperative action +session.trigger_action({ + type: "inject_to_client", + message: { + "action": 13, + "channel": channel_name, + "error": { "code": 90198, "statusCode": 500, "message": "Channel detached by server" } + } +}) + +# Channel should transition ATTACHING (reattach) -> ATTACHED (reattach succeeds) +AWAIT_STATE channel.state == ChannelState.attached + WITH timeout: 15 seconds +``` + +### Assertions + +```pseudo +# Channel re-attached successfully +ASSERT channel.state == ChannelState.attached + +# State sequence: ATTACHING (with error from DETACHED) -> ATTACHED +ASSERT channel_state_changes CONTAINS_IN_ORDER [ + ChannelState.attaching, + ChannelState.attached +] + +# Connection remains CONNECTED throughout +ASSERT client.connection.state == ConnectionState.connected + +# Proxy log shows the re-attach ATTACH message from the client +log = session.get_log() +attach_frames = log.filter(e => + e.type == "ws_frame" AND + e.direction == "client_to_server" AND + e.message.action == 10 AND + e.message.channel == channel_name +) +# At least 2 ATTACH frames: initial attach + reattach after injected DETACHED +ASSERT attach_frames.length >= 2 +``` + +--- + +## Test 17: RTL14 -- Server sends channel ERROR, channel goes FAILED + +**Test ID**: `realtime/proxy/RTL14/channel-error-goes-failed-1` + +| Spec | Requirement | +|------|-------------| +| RTL14 | If an ERROR ProtocolMessage is received for this channel, the channel should immediately transition to the FAILED state, and the RealtimeChannel.errorReason should be set | + +Tests that when the proxy injects a channel-scoped ERROR message for an attached channel, the SDK transitions the channel to FAILED. The connection should remain CONNECTED because the error is channel-scoped, not connection-scoped. + +### Setup + +```pseudo +channel_name = "test-RTL14-${random_id()}" + +# Create proxy session with clean passthrough +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [] +) + +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps + +```pseudo +# Connect and attach normally through proxy +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15 seconds + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Record channel state changes from this point +channel_state_changes = [] +channel.on((change) => { + channel_state_changes.append(change.current) +}) + +# Inject a channel-scoped ERROR message via imperative action +session.trigger_action({ + type: "inject_to_client", + message: { + "action": 9, + "channel": channel_name, + "error": { "code": 40160, "statusCode": 403, "message": "Not permitted" } + } +}) + +# Channel should transition to FAILED +AWAIT_STATE channel.state == ChannelState.failed + WITH timeout: 10 seconds +``` + +### Assertions + +```pseudo +# Channel transitioned to FAILED +ASSERT channel.state == ChannelState.failed + +# errorReason is set from the injected ERROR +ASSERT channel.errorReason IS NOT null +ASSERT channel.errorReason.code == 40160 +ASSERT channel.errorReason.statusCode == 403 +ASSERT channel.errorReason.message CONTAINS "Not permitted" + +# State change event shows ATTACHED -> FAILED +ASSERT channel_state_changes CONTAINS_IN_ORDER [ + ChannelState.failed +] +ASSERT length(channel_state_changes) == 1 + +# Connection remains CONNECTED (channel-scoped ERROR does not close connection) +ASSERT client.connection.state == ConnectionState.connected +``` + +--- + +## Test 24: RTL12 -- ATTACHED with resumed=false on already-attached channel + +**Test ID**: `realtime/proxy/RTL12/attached-non-resumed-update-0` + +| Spec | Requirement | +|------|-------------| +| RTL12 | An attached channel may receive an additional ATTACHED ProtocolMessage from Ably at any point. If and only if the resumed flag is false, this should result in the channel emitting an UPDATE event with a ChannelStateChange object. The ChannelStateChange should have both previous and current set to "attached", the reason set to the error from the ATTACHED message (if any), and the resumed attribute set per the RESUMED bitflag. The library must NOT emit an ATTACHED event (RTL2g) | + +Tests that when the proxy injects an ATTACHED message with resumed=false (RESUMED bit not set) for an already-attached channel, the SDK emits an UPDATE event (not ATTACHED) with a ChannelStateChange reflecting current=attached, previous=attached, resumed=false, and the injected error reason. The channel remains attached throughout. + +### Setup + +```pseudo +channel_name = "test-RTL12-${random_id()}" + +# Create proxy session with clean passthrough +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [] +) + +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps + +```pseudo +# Connect and attach normally through proxy +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15 seconds + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Listen for both 'update' and 'attached' events +update_events = [] +attached_events = [] +channel.on("update", (change) => { + update_events.append(change) +}) +channel.on("attached", (change) => { + attached_events.append(change) +}) + +# Inject an ATTACHED message with resumed=false and an error via imperative action +session.trigger_action({ + type: "inject_to_client", + message: { + "action": 11, + "channel": channel_name, + "flags": 0, + "error": { "code": 91001, "statusCode": 500, "message": "Continuity lost" } + } +}) + +# Wait for the update event to be emitted +poll_until( + condition: () => update_events.length >= 1, + timeout: 10 seconds +) +``` + +### Assertions + +```pseudo +# Channel emitted an UPDATE event +ASSERT update_events.length == 1 + +# The ChannelStateChange has correct fields +ASSERT update_events[0].current == ChannelState.attached +ASSERT update_events[0].previous == ChannelState.attached +ASSERT update_events[0].resumed == false +ASSERT update_events[0].reason IS NOT null +ASSERT update_events[0].reason.code == 91001 +ASSERT update_events[0].reason.statusCode == 500 +ASSERT update_events[0].reason.message CONTAINS "Continuity lost" + +# No 'attached' event was emitted (RTL2g) +ASSERT attached_events.length == 0 + +# Channel state remains ATTACHED +ASSERT channel.state == ChannelState.attached + +# Connection remains CONNECTED +ASSERT client.connection.state == ConnectionState.connected +``` + +--- + +## Test 25: RTL3d -- Channels reattach after connection recovery + +**Test ID**: `realtime/proxy/RTL3d/channels-reattach-on-reconnect-0` + +| Spec | Requirement | +|------|-------------| +| RTL3d | If the connection state enters the CONNECTED state, any channels in the ATTACHING, ATTACHED, or SUSPENDED states should be transitioned to ATTACHING and initiate an attach sequence. The connection should also process any queued messages immediately | + +Tests that when two attached channels experience a connection disconnect and subsequent reconnection, both channels automatically transition through ATTACHING and back to ATTACHED. The proxy logs confirm re-attach ATTACH messages are sent on the second WebSocket connection. + +### Setup + +```pseudo +channel_a_name = "test-RTL3d-a-${random_id()}" +channel_b_name = "test-RTL3d-b-${random_id()}" + +# Create proxy session with clean passthrough +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [] +) + +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel_a = client.channels.get(channel_a_name) +channel_b = client.channels.get(channel_b_name) +``` + +### Test Steps + +```pseudo +# Connect and attach both channels normally through proxy +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15 seconds + +AWAIT channel_a.attach() +AWAIT channel_b.attach() +ASSERT channel_a.state == ChannelState.attached +ASSERT channel_b.state == ChannelState.attached + +# Record channel state changes from this point +channel_a_state_changes = [] +channel_b_state_changes = [] +channel_a.on((change) => { + channel_a_state_changes.append(change.current) +}) +channel_b.on((change) => { + channel_b_state_changes.append(change.current) +}) + +# Trigger disconnect via imperative action (close the WebSocket) +session.trigger_action({ + type: "close" +}) + +# Wait for connection to reach DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 10 seconds + +# Wait for connection to recover to CONNECTED +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 30 seconds + +# Wait for both channels to re-attach +AWAIT_STATE channel_a.state == ChannelState.attached + WITH timeout: 15 seconds +AWAIT_STATE channel_b.state == ChannelState.attached + WITH timeout: 15 seconds +``` + +### Assertions + +```pseudo +# Both channels end in ATTACHED state +ASSERT channel_a.state == ChannelState.attached +ASSERT channel_b.state == ChannelState.attached + +# Both channels transitioned through ATTACHING -> ATTACHED after reconnection +ASSERT channel_a_state_changes CONTAINS_IN_ORDER [ + ChannelState.attaching, + ChannelState.attached +] +ASSERT channel_b_state_changes CONTAINS_IN_ORDER [ + ChannelState.attaching, + ChannelState.attached +] + +# Connection is CONNECTED +ASSERT client.connection.state == ConnectionState.connected + +# Proxy log shows ATTACH messages for both channels on the second WS connection +log = session.get_log() +attach_frames_a = log.filter(e => + e.type == "ws_frame" AND + e.direction == "client_to_server" AND + e.message.action == 10 AND + e.message.channel == channel_a_name +) +attach_frames_b = log.filter(e => + e.type == "ws_frame" AND + e.direction == "client_to_server" AND + e.message.action == 10 AND + e.message.channel == channel_b_name +) +# At least 2 ATTACH frames each: initial attach + reattach after reconnection +ASSERT attach_frames_a.length >= 2 +ASSERT attach_frames_b.length >= 2 +``` + +--- + +## Integration Test Notes + +### Why Proxy Tests vs Unit Tests + +These tests verify the same spec points as the unit tests, but provide higher confidence because: + +1. **Real WebSocket connections** -- the SDK's actual transport layer is exercised +2. **Real Ably protocol** -- the proxy modifies real server responses, not synthetic mocks +3. **Real timing** -- timeout behaviour (RTL4f, RTL5f) is tested with actual clocks, not fake timers +4. **Real server interaction** -- the reattach in RTL13a completes against the live sandbox, verifying the full round-trip + +### Timeout Handling + +All `AWAIT_STATE` calls use generous timeouts because real network traffic is involved: +- Connection to CONNECTED via proxy: 15 seconds +- Channel state transitions with real server: 15 seconds +- Timeout-based transitions (RTL4f, RTL5f): realtimeRequestTimeout + 12 seconds headroom +- Cleanup close: 10 seconds + +### Channel Names + +Each test uses a unique channel name with a random component (`${random_id()}`) to avoid interference between tests running in the same sandbox app. + +### Two-Phase Proxy Configuration + +Test 15 (RTL5f) uses a two-phase approach: +1. Start with clean passthrough rules to allow normal connection and attach +2. Dynamically add fault rules via `session.add_rules()` before the detach attempt + +This avoids needing separate proxy sessions for the attach and detach phases. diff --git a/uts/realtime/integration/proxy/connection_open_failures.md b/uts/realtime/integration/proxy/connection_open_failures.md new file mode 100644 index 000000000..0c79673db --- /dev/null +++ b/uts/realtime/integration/proxy/connection_open_failures.md @@ -0,0 +1,505 @@ +# Connection Opening Failures — Proxy Integration Tests + +Spec points: `RTN14a`, `RTN14b`, `RTN14c`, `RTN14d`, `RTN14g` + +## Test Type + +Proxy integration test against Ably Sandbox endpoint + +## Proxy Infrastructure + +See `uts/test/realtime/integration/helpers/proxy.md` for the full proxy infrastructure specification. + +## Related Unit Tests + +See `uts/test/realtime/unit/connection/connection_open_failures_test.md` for the corresponding unit tests that verify the same spec points with mocked WebSocket. + +## Sandbox Setup + +Tests run against the Ably Sandbox via a programmable proxy. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + # Provision test app + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + # Clean up test app + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Common Cleanup + +```pseudo +AFTER EACH TEST: + IF client IS NOT null AND client.connection.state IN [connected, connecting, disconnected]: + client.connection.close() + AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10 seconds + IF session IS NOT null: + session.close() +``` + +--- + +## RTN14a — Fatal error during connection open causes FAILED + +**Test ID**: `realtime/proxy/RTN14a/fatal-connect-error-0` + +| Spec | Requirement | +|------|-------------| +| RTN14a | If the connection attempt encounters a fatal error (non-token error), the connection transitions to FAILED | + +Tests that when the server responds with a fatal ERROR (non-token error code) during connection open, the SDK transitions to FAILED and sets errorReason. This verifies the same behaviour as the unit test but against the real Ably sandbox with fault injection. + +### Setup + +```pseudo +# Create proxy session that replaces the first CONNECTED with a fatal ERROR +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, + "action": { + "type": "replace", + "message": { + "action": 9, + "error": { "code": 40005, "statusCode": 400, "message": "Invalid key" } + } + }, + "times": 1, + "comment": "RTN14a: Replace CONNECTED with fatal ERROR" + }] +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Record state changes for sequence verification +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +# Start connection +client.connect() + +# Wait for FAILED state +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 15 seconds +``` + +### Assertions + +```pseudo +# Connection transitioned to FAILED +ASSERT client.connection.state == ConnectionState.failed + +# Error reason is set from the injected ERROR message +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 40005 +ASSERT client.connection.errorReason.statusCode == 400 + +# State sequence includes CONNECTING -> FAILED +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.failed +] + +# Connection ID/key not set (never received real CONNECTED) +ASSERT client.connection.id IS null +ASSERT client.connection.key IS null +``` + +--- + +## RTN14b — Token error during connection, SDK renews and reconnects + +**Test ID**: `realtime/proxy/RTN14b/token-error-renew-reconnect-0` + +| Spec | Requirement | +|------|-------------| +| RTN14b | If a token error (40140-40149) occurs during connection and the token is renewable, attempt to obtain a new token and retry | + +Tests that when the server responds with a token error during the first connection attempt, the SDK renews the token via authCallback and successfully connects on the second attempt. The proxy intercepts only the first CONNECTED, replacing it with a 40142 error; the second attempt passes through. + +### Setup + +```pseudo +# Track authCallback invocations +auth_callback_count = 0 + +# Create proxy session that injects token error on first CONNECTED only +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, + "action": { + "type": "replace", + "message": { + "action": 9, + "error": { "code": 40142, "statusCode": 401, "message": "Token expired" } + } + }, + "times": 1, + "comment": "RTN14b: Token error on first connect, renewal should succeed" + }] +) + +# Use token auth with authCallback so the SDK can renew +client = Realtime(options: ClientOptions( + authCallback: (params) => { + auth_callback_count++ + # Request a token from the sandbox using the API key + token_details = request_token_from_sandbox(api_key, params) + RETURN token_details + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Record state changes for sequence verification +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +# Start connection +client.connect() + +# SDK should see token error, renew token, reconnect, and reach CONNECTED +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 30 seconds +``` + +### Assertions + +```pseudo +# Successfully connected after token renewal +ASSERT client.connection.state == ConnectionState.connected + +# Connection properties are set (from the real CONNECTED on second attempt) +ASSERT client.connection.id IS NOT null +ASSERT client.connection.key IS NOT null + +# authCallback was called at least twice (initial token + renewal) +ASSERT auth_callback_count >= 2 + +# State sequence shows the SDK went through CONNECTING, then back to CONNECTING after error, +# and finally reached CONNECTED +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected +] + +# Proxy event log shows two WebSocket connections +log = session.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +ASSERT ws_connects.length >= 2 + +# No residual error reason on successful connection +ASSERT client.connection.errorReason IS null +``` + +--- + +## RTN14d — Retry after connection refused + +**Test ID**: `realtime/proxy/RTN14d/retry-after-refused-0` + +| Spec | Requirement | +|------|-------------| +| RTN14d | After a recoverable connection failure, the client transitions to DISCONNECTED and automatically retries after disconnectedRetryTimeout | + +Tests that when the first WebSocket connection is refused at the transport level, the SDK transitions to DISCONNECTED, waits for the retry timeout, and successfully connects on the second attempt. The proxy refuses the first connection and passes through the second. + +### Setup + +```pseudo +# Create proxy session that refuses the first WebSocket connection +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "ws_connect", "count": 1 }, + "action": { "type": "refuse_connection" }, + "times": 1, + "comment": "RTN14d: Refuse first WebSocket connection" + }] +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + disconnectedRetryTimeout: 2000 +)) +``` + +### Test Steps + +```pseudo +# Record state changes for sequence verification +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +# Start connection +client.connect() + +# SDK should fail on first attempt, go DISCONNECTED, retry, then reach CONNECTED +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 30 seconds +``` + +### Assertions + +```pseudo +# Successfully connected after retry +ASSERT client.connection.state == ConnectionState.connected + +# Connection properties are set +ASSERT client.connection.id IS NOT null +ASSERT client.connection.key IS NOT null + +# State sequence shows CONNECTING -> DISCONNECTED -> CONNECTING -> CONNECTED +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + +# Proxy event log shows two WebSocket connection attempts +log = session.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +ASSERT ws_connects.length >= 2 +``` + +--- + +## RTN14g — Connection-level ERROR during open causes FAILED + +**Test ID**: `realtime/proxy/RTN14g/server-error-causes-failed-0` + +| Spec | Requirement | +|------|-------------| +| RTN14g | If an ERROR ProtocolMessage with empty channel attribute is received, transition to FAILED state and set errorReason | + +Tests that when the server responds with a connection-level ERROR (no channel field) with a server error code during connection open, the SDK transitions to FAILED. This is functionally similar to RTN14a but uses a 5xx error code (server error) rather than a 4xx client error, confirming that both ranges (outside 40140-40149) result in FAILED. + +### Setup + +```pseudo +# Create proxy session that replaces the first CONNECTED with a server ERROR +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, + "action": { + "type": "replace", + "message": { + "action": 9, + "error": { "code": 50000, "statusCode": 500, "message": "Internal server error" } + } + }, + "times": 1, + "comment": "RTN14g: Connection-level ERROR (server error) during open" + }] +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Record state changes for sequence verification +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +# Start connection +client.connect() + +# Wait for FAILED state +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 15 seconds +``` + +### Assertions + +```pseudo +# Connection transitioned to FAILED +ASSERT client.connection.state == ConnectionState.failed + +# Error reason is set from the injected ERROR message +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 50000 +ASSERT client.connection.errorReason.statusCode == 500 +ASSERT client.connection.errorReason.message == "Internal server error" + +# State sequence includes CONNECTING -> FAILED +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.failed +] + +# Connection ID/key not set +ASSERT client.connection.id IS null +ASSERT client.connection.key IS null +``` + +--- + +## RTN14c — Connection timeout (no CONNECTED received) + +**Test ID**: `realtime/proxy/RTN14c/connection-timeout-0` + +| Spec | Requirement | +|------|-------------| +| RTN14c | A connection attempt fails if not connected within realtimeRequestTimeout | + +Tests that when the server accepts the WebSocket but never sends a CONNECTED message, the SDK times out and transitions to DISCONNECTED. The proxy suppresses the CONNECTED message from the server, forcing the SDK to rely on its timeout logic. + +### Setup + +```pseudo +# Create proxy session that suppresses all CONNECTED messages +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, + "action": { "type": "suppress" }, + "comment": "RTN14c: Suppress CONNECTED to force timeout" + }] +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + realtimeRequestTimeout: 3000 +)) +``` + +### Test Steps + +```pseudo +# Record state changes for sequence verification +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +# Start connection +client.connect() + +# SDK should time out waiting for CONNECTED and transition to DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 15 seconds +``` + +### Assertions + +```pseudo +# Connection timed out and transitioned to DISCONNECTED +ASSERT client.connection.state == ConnectionState.disconnected + +# Error reason indicates timeout +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.message CONTAINS "timeout" + OR client.connection.errorReason.code IN [50003, 80003] + +# State sequence includes CONNECTING -> DISCONNECTED +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.disconnected +] + +# Connection ID/key not set (CONNECTED was never received) +ASSERT client.connection.id IS null +ASSERT client.connection.key IS null +``` + +--- + +## Integration Test Notes + +### Timeout Handling + +All `AWAIT_STATE` calls use generous timeouts because real network traffic is involved: +- Connection to CONNECTED via proxy: 30 seconds (allows for auth + transport + retry) +- Connection to FAILED/DISCONNECTED: 15 seconds (allows for proxy rule processing) +- Cleanup close: 10 seconds + +### Token Auth Helper + +The RTN14b test requires a helper to request tokens from the sandbox: + +```pseudo +function request_token_from_sandbox(api_key, token_params): + # Split API key into key name and secret + key_name = api_key.split(":")[0] + key_secret = api_key.split(":")[1] + + # Request a token from the sandbox REST API + response = POST https://sandbox.realtime.ably-nonprod.net/keys/{key_name}/requestToken + WITH Authorization: Basic base64(api_key) + WITH body: token_params OR {} + + RETURN parse_json(response.body) # TokenDetails +``` + +### Why Proxy Tests vs Unit Tests + +These tests verify the same spec points as the unit tests in `connection_open_failures_test.md`, but provide higher confidence because: + +1. **Real WebSocket connections** -- the SDK's actual transport layer is exercised +2. **Real Ably protocol** -- the proxy modifies real server responses, not synthetic mocks +3. **Real timing** -- timeout behaviour is tested with actual clocks, not fake timers +4. **Real token renewal** -- RTN14b exercises the full authCallback-to-reconnect flow against the sandbox diff --git a/uts/realtime/integration/proxy/connection_resume.md b/uts/realtime/integration/proxy/connection_resume.md new file mode 100644 index 000000000..1621137a1 --- /dev/null +++ b/uts/realtime/integration/proxy/connection_resume.md @@ -0,0 +1,1322 @@ +# Connection Resume and Recovery Proxy Integration Tests (RTN15, RTN16) + +Spec points: `RTN15a`, `RTN15b`, `RTN15c6`, `RTN15c7`, `RTN15g`, `RTN15g2`, `RTN15h1`, `RTN15h3`, `RTN15j`, `RTN16d`, `RTN16l`, `RTN19a`, `RTN19a2` + +## Test Type + +Proxy integration test against Ably Sandbox endpoint. + +Uses the programmable proxy (`uts/test/proxy/`) to inject transport-level faults while the SDK communicates with the real Ably backend. See `uts/test/realtime/integration/helpers/proxy.md` for proxy infrastructure details. + +Corresponding unit tests: `uts/test/realtime/unit/connection/connection_failures_test.md`, `uts/test/realtime/unit/connection/connection_recovery_test.md` + +## Sandbox Setup + +```pseudo +BEFORE ALL TESTS: + # Provision test app + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + # Clean up test app + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## Test 6: RTN15a - Unexpected disconnect triggers resume + +**Test ID**: `realtime/proxy/RTN15a/disconnect-triggers-resume-0` + +| Spec | Requirement | +|------|-------------| +| RTN15a | If transport is disconnected unexpectedly, attempt resume | + +Tests that an unexpected transport disconnect causes the SDK to reconnect and attempt a resume, verified via the proxy event log. + +**Unit test counterpart:** `connection_failures_test.md` > RTN15a + +### Setup + +**Proxy rules:** Close the WebSocket connection after a 1-second delay. This simulates an unexpected disconnect after the SDK has connected. + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + rules: [ + { + match: { type: "delay_after_ws_connect", delayMs: 1000 }, + action: { type: "close" }, + times: 1, + comment: "RTN15a: Close WebSocket after 1s to trigger unexpected disconnect" + } + ] +) +``` + +**SDK config:** + +```pseudo +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Register state listener BEFORE connecting so we capture all state transitions +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +# Connect through proxy +client.connect() + +# Wait for first connected (rule fires after 1s, then proxy closes connection) +# SDK should reconnect and resume +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH condition: state_changes.filter(s => s == ConnectionState.connected).length >= 2 + WITH timeout: 30s +``` + +### Assertions + +```pseudo +# State changes should include: connecting, connected, disconnected, connecting, connected +disconnectedIdx = state_changes.indexOf(ConnectionState.disconnected) +ASSERT disconnectedIdx >= 0 + +# After the disconnected, there should be another connecting and connected +postDisconnectConnectingIdx = state_changes.indexOf(ConnectionState.connecting, disconnectedIdx) +ASSERT postDisconnectConnectingIdx > disconnectedIdx + +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + +# Verify resume was attempted via proxy log +log = session.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +ASSERT ws_connects.length >= 2 + +# Second WebSocket connection should include resume query parameter +ASSERT ws_connects[1].queryParams["resume"] IS NOT null +``` + +### Cleanup + +```pseudo +client.connection.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10s +session.close() +``` + +--- + +## Test 6b: RTN15a - Unexpected disconnect triggers resume (TCP close without close frame) + +**Test ID**: `realtime/proxy/RTN15a/tcp-close-triggers-resume-1` + +| Spec | Requirement | +|------|-------------| +| RTN15a | If transport is disconnected unexpectedly, attempt resume | + +Same as Test 6, but the proxy closes the underlying TCP connection without +sending a WebSocket close frame. The SDK should detect the TCP FIN and +transition to disconnected with minimal delay — identical to the close-frame +case. + +### Setup + +**Proxy rules:** Close the underlying TCP connection (no WebSocket close +frame) after a 1-second delay. + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + rules: [ + { + match: { type: "delay_after_ws_connect", delayMs: 1000 }, + action: { type: "disconnect" }, + times: 1, + comment: "RTN15a: Close TCP (no close frame) after 1s to trigger unexpected disconnect" + } + ] +) +``` + +**SDK config:** Same as Test 6. + +### Test Steps + +Same as Test 6. + +### Assertions + +Same as Test 6. + +### Cleanup + +Same as Test 6. + +--- + +## Test 7: RTN15b, RTN15c6 - Resume preserves connectionId + +**Test ID**: `realtime/proxy/RTN15b/resume-preserves-connid-0` + +| Spec | Requirement | +|------|-------------| +| RTN15b | Resume is attempted with connectionKey in `resume` query parameter | +| RTN15c6 | Successful resume indicated by same connectionId in CONNECTED response | + +Tests that after an unexpected disconnect and successful resume, the connection ID remains the same and the resume query parameter contains the connection key. + +**Unit test counterpart:** `connection_failures_test.md` > RTN15b, RTN15c6 + +### Setup + +**Proxy rules:** Close the WebSocket connection after a 1-second delay. + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + rules: [ + { + match: { type: "delay_after_ws_connect", delayMs: 1000 }, + action: { type: "close" }, + times: 1, + comment: "RTN15b/c6: Close WebSocket after 1s to trigger resume" + } + ] +) +``` + +**SDK config:** + +```pseudo +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect through proxy +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s + +# Record connection identity before disconnect +original_connection_id = client.connection.id +original_connection_key = client.connection.key +ASSERT original_connection_id IS NOT null +ASSERT original_connection_key IS NOT null + +# Proxy closes connection after 1s; wait for disconnected then reconnected +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 10s + +# Wait for SDK to resume +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s +``` + +### Assertions + +```pseudo +# RTN15c6: Connection ID is preserved (successful resume) +ASSERT client.connection.id == original_connection_id + +# RTN15b: Second ws_connect URL includes resume={connectionKey} +log = session.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +ASSERT ws_connects.length >= 2 +ASSERT ws_connects[1].queryParams["resume"] == original_connection_key + +# No error reason on successful resume +ASSERT client.connection.errorReason IS null +``` + +### Cleanup + +```pseudo +client.connection.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10s +session.close() +``` + +--- + +## Test 8: RTN15c7 - Failed resume gets new connectionId + +**Test ID**: `realtime/proxy/RTN15c7/failed-resume-new-connid-0` + +| Spec | Requirement | +|------|-------------| +| RTN15c7 | If resume fails, server sends CONNECTED with new connectionId and error | + +Tests that when a resume fails (simulated by the proxy replacing the server's second CONNECTED response with one containing a different connectionId and error), the SDK accepts the new connection identity and exposes the error. + +**Unit test counterpart:** `connection_failures_test.md` > RTN15c7 + +### Setup + +**Proxy rules:** Two rules work together: +1. Close the WebSocket connection after 1 second (fires once) to trigger a resume attempt. +2. Replace the 2nd CONNECTED message (the resume response) with a crafted one that has a different connectionId and an error, simulating a failed resume. + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + rules: [ + { + match: { type: "delay_after_ws_connect", delayMs: 1000 }, + action: { type: "close" }, + times: 1, + comment: "RTN15c7: Close WebSocket after 1s to trigger resume attempt" + }, + { + "match": { "type": "ws_frame_to_client", "action": "CONNECTED", "count": 2 }, + "action": { + "type": "replace", + "message": { + "action": 4, + "connectionId": "proxy-injected-new-id", + "connectionKey": "proxy-injected-new-key", + "connectionDetails": { + "connectionKey": "proxy-injected-new-key", + "clientId": null, + "maxMessageSize": 65536, + "maxInboundRate": 250, + "maxOutboundRate": 100, + "maxFrameSize": 524288, + "serverId": "test-server", + "connectionStateTtl": 120000, + "maxIdleInterval": 15000 + }, + "error": { + "code": 80008, + "statusCode": 400, + "message": "Unable to recover connection" + } + } + }, + "times": 1, + "comment": "RTN15c7: Replace 2nd CONNECTED with failed resume (different connectionId + error 80008)" + } + ] +) +``` + +**SDK config:** + +```pseudo +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect through proxy — first CONNECTED passes through normally +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s + +# Record original identity +original_connection_id = client.connection.id +ASSERT original_connection_id IS NOT null +ASSERT original_connection_id != "proxy-injected-new-id" + +# Proxy closes connection after 1s; wait for disconnected then reconnected +# SDK reconnects, but proxy replaces the CONNECTED response with a new connectionId +# SDK should still reach CONNECTED, but with the new identity +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 10s + +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s +``` + +### Assertions + +```pseudo +# RTN15c7: Connection ID changed (resume failed, got new connection) +ASSERT client.connection.id == "proxy-injected-new-id" +ASSERT client.connection.id != original_connection_id + +# Connection key updated to the new one +ASSERT client.connection.key == "proxy-injected-new-key" + +# Error reason is set indicating why resume failed +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80008 + +# Connection is still CONNECTED (not FAILED — the server gave a new connection) +ASSERT client.connection.state == ConnectionState.connected + +# Verify resume was attempted in the proxy log +log = session.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +ASSERT ws_connects.length >= 2 +ASSERT ws_connects[1].queryParams["resume"] IS NOT null +``` + +### Cleanup + +```pseudo +client.connection.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10s +session.close() +``` + +--- + +## Test 9: RTN15h1 - DISCONNECTED with token error, non-renewable token -> FAILED + +**Test ID**: `realtime/proxy/RTN15h1/token-error-nonrenewable-failed-0` + +| Spec | Requirement | +|------|-------------| +| RTN15h1 | If DISCONNECTED contains a token error and the token is not renewable, transition to FAILED | + +Tests that when the proxy injects a DISCONNECTED message with a token error (code 40142), and the SDK was configured with a non-renewable token (token string only, no key or authCallback), the SDK transitions to FAILED because it has no means to renew the token. + +**Unit test counterpart:** `connection_failures_test.md` > RTN15h1 + +### Setup + +**Proxy rules:** After the initial WebSocket connection is established, wait 1 second then inject a DISCONNECTED message with token error and close the connection. + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + rules: [ + { + "match": { "type": "delay_after_ws_connect", "delayMs": 1000 }, + "action": { + "type": "inject_to_client_and_close", + "message": { + "action": 6, + "error": { + "code": 40142, + "statusCode": 401, + "message": "Token expired" + } + } + }, + "times": 1, + "comment": "RTN15h1: Inject DISCONNECTED with token error (40142) after 1s" + } + ] +) +``` + +**Token provisioning:** Obtain a real token from the sandbox so the initial connection succeeds, then use it without any renewal capability. + +```pseudo +# Provision a token via REST using the API key (promise-based) +rest = Ably.Rest(options: ClientOptions(key: api_key, endpoint: "nonprod:sandbox")) +token_details = AWAIT rest.auth.requestToken() +token_string = token_details.token +``` + +**SDK config:** Use the token string directly — no key, no authCallback. This makes the token non-renewable. + +```pseudo +client = Realtime(options: ClientOptions( + token: token_string, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect through proxy — initial connection succeeds with the real token +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s + +# Record state changes +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +# After 1s the proxy injects DISCONNECTED with 40142 and closes the socket. +# The SDK has a non-renewable token, so it cannot renew -> FAILED. +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 15s +``` + +### Assertions + +```pseudo +# RTN15h1: Ended in FAILED state +ASSERT client.connection.state == ConnectionState.failed + +# Error reason reflects the token error +# NOTE: ably-js reports error code 40171 ("Token not renewable") rather than the injected +# 40142, because the SDK detects it has no means to renew (no key, no authCallback, no +# authUrl) and substitutes a more specific error code before transitioning to FAILED. +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 40171 +ASSERT client.connection.errorReason.statusCode == 401 + +# State changes should show the transition to FAILED +# (may pass through DISCONNECTED briefly before FAILED) +ASSERT state_changes CONTAINS ConnectionState.failed +``` + +### Cleanup + +```pseudo +# No need to close — already in FAILED state +session.close() +``` + +--- + +## Test 10: RTN15h3 - DISCONNECTED with non-token error triggers reconnect + +**Test ID**: `realtime/proxy/RTN15h3/non-token-error-reconnects-0` + +| Spec | Requirement | +|------|-------------| +| RTN15h3 | If DISCONNECTED contains a non-token error, initiate immediate reconnect with resume | + +Tests that when the proxy injects a DISCONNECTED message with a non-token error (code 80003), the SDK reconnects and resumes rather than transitioning to FAILED. + +**Unit test counterpart:** `connection_failures_test.md` > RTN15h3 + +### Setup + +**Proxy rules:** After the initial WebSocket connection, wait 1 second then inject a DISCONNECTED message with a non-token error and close the connection. Only fire once — the reconnection attempt passes through cleanly. + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + rules: [ + { + "match": { "type": "delay_after_ws_connect", "delayMs": 1000 }, + "action": { + "type": "inject_to_client_and_close", + "message": { + "action": 6, + "error": { + "code": 80003, + "statusCode": 500, + "message": "Service temporarily unavailable" + } + } + }, + "times": 1, + "comment": "RTN15h3: Inject DISCONNECTED with non-token error (80003) after 1s, once only" + } + ] +) +``` + +**SDK config:** + +```pseudo +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect through proxy +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s + +# Record state changes +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +# After 1s the proxy injects DISCONNECTED with non-token error and closes. +# The rule fires once, so the reconnection attempt passes through to the real server. + +# Wait for DISCONNECTED (from the injected message) +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 10s + +# SDK should automatically reconnect +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s +``` + +### Assertions + +```pseudo +# RTN15h3: SDK reconnected successfully (not FAILED) +ASSERT client.connection.state == ConnectionState.connected + +# State changes should show: disconnected -> connecting -> connected +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + +# Verify resume was attempted +log = session.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +ASSERT ws_connects.length >= 2 +ASSERT ws_connects[1].queryParams["resume"] IS NOT null + +# No error reason after successful reconnection +ASSERT client.connection.errorReason IS null +``` + +### Cleanup + +```pseudo +client.connection.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10s +session.close() +``` + +--- + +## Test 21: RTN15j - Fatal ERROR on established connection + +**Test ID**: `realtime/proxy/RTN15j/fatal-error-established-conn-0` + +| Spec | Requirement | +|------|-------------| +| RTN15j | If an ERROR ProtocolMessage with an empty channel attribute is received, this indicates a fatal error in the connection. The client should transition to the FAILED state triggering all attached channels to transition to the FAILED state as well. The Connection#errorReason should be set with the error received from Ably. | + +Tests that a connection-level ERROR ProtocolMessage (no channel field) causes the connection to transition to FAILED, propagates the error to all attached channels, and that the SDK does not attempt to reconnect. + +**Unit test counterpart:** `connection_failures_test.md` > RTN15j + +### Setup + +**Proxy rules:** None initially (passthrough). The ERROR is injected imperatively after connection and channel attachment complete. + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + rules: [] +) +``` + +**SDK config:** + +```pseudo +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect through proxy +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s + +# Attach two channels in parallel +channel_a = client.channels.get(uniqueChannelName("fatal-error-a")) +channel_b = client.channels.get(uniqueChannelName("fatal-error-b")) +AWAIT Promise.all([channel_a.attach(), channel_b.attach()]) + WITH timeout: 15s + +# Record state changes +connection_state_changes = [] +client.connection.on((change) => { + connection_state_changes.append(change.current) +}) +channel_a_state_changes = [] +channel_a.on((change) => { + channel_a_state_changes.append(change.current) +}) +channel_b_state_changes = [] +channel_b.on((change) => { + channel_b_state_changes.append(change.current) +}) + +# Inject a connection-level ERROR via proxy imperative action +session.trigger_action({ + type: "inject_to_client", + message: { + "action": 9, + "error": { + "code": 50000, + "statusCode": 500, + "message": "Internal server error" + } + } +}) + +# SDK should transition to FAILED +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 15s +``` + +### Assertions + +```pseudo +# RTN15j: Connection is in FAILED state +ASSERT client.connection.state == ConnectionState.failed + +# Connection errorReason has the injected error +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 50000 +ASSERT client.connection.errorReason.statusCode == 500 + +# Both channels transitioned to FAILED +ASSERT channel_a.state == ChannelState.failed +ASSERT channel_b.state == ChannelState.failed + +# Channel errors match the connection error +ASSERT channel_a.errorReason IS NOT null +ASSERT channel_a.errorReason.code == 50000 +ASSERT channel_b.errorReason IS NOT null +ASSERT channel_b.errorReason.code == 50000 + +# State change sequences +ASSERT connection_state_changes CONTAINS ConnectionState.failed +ASSERT channel_a_state_changes CONTAINS ChannelState.failed +ASSERT channel_b_state_changes CONTAINS ChannelState.failed + +# No reconnection attempted — only the original ws_connect in the proxy log +log = session.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +ASSERT ws_connects.length == 1 +``` + +### Cleanup + +```pseudo +# No need to close — already in FAILED state +session.close() +``` + +--- + +## Test 22: RTN15g/g2 - connectionStateTtl expiry clears resume state + +**Test ID**: `realtime/proxy/RTN15g/ttl-expiry-clears-resume-0` + +| Spec | Requirement | +|------|-------------| +| RTN15g | If disconnected for longer than connectionStateTtl, do not attempt resume; connect as a fresh connection | +| RTN15g2 | The staleness measure is whether the time since last activity exceeds connectionStateTtl + maxIdleInterval | + +Tests that when the client has been disconnected for longer than connectionStateTtl + maxIdleInterval, the SDK does not attempt to resume. Instead it makes a fresh connection, resulting in a new connectionId. + +**Unit test counterpart:** `connection_failures_test.md` > RTN15g + +### Setup + +**Proxy rules:** Three rules work together: +1. Replace the first CONNECTED message to inject short `connectionStateTtl` (2000ms) and `maxIdleInterval` (15000ms) into connectionDetails, and set a known `connectionId` so we can verify the final id differs. +2. Close the WebSocket connection after 1 second (fires once). At this point the client enters DISCONNECTED with `connectionStateTtl=2000ms`. +3. Refuse the second ws_connect (fires once). This keeps the client in DISCONNECTED while the TTL clock runs out, so when the TTL expires the client transitions to SUSPENDED. The third ws_connect (when the `suspendedRetryTimeout` fires) should be a fresh connection. + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + rules: [ + { + "match": { "type": "ws_frame_to_client", "action": "CONNECTED", "count": 1 }, + "action": { + "type": "replace", + "message": { + "action": 4, + "connectionId": "proxy-ttl-test-id", + "connectionKey": "__PASSTHROUGH__", + "connectionDetails": { + "connectionKey": "__PASSTHROUGH__", + "clientId": null, + "maxMessageSize": 65536, + "maxInboundRate": 250, + "maxOutboundRate": 100, + "maxFrameSize": 524288, + "serverId": "test-server", + "connectionStateTtl": 2000, + "maxIdleInterval": 15000 + } + } + }, + "times": 1, + "comment": "RTN15g: Replace 1st CONNECTED to set short connectionStateTtl (2s) and known connectionId" + }, + { + "match": { "type": "delay_after_ws_connect", "delayMs": 1000 }, + "action": { "type": "close" }, + "times": 1, + "comment": "RTN15g: Close connection after 1s — client enters DISCONNECTED with 2s TTL" + }, + { + "match": { "type": "ws_connect", "count": 2 }, + "action": { "type": "refuse" }, + "times": 1, + "comment": "RTN15g: Refuse 2nd ws_connect — keeps client disconnected until TTL expires and SUSPENDED fires" + } + ] +) +``` + +**SDK config:** Use a short `suspendedRetryTimeout` so the test doesn't wait long after SUSPENDED. + +```pseudo +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + suspendedRetryTimeout: 1000 +)) +``` + +### Test Steps + +```pseudo +# Connect through proxy — first CONNECTED is replaced with short TTL and known connectionId +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s + +# Verify proxy-injected connectionId +ASSERT client.connection.id == "proxy-ttl-test-id" + +# T=1s: proxy closes connection -> DISCONNECTED +# T=1-3s: retry attempt is refused -> stays DISCONNECTED +# T=3s: connectionStateTtl(2s) expires -> SUSPENDED +# T=4s: suspendedRetryTimeout(1s) fires -> fresh ws_connect (no resume) +# -> CONNECTED with new connectionId from real server + +AWAIT_STATE client.connection.state == ConnectionState.suspended + WITH timeout: 15s + +# After suspended, SDK makes a fresh connection +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s +``` + +### Assertions + +```pseudo +# RTN15g: Connection ID changed (fresh connection, not resumed) +ASSERT client.connection.id != "proxy-ttl-test-id" + +# Verify the proxy log shows at least 3 ws_connects +log = session.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +ASSERT ws_connects.length >= 3 + +# First ws_connect: initial — no resume +ASSERT ws_connects[0].queryParams["resume"] IS null + +# Last ws_connect: fresh connection after TTL expiry — no resume +last_ws_connect = ws_connects[ws_connects.length - 1] +ASSERT last_ws_connect.queryParams["resume"] IS null +``` + +### Cleanup + +```pseudo +client.connection.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10s +session.close() +``` + +--- + +## Test 23: RTN19a/a2 - Unacked messages resent on new transport after resume + +**Test ID**: `realtime/proxy/RTN19a/unacked-resent-on-resume-0` + +| Spec | Requirement | +|------|-------------| +| RTN19a | Any ProtocolMessage awaiting ACK/NACK on the old transport must be resent on the new transport | +| RTN19a2 | On successful resume (RTN15c6), the resent messages retain the same msgSerial | + +Tests that a message awaiting ACK on the old transport is resent after reconnection and resume, and that the publish eventually completes successfully. + +**Unit test counterpart:** `connection_failures_test.md` > RTN19a + +### Setup + +**Proxy rules:** Suppress the first ACK (action 1) from the server. This causes the SDK to have an unacknowledged message when the disconnect occurs. + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + rules: [ + { + "match": { "type": "ws_frame_to_client", "action": "ACK" }, + "action": { + "type": "suppress" + }, + "times": 1, + "comment": "RTN19a: Suppress the first ACK so the SDK has a pending unacked message" + } + ] +) +``` + +**SDK config:** + +```pseudo +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect through proxy +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s + +# Attach a channel +channel = client.channels.get("test-resend-unacked") +channel.attach() +AWAIT_STATE channel.state == ChannelState.attached + WITH timeout: 15s + +# Start a publish — do NOT await it yet. +# The message is sent to the server, but the ACK is suppressed by the proxy rule. +publish_future = channel.publish("event", "test-data") + +# Poll the proxy log until we can confirm both: +# (a) the MESSAGE frame has been sent client->server (action==15) +# (b) the ACK frame has been suppressed server->client (action==1 with ruleMatched) +# This avoids a fixed sleep and ensures the disconnect fires at the right moment. +poll_until( + condition: () => { + log = session.get_log() + message_sent = log has ws_frame client_to_server action==15 + ack_suppressed = log has ws_frame server_to_client action==1 with ruleMatched + return message_sent AND ack_suppressed + }, + timeout: 10s +) + +# Close the connection — the SDK has an unacked message pending +session.trigger_action({ type: "close" }) + +# SDK reconnects and resumes (the ACK suppression rule already fired once, +# so the reconnected session passes ACKs through normally) +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s + +# Now await the publish — it should complete successfully after the message +# is resent on the new transport and ACKed +AWAIT publish_future + WITH timeout: 15s +``` + +### Assertions + +```pseudo +# The publish completed successfully (no exception thrown) +ASSERT publish_future.completed == true +ASSERT publish_future.error IS null + +# Verify resume occurred +log = session.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +ASSERT ws_connects.length >= 2 +ASSERT ws_connects[1].queryParams["resume"] IS NOT null + +# RTN19a: The MESSAGE frame was sent on both transports (original + resend) +message_frames = log.filter(e => + e.type == "ws_frame_to_server" AND + e.message.action == "MESSAGE" +) +ASSERT message_frames.length >= 2 + +# RTN19a2: On successful resume, the resent message has the same msgSerial +ASSERT message_frames[0].message.msgSerial == message_frames[1].message.msgSerial + +# Successful resume: connectionId preserved +ASSERT ws_connects[1].queryParams["resume"] IS NOT null +``` + +### Cleanup + +```pseudo +client.connection.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10s +session.close() +``` + +--- + +## Test 24: RTN16d - Successful recovery preserves connectionId and updates connectionKey + +**Test ID**: `realtime/proxy/RTN16d/recovery-preserves-connid-0` + +| Spec | Requirement | +|------|-------------| +| RTN16d | After a connection has been successfully recovered, Connection#id should be identical to the id of the connection that was recovered, and Connection#key should have been updated to the ConnectionDetails#connectionKey provided in the CONNECTED ProtocolMessage | +| RTN16k | The first connection with a `recover` option should add a `recover` querystring param set from the connectionKey component of the recoveryKey | + +Tests that when a client is instantiated with a `recover` option containing a valid recovery key obtained from a previous connection, the SDK sends the `recover` query parameter, and after successful recovery the connectionId is preserved and the connectionKey is updated. + +**Unit test counterpart:** `connection_recovery_test.md` > RTN16k, RTN16g + +### Setup + +**Step 1: Establish an initial connection and obtain a recovery key.** + +Use a direct proxy session (passthrough, no rules) to connect to the sandbox, attach a channel, and capture the recovery key. + +```pseudo +session_1 = create_proxy_session( + endpoint: "nonprod:sandbox", + rules: [] +) + +client_1 = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: session_1.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) +``` + +**Step 2: Create a second client using the recovery key.** + +A second proxy session is used so we can inspect the `recover` query parameter in the log. + +```pseudo +session_2 = create_proxy_session( + endpoint: "nonprod:sandbox", + rules: [] +) +``` + +### Test Steps + +```pseudo +# --- Phase 1: Obtain recovery key from first client --- + +client_1.connect() +AWAIT_STATE client_1.connection.state == ConnectionState.connected + WITH timeout: 15s + +original_connection_id = client_1.connection.id +original_connection_key = client_1.connection.key +ASSERT original_connection_id IS NOT null + +# Attach a channel so it appears in the recovery key +channel_1 = client_1.channels.get(uniqueChannelName("recovery-test")) +channel_1.attach() +AWAIT_STATE channel_1.state == ChannelState.attached + WITH timeout: 15s + +# Get the recovery key +recovery_key = client_1.connection.createRecoveryKey() +ASSERT recovery_key IS NOT null + +# Close the first client's transport WITHOUT closing the Ably connection gracefully. +# We want the server to keep the connection state alive for recovery. +# Use session_1.trigger_action to forcibly close the WebSocket. +session_1.trigger_action({ type: "close" }) + +# Wait for the client to detect the disconnect +AWAIT_STATE client_1.connection.state == ConnectionState.disconnected + WITH timeout: 10s + +# Close client_1 without allowing it to reconnect +client_1.connection.close() +AWAIT_STATE client_1.connection.state == ConnectionState.closed + WITH timeout: 10s +session_1.close() + +# --- Phase 2: Recover using the recovery key --- + +client_2 = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: session_2.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + recover: recovery_key +)) + +client_2.connect() +AWAIT_STATE client_2.connection.state == ConnectionState.connected + WITH timeout: 15s +``` + +### Assertions + +```pseudo +# RTN16d: Connection ID is preserved (same as original connection) +ASSERT client_2.connection.id == original_connection_id + +# RTN16d: Connection key is updated (new key from server) +ASSERT client_2.connection.key IS NOT null +ASSERT client_2.connection.key != original_connection_key + +# RTN16k: Verify the recover query parameter was sent via proxy log +log = session_2.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +ASSERT ws_connects.length >= 1 +ASSERT ws_connects[0].queryParams["recover"] == original_connection_key + +# No resume param (this is recovery, not resume) +ASSERT ws_connects[0].queryParams["resume"] IS null + +# No error on successful recovery +ASSERT client_2.connection.errorReason IS null +``` + +### Cleanup + +```pseudo +client_2.connection.close() +AWAIT_STATE client_2.connection.state == ConnectionState.closed + WITH timeout: 10s +session_2.close() +``` + +--- + +## Test 25: RTN16l - Recovery failure treated as fresh connection (per RTN15c7) + +**Test ID**: `realtime/proxy/RTN16l/recovery-failure-fresh-conn-0` + +| Spec | Requirement | +|------|-------------| +| RTN16l | Recovery failures should be handled identically to resume failures, per RTN15c7, RTN15c5, and RTN15c4 | +| RTN15c7 | If recovery/resume fails, server sends CONNECTED with a new connectionId and an error; client resets msgSerial to 0 | + +Tests that when a recovery attempt fails (the server responds with a new connectionId and an error because it cannot recover the connection), the SDK handles it as a fresh connection: it gets a new connectionId, sets the error on the connection, and the client remains in CONNECTED state. + +**Unit test counterpart:** `connection_recovery_test.md` > RTN16f + +### Setup + +**Proxy rules:** Replace the first CONNECTED response with one that has a different connectionId and an error, simulating the server rejecting the recovery attempt. + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + rules: [ + { + "match": { "type": "ws_frame_to_client", "action": "CONNECTED", "count": 1 }, + "action": { + "type": "replace", + "message": { + "action": 4, + "connectionId": "recovery-failed-new-id", + "connectionKey": "recovery-failed-new-key", + "connectionDetails": { + "connectionKey": "recovery-failed-new-key", + "clientId": null, + "maxMessageSize": 65536, + "maxInboundRate": 250, + "maxOutboundRate": 100, + "maxFrameSize": 524288, + "serverId": "test-server", + "connectionStateTtl": 120000, + "maxIdleInterval": 15000 + }, + "error": { + "code": 80008, + "statusCode": 400, + "message": "Unable to recover connection" + } + } + }, + "times": 1, + "comment": "RTN16l: Replace CONNECTED with recovery failure (new connectionId + error 80008)" + } + ] +) +``` + +**SDK config:** Use a fabricated recovery key. The connectionKey doesn't need to be valid since the proxy will replace the server response anyway. + +```pseudo +fabricated_recovery_key = toJson({ + "connectionKey": "stale-old-key", + "msgSerial": 99, + "channelSerials": { + "old-channel": "old-serial" + } +}) + +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + recover: fabricated_recovery_key +)) +``` + +### Test Steps + +```pseudo +# Connect with the fabricated recovery key +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s +``` + +### Assertions + +```pseudo +# RTN16l + RTN15c7: Connection got a new ID (recovery failed) +ASSERT client.connection.id == "recovery-failed-new-id" +ASSERT client.connection.key == "recovery-failed-new-key" + +# RTN15c7: Error is set on the connection indicating recovery failure +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80008 + +# Connection is still CONNECTED (not FAILED — the server gave a new connection) +ASSERT client.connection.state == ConnectionState.connected + +# Verify the recover param was sent via proxy log +log = session.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +ASSERT ws_connects.length >= 1 +ASSERT ws_connects[0].queryParams["recover"] == "stale-old-key" +``` + +### Cleanup + +```pseudo +client.connection.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10s +session.close() +``` + +--- + +## Integration Test Notes + +### Timeout Handling + +All `AWAIT_STATE` calls use generous timeouts since real network traffic through the proxy is involved: +- Initial CONNECTED: 15 seconds (auth + transport setup through proxy) +- Reconnection CONNECTED: 15 seconds (allows for SDK retry logic + network round-trip) +- DISCONNECTED (injected): 10 seconds (1s proxy delay + processing) +- FAILED: 15 seconds (SDK may attempt intermediate steps) +- CLOSED (cleanup): 10 seconds + +### Temporal Triggers vs Imperative Actions + +Where possible, tests use temporal proxy rules (e.g. `delay_after_ws_connect` + `close`) rather than imperative `session.trigger_action({ type: "disconnect" })` calls. Temporal triggers are deterministic — the proxy fires them at a known point in the connection lifecycle — whereas imperative actions can race with SDK internal state transitions, leading to flaky tests. + +### Error Handling + +If any test fails to reach an expected state: +- Log the connection `errorReason` +- Log all recorded `state_changes` +- Retrieve and log the proxy session event log via `session.get_log()` +- Fail with diagnostic information + +### Cleanup + +Always clean up both the SDK client and the proxy session: + +```pseudo +AFTER EACH TEST: + IF client IS NOT null AND client.connection.state IN [connected, connecting, disconnected]: + client.connection.close() + AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10s + IF session IS NOT null: + session.close() +``` diff --git a/uts/realtime/integration/proxy/heartbeat.md b/uts/realtime/integration/proxy/heartbeat.md new file mode 100644 index 000000000..e213436b7 --- /dev/null +++ b/uts/realtime/integration/proxy/heartbeat.md @@ -0,0 +1,169 @@ +# Realtime Heartbeat — Proxy Integration Tests + +Spec points: `RTN23a` + +## Test Type + +Proxy integration test against Ably Sandbox endpoint + +## Proxy Infrastructure + +See `uts/test/realtime/integration/helpers/proxy.md` for the full proxy infrastructure specification. + +## Related Unit Tests + +See `uts/test/realtime/unit/connection/heartbeat_test.md` for the corresponding unit tests that verify the same spec points with mocked WebSocket and fake timers. + +## Sandbox Setup + +Tests run against the Ably Sandbox via a programmable proxy. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + # Provision test app + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + # Clean up test app + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Common Cleanup + +```pseudo +AFTER EACH TEST: + IF client IS NOT null AND client.connection.state IN [connected, connecting, disconnected]: + client.connection.close() + AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10 seconds + IF session IS NOT null: + session.close() +``` + +--- + +## RTN23a — Heartbeat starvation causes disconnect and reconnect + +**Test ID**: `realtime/proxy/RTN23a/heartbeat-starvation-reconnect-0` + +| Spec | Requirement | +|------|-------------| +| RTN23a | If no activity is received for `maxIdleInterval + realtimeRequestTimeout`, the transport should be disconnected | + +The proxy closes the WebSocket connection after a 2s delay from ws_connect, simulating a transport failure. The SDK transitions to DISCONNECTED and automatically reconnects. The close rule fires once (times: 1), so the second WS connection is unaffected. + +### Setup + +```pseudo +# Create proxy session that closes the WebSocket after 2s to simulate transport failure +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "delay_after_ws_connect", "delayMs": 2000 }, + "action": { "type": "close" }, + "times": 1, + "comment": "RTN23a: Close WebSocket after 2s to simulate transport failure" + }] +) + +keyName = api_key.split(":")[0] +keySecret = api_key.split(":")[1] + +client = Realtime(options: ClientOptions( + authCallback: (_params, cb) => { + cb(null, generateJWT({ keyName, keySecret })) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Record state changes for sequence verification +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +# Start connection +client.connect() + +# SDK receives real CONNECTED from Ably (within the 2s before close fires) +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15 seconds + +# Capture connection details from the first connection +first_connection_id = client.connection.id +first_connection_key = client.connection.key +ASSERT first_connection_id IS NOT null + +# The proxy closes the WebSocket after 2s. The SDK detects the close frame +# immediately and transitions to DISCONNECTED, then automatically reconnects. +# The close rule has times=1, so the second WS connection is unaffected. + +# Wait for DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 15 seconds + +# Wait for successful reconnection +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 30 seconds +``` + +### Assertions + +```pseudo +# Connection is re-established with new connection details +ASSERT client.connection.state == ConnectionState.connected +ASSERT client.connection.id IS NOT null +ASSERT client.connection.key IS NOT null + +# State sequence shows: connected -> disconnected -> reconnecting -> connected +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + +# Proxy event log confirms two WebSocket connections +log = session.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +ASSERT ws_connects.length >= 2 + +# Second connection should include resume parameter (RTN15c) +ASSERT ws_connects[1].queryParams["resume"] IS NOT null +``` + +--- + +## Integration Test Notes + +### Timing Considerations + +The RTN23a test is fast because the `close` action sends a WebSocket close frame that the SDK detects immediately. The proxy closes the connection after the configured 2s delay, so the test completes in approximately 2–3 seconds rather than waiting for an idle timer to expire. + +The unit tests in `heartbeat_test.md` use fake timers and short intervals for fast, deterministic testing of the same logic. + +### Why Proxy Tests vs Unit Tests + +These tests complement the unit tests in `heartbeat_test.md`: + +1. **Real transport failure** -- the proxy sends an actual WebSocket close frame; the SDK handles it through the real connection lifecycle code +2. **Real reconnection** -- the SDK reconnects through a real WebSocket to a real server +3. **Real `heartbeats=true` parameter** -- verified in the actual WebSocket URL captured by the proxy diff --git a/uts/realtime/integration/proxy/presence_reentry.md b/uts/realtime/integration/proxy/presence_reentry.md new file mode 100644 index 000000000..0e3d6bd91 --- /dev/null +++ b/uts/realtime/integration/proxy/presence_reentry.md @@ -0,0 +1,360 @@ +# Presence Re-entry Proxy Integration Tests + +Spec points: `RTP17i`, `RTP17g` + +## Test Type + +Proxy integration test against Ably Sandbox endpoint + +## Proxy Infrastructure + +See `uts/test/realtime/integration/helpers/proxy.md` for the full proxy infrastructure specification. + +## Corresponding Unit Tests + +- `uts/test/realtime/unit/presence/realtime_presence_reentry.md` -- RTP17i (automatic re-entry on ATTACHED non-RESUMED), RTP17g (re-entry publishes ENTER with stored clientId and data) + +## Sandbox Setup + +Tests run against the Ably Sandbox via a programmable proxy. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + # Provision test app + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + # Clean up test app + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Common Cleanup + +```pseudo +AFTER EACH TEST: + IF session IS NOT null: + session.close() + session = null +``` + +--- + +## Test 27: RTP17i/RTP17g -- Automatic presence re-enter on non-resumed reattach + +**Test ID**: `realtime/proxy/RTP17i/reenter-on-non-resumed-0` + +| Spec | Requirement | +|------|-------------| +| RTP17i | The RealtimePresence object should perform automatic re-entry whenever the channel receives an ATTACHED ProtocolMessage, except in the case where the channel is already attached and the ProtocolMessage has the RESUMED bit flag set | +| RTP17g | Automatic re-entry consists of, for each member of the internal PresenceMap, publishing a PresenceMessage with an ENTER action using the clientId, data, and id attributes from that member | + +Tests that when an already-attached channel receives an injected ATTACHED ProtocolMessage with `resumed=false` (flags=0, RESUMED bit not set), the SDK automatically re-enters all locally-entered presence members. Verified via proxy log: count PRESENCE frames (action=14, client_to_server) before injection, then poll until the count increases. + +The server won't broadcast the re-enter to other subscribers (since from the server's perspective the member never left), so a second observer client is not used. The proxy log provides direct evidence of the SDK's wire behaviour. + +### Setup + +```pseudo +channel_name = unique_channel_name("test-rtp17i") + +# Extract key name and key secret from the provisioned API key +key_parts = api_key.split(":") +key_name = key_parts[0] +key_secret = key_parts[1] + +# Create proxy session with clean passthrough (no fault rules) +session = create_proxy_session(rules: []) + +# client: the presence member, connects through the proxy so we can inject ATTACHED +# Needs a clientId for presence — use authCallback with JWT that includes clientId +client = Realtime(options: ClientOptions( + authCallback: (params, cb) => { + cb(null, generateJWT(keyName: key_name, keySecret: key_secret, clientId: "client-a")) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps + +```pseudo +# Phase 1 — Establish real presence state + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15 seconds + +AWAIT channel.attach() +AWAIT channel.presence.enter(data: "hello") + +# Phase 2 — Count PRESENCE frames in the log before injection + +log_before = session.get_log() +presence_frames_before = log_before.filter(e => + e.type == "ws_frame" AND + e.direction == "client_to_server" AND + e.message.action == 14 +).length + +# Phase 3 — Inject ATTACHED with resumed=false (flags=0) +# This triggers RTP17i re-entry without needing an actual disconnect + +session.trigger_action({ + type: "inject_to_client", + message: { + action: 11, + channel: channel_name, + flags: 0, + error: { code: 91001, statusCode: 500, message: "Continuity lost" } + } +}) + +# Phase 4 — Poll until a new PRESENCE frame appears in the log + +POLL_UNTIL(timeout: 10 seconds, interval: 200ms): + log = session.get_log() + presence_frames = log.filter(e => + e.type == "ws_frame" AND + e.direction == "client_to_server" AND + e.message.action == 14 + ) + RETURN presence_frames.length > presence_frames_before +``` + +### Assertions + +```pseudo +# Get final proxy log +log_after = session.get_log() +all_presence_frames = log_after.filter(e => + e.type == "ws_frame" AND + e.direction == "client_to_server" AND + e.message.action == 14 +) + +# At least one new PRESENCE frame was sent after the injection +ASSERT all_presence_frames.length > presence_frames_before + +# The last (most recent) re-enter frame should contain the presence data +reenter_frame = all_presence_frames[all_presence_frames.length - 1] +ASSERT reenter_frame.message.presence IS NOT null +ASSERT reenter_frame.message.presence.length >= 1 + +# RTP17g: re-enter uses stored clientId, data, and ENTER action +reenter_msg = reenter_frame.message.presence[0] +ASSERT reenter_msg.clientId == "client-a" +ASSERT reenter_msg.data == "hello" +ASSERT reenter_msg.action == 2 # ENTER + +# Channel should still be attached and connection still connected +ASSERT channel.state == ChannelState.attached +ASSERT client.connection.state == ConnectionState.connected +``` + +--- + +## Test 28: RTP17i -- Presence re-enter after real disconnect + +**Test ID**: `realtime/proxy/RTP17i/reenter-after-disconnect-1` + +| Spec | Requirement | +|------|-------------| +| RTP17i | The RealtimePresence object should perform automatic re-entry whenever the channel receives an ATTACHED ProtocolMessage, except in the case where the channel is already attached and the ProtocolMessage has the RESUMED bit flag set | + +Tests the same RTP17i re-entry logic, but triggered via a real WebSocket disconnect and reconnect rather than injection. The proxy closes the WebSocket connection 3 seconds after it is established (giving time to attach and enter presence). On reconnect, the proxy replaces the 2nd ATTACHED message on the channel with a non-resumed one (flags=0), triggering re-entry. We verify via proxy log that a PRESENCE ENTER frame is sent after the 2nd `ws_connect` event. + +### Setup + +```pseudo +channel_name = unique_channel_name("test-rtp17i-real") + +# Extract key name and key secret from the provisioned API key +key_parts = api_key.split(":") +key_name = key_parts[0] +key_secret = key_parts[1] + +# Create proxy session with two fault rules: +# 1. Close the WebSocket 3s after connect (giving time to attach + enter presence) +# 2. Replace the 2nd ATTACHED on the channel with a non-resumed one +session = create_proxy_session( + rules: [ + { + match: { type: "delay_after_ws_connect", delayMs: 3000 }, + action: { type: "close" }, + times: 1, + comment: "RTP17i: Close WebSocket after 3s to trigger reconnect" + }, + { + match: { type: "ws_frame_to_client", action: "ATTACHED", channel: channel_name, count: 2 }, + action: { + type: "replace", + message: { + action: 11, + channel: channel_name, + flags: 0, + error: { code: 91001, statusCode: 500, message: "Continuity lost" } + } + }, + times: 1, + comment: "RTP17i: Replace 2nd ATTACHED with non-resumed to trigger re-entry" + } + ] +) + +# client_a: the presence member, connects through the proxy +client_a = Realtime(options: ClientOptions( + authCallback: (params, cb) => { + cb(null, generateJWT(keyName: key_name, keySecret: key_secret, clientId: "client-a")) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel_a = client_a.channels.get(channel_name) +``` + +### Test Steps + +```pseudo +# Phase 1 — Establish presence before the proxy closes the connection + +client_a.connect() +AWAIT_STATE client_a.connection.state == ConnectionState.connected + WITH timeout: 15 seconds + +AWAIT channel_a.attach() +AWAIT channel_a.presence.enter(data: "hello") + +# Phase 2 — Wait for the temporal trigger to fire (at T+3s) and for reconnect + +# The proxy's delay_after_ws_connect rule closes the WebSocket at T+3s +AWAIT_STATE client_a.connection.state == ConnectionState.disconnected + WITH timeout: 10 seconds +AWAIT_STATE client_a.connection.state == ConnectionState.connected + WITH timeout: 15 seconds + +# Wait for the channel to reattach (the 2nd ATTACHED will be replaced with non-resumed) +AWAIT_STATE channel_a.state == ChannelState.attached + WITH timeout: 15 seconds + +# Phase 3 — Poll until a PRESENCE frame appears in the log after the 2nd ws_connect + +POLL_UNTIL(timeout: 10 seconds, interval: 200ms): + log = session.get_log() + ws_connects = log.filter(e => e.type == "ws_connect") + IF ws_connects.length < 2: RETURN false + second_connect_time = ws_connects[1].timestamp + presence_after_reconnect = log.filter(e => + e.type == "ws_frame" AND + e.direction == "client_to_server" AND + e.message.action == 14 AND + e.timestamp > second_connect_time + ) + RETURN presence_after_reconnect.length > 0 +``` + +### Assertions + +```pseudo +# Get final proxy log +log = session.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +second_connect_time = ws_connects[1].timestamp + +reenter_frames = log.filter(e => + e.type == "ws_frame" AND + e.direction == "client_to_server" AND + e.message.action == 14 AND + e.timestamp > second_connect_time +) + +ASSERT reenter_frames.length >= 1 + +# Check the first re-enter frame +reenter_frame = reenter_frames[0] +ASSERT reenter_frame.message.presence IS NOT null +ASSERT reenter_frame.message.presence.length >= 1 + +# RTP17g: re-enter uses stored clientId, data, and ENTER action +reenter_msg = reenter_frame.message.presence[0] +ASSERT reenter_msg.clientId == "client-a" +ASSERT reenter_msg.data == "hello" +ASSERT reenter_msg.action == 2 # ENTER + +# Channel is still attached and connection is still connected +ASSERT channel_a.state == ChannelState.attached +ASSERT client_a.connection.state == ConnectionState.connected +``` + +--- + +## Integration Test Notes + +### Single-Client Design (Test 27) + +Test 27 uses only one client (the presence member) rather than a second observer. Since from the server's perspective the member never left (we only injected ATTACHED on the client side without a real disconnect), the server does not broadcast a presence event to any observers. Verifying the re-entry via the proxy log is more reliable: the proxy log directly records every PRESENCE wire frame (action=14) sent from the SDK. + +### Real Disconnect Design (Test 28) + +Test 28 uses a temporal proxy rule (`delay_after_ws_connect` + `close`) to close the WebSocket 3 seconds after it opens. This gives enough time for the initial attach and presence enter to complete before the disconnect. On reconnect, a second proxy rule intercepts the 2nd ATTACHED on the channel (count: 2) and replaces it with a non-resumed message, triggering RTP17i re-entry. + +### clientId and Authentication + +Both tests use `authCallback` with `generateJWT` that includes the `clientId: "client-a"` claim. This avoids passing `clientId` directly on `ClientOptions` (which can trigger unexpected token auth flows) and provides direct control over the identity used for presence. + +### Presence Action on Re-entry + +Per RTP17g, the SDK sends a PRESENCE message with action ENTER (wire value 2). The proxy log captures this wire-level message. The assertion checks `presence[0].action == 2` directly on the frame in the proxy log. + +### Proxy Log Frame Structure + +Each `ws_frame` log entry has the shape: + +```pseudo +{ + type: "ws_frame", + direction: "client_to_server" | "server_to_client", + timestamp: , + message: { + action: , # 14 == PRESENCE + channel: , + presence: [ # present for PRESENCE messages + { + clientId: , + data: , + action: # 2 == ENTER + } + ] + } +} +``` + +### Timeout Handling + +All `AWAIT_STATE` and `POLL_UNTIL` calls use generous timeouts because real network traffic is involved: +- Connection to CONNECTED: 15 seconds +- Channel attach: implicit in the `AWAIT channel.attach()` call +- Disconnect detection: 10 seconds +- Presence re-entry poll: 10 seconds +- Cleanup close: implicit in `session.close()` + +### Channel Names + +Each test uses a unique channel name with a random component to avoid interference between tests running in the same sandbox app. diff --git a/uts/realtime/integration/proxy/rest_faults.md b/uts/realtime/integration/proxy/rest_faults.md new file mode 100644 index 000000000..e93ce3cf9 --- /dev/null +++ b/uts/realtime/integration/proxy/rest_faults.md @@ -0,0 +1,389 @@ +# REST Fault Proxy Integration Tests + +Spec points: `RSC10`, `RSC15m`, `REC2c2`, `RTL6` + +## Test Type + +Proxy integration test against Ably Sandbox endpoint + +## Proxy Infrastructure + +See `uts/test/realtime/integration/helpers/proxy.md` for the full proxy infrastructure specification. + +## Corresponding Unit Tests + +- `uts/test/rest/unit/auth/token_renewal.md` -- RSC10 (unit test verifies token renewal logic with mocked HTTP) +- `uts/test/rest/unit/fallback.md` -- RSC15m/REC2c2 (unit test verifies fallback/error handling with mocked HTTP) +- `uts/test/realtime/unit/channels/channel_publish.md` -- RTL6 (unit test verifies publish request formation) + +## Sandbox Setup + +Tests run against the Ably Sandbox via a programmable proxy. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + # Provision test app + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + # Clean up test app + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Common Cleanup + +```pseudo +AFTER EACH TEST: + IF client IS NOT null: + # For Realtime clients, close the connection + IF client HAS connection AND client.connection.state IN [connected, connecting, disconnected]: + client.connection.close() + AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10 seconds + IF session IS NOT null: + session.close() +``` + +### Token Auth Helper + +```pseudo +function request_token_from_sandbox(api_key): + # Create a temporary Rest client pointed directly at the sandbox (bypassing the proxy) + # and use it to obtain a TokenDetails object + inner_rest = Rest(options: ClientOptions( + key: api_key, + endpoint: SANDBOX_ENDPOINT + )) + token_details = AWAIT inner_rest.auth.requestToken() + RETURN token_details # TokenDetails +``` + +Note: The sandbox endpoint is used directly (not through the proxy) so that token requests are never intercepted by proxy fault-injection rules. + +--- + +## Test 18: RSC10 -- Token renewal on HTTP 401 (40142) + +**Test ID**: `realtime/proxy/RSC10/token-renewal-on-401-0` + +| Spec | Requirement | +|------|-------------| +| RSC10 | When a REST request receives a 401 with a token error (40140-40149), the SDK should renew the token and retry the request | + +Tests that when an authenticated REST request receives an HTTP 401 with error code 40142 (token expired), the SDK transparently renews the token via `authCallback` and retries the request. The proxy returns 401 on the first HTTP request to a channel endpoint, then passes through subsequent requests. + +### Setup + +```pseudo +# Track authCallback invocations +auth_callback_count = 0 + +# Create proxy session that returns 401 on the first channel request +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "http_request", "pathContains": "/channels/" }, + "action": { + "type": "http_respond", + "status": 401, + "body": { "error": { "code": 40142, "statusCode": 401, "message": "Token expired" } } + }, + "times": 1, + "comment": "RSC10: Return 401 on first channel request, then passthrough" + }] +) + +# Use token auth with authCallback so the SDK can renew. +# The authCallback creates its own inner Rest client pointed directly at the sandbox +# to obtain a token, bypassing the proxy entirely. +client = Rest(options: ClientOptions( + authCallback: (params, cb) => { + auth_callback_count++ + inner_rest = Rest(options: ClientOptions( + key: api_key, + endpoint: SANDBOX_ENDPOINT + )) + inner_rest.auth.requestToken().then( + (token) => cb(null, token), + (err) => cb(err, null) + ) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false +)) + +channel_name = "test-RSC10-token-renewal-" + random_string() +channel = client.channels.get(channel_name) +``` + +### Test Steps + +```pseudo +# Publish a message -- first request gets 401, SDK renews token, retries +result = AWAIT channel.publish("test-event", "hello") + +# The publish should succeed (SDK transparently renewed and retried) +``` + +### Assertions + +```pseudo +# Publish completed successfully (no error thrown) +ASSERT result IS successful + +# authCallback was called at least twice (initial token + renewal after 401) +ASSERT auth_callback_count >= 2 + +# Proxy event log shows two HTTP requests to the channel endpoint +log = session.get_log() +http_requests = log.filter(e => e.type == "http_request" AND e.path CONTAINS "/channels/") +ASSERT http_requests.length >= 2 + +# First request was intercepted (got 401), second request passed through (got 2xx) +http_responses = log.filter(e => e.type == "http_response") +ASSERT http_responses[0].status == 401 +ASSERT http_responses[1].status IN [200, 201] +``` + +--- + +## Test 19: RSC15m / REC2c2 -- HTTP 503 error with fallback hosts disabled + +**Test ID**: `realtime/proxy/RSC15m/http-503-no-fallback-0` + +| Spec | Requirement | +|------|-------------| +| RSC15m | When the set of fallback domains is empty, failing HTTP requests that would have qualified for a retry against a fallback host will instead result in an error immediately | +| REC2c2 | Fallback hosts are automatically disabled when `endpoint` is set to an explicit hostname | + +Tests that when a REST request receives an HTTP 503 (Service Unavailable) and the client is configured with `endpoint: "localhost"` (which disables fallback hosts per REC2c2), the SDK returns the error to the caller without attempting fallback hosts. + +### Setup + +```pseudo +# Create proxy session that returns 503 on the first channel request +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "http_request", "pathContains": "/channels/" }, + "action": { + "type": "http_respond", + "status": 503, + "body": { "error": { "code": 50300, "statusCode": 503, "message": "Service temporarily unavailable" } } + }, + "times": 1, + "comment": "RSC15m: Return 503 on first channel request" + }] +) + +# Use token auth with authCallback (Basic auth is prohibited over non-TLS per RSC18). +# The authCallback creates its own inner Rest client pointed directly at the sandbox +# to obtain a token, bypassing the proxy entirely. +client = Rest(options: ClientOptions( + authCallback: (params, cb) => { + inner_rest = Rest(options: ClientOptions( + key: api_key, + endpoint: SANDBOX_ENDPOINT + )) + inner_rest.auth.requestToken().then( + (token) => cb(null, token), + (err) => cb(err, null) + ) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false +)) + +channel_name = "test-RSC15m-503-error-" + random_string() +channel = client.channels.get(channel_name) +``` + +### Test Steps + +```pseudo +# Try to publish a message -- should fail with 503 error +AWAIT channel.publish("test-event", "hello") FAILS WITH error +``` + +### Assertions + +```pseudo +# The error propagates to the caller with the correct error code +ASSERT error.code == 50300 +ASSERT error.statusCode == 503 + +# Proxy event log shows only one HTTP request to the channel endpoint +# (no fallback attempts since endpoint="localhost" disables fallback hosts) +log = session.get_log() +http_requests = log.filter(e => e.type == "http_request" AND e.path CONTAINS "/channels/") +ASSERT http_requests.length == 1 +``` + +--- + +## Test 20: RTL6 -- End-to-end publish and history through proxy + +**Test ID**: `realtime/proxy/RTL6/publish-history-through-proxy-0` + +| Spec | Requirement | +|------|-------------| +| RTL6 | Messages published via a Realtime connection should be deliverable and retrievable | + +Tests that the proxy transparently forwards both WebSocket and HTTP traffic without interfering with normal operation. A Realtime client publishes a message through the proxy, and a REST client retrieves it via channel history, also through the proxy. This is a "golden path" test that validates the proxy infrastructure itself. + +### Setup + +```pseudo +# Create proxy session with no rules (pure passthrough) +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [] +) + +# Derive key parts for JWT signing +key_name = api_key.split(":")[0] +key_secret = api_key.split(":")[1] + +# Create Realtime client through proxy for publishing. +# Uses a JWT authCallback: the callback signs a JWT locally (no outbound request needed). +realtime_client = Realtime(options: ClientOptions( + authCallback: (params, cb) => { + cb(null, generateJWT({ keyName: key_name, keySecret: key_secret })) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +# Create REST client through proxy for history retrieval. +# Also uses JWT authCallback for the same reason. +rest_client = Rest(options: ClientOptions( + authCallback: (params, cb) => { + cb(null, generateJWT({ keyName: key_name, keySecret: key_secret })) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false +)) + +channel_name = "test-RTL6-publish-history-" + random_string() +realtime_channel = realtime_client.channels.get(channel_name) +rest_channel = rest_client.channels.get(channel_name) +``` + +### Test Steps + +```pseudo +# Connect Realtime client through proxy and wait until connected +AWAIT connectAndWait(realtime_client) + WITH timeout: 15 seconds +# connectAndWait() calls realtime_client.connect() and resolves once the connection +# reaches the CONNECTED state (or rejects on FAILED/SUSPENDED). + +# Attach to the channel +AWAIT realtime_channel.attach() +AWAIT_STATE realtime_channel.state == ChannelState.attached + WITH timeout: 10 seconds + +# Publish a message via Realtime +AWAIT realtime_channel.publish("test-msg", "hello world") + +# Poll history via REST until the published message appears. +# History is eventually consistent so a single immediate read may return nothing. +history = AWAIT pollUntil( + condition: () => { + result = AWAIT rest_channel.history() + RETURN result.items.length > 0 ? result : null + }, + interval: 500ms, + timeout: 10 seconds +) +``` + +### Assertions + +```pseudo +# History contains the published message +ASSERT history.items.length >= 1 + +# Find the published message in history +published_msg = history.items.find(m => m.name == "test-msg") +ASSERT published_msg IS NOT null +ASSERT published_msg.data == "hello world" + +# Proxy event log shows both WebSocket and HTTP traffic +log = session.get_log() + +# At least one WebSocket connection was made (Realtime client) +ws_connects = log.filter(e => e.type == "ws_connect") +ASSERT ws_connects.length >= 1 + +# At least one HTTP request was made (REST history call + token requests) +http_requests = log.filter(e => e.type == "http_request") +ASSERT http_requests.length >= 1 +``` + +### Cleanup + +```pseudo +# Close the Realtime client +realtime_client.connection.close() +AWAIT_STATE realtime_client.connection.state == ConnectionState.closed + WITH timeout: 10 seconds +``` + +--- + +## Integration Test Notes + +### Timeout Handling + +All `AWAIT_STATE` calls use generous timeouts because real network traffic is involved: +- Connection to CONNECTED via proxy: 15 seconds (allows for auth + transport setup) +- Channel attach: 10 seconds +- History polling: 10 seconds (allows for eventual consistency) +- Cleanup close: 10 seconds + +### Authentication Through Proxy + +All tests use `authCallback` rather than API key auth. This is required because: +1. `tls: false` is needed for proxy tests (proxy serves plain HTTP/WS with TLS only upstream) +2. RSC18 prohibits Basic auth over non-TLS connections +3. `authCallback` makes tokens renewable, which is needed for RSC10 (token renewal test) + +**RSC10 and RSC15m** use a token-based `authCallback`: each invocation creates a temporary inner `Rest` client pointed directly at the sandbox (using `endpoint: SANDBOX_ENDPOINT` with the full API key) and calls `auth.requestToken()`. The resulting `TokenDetails` is returned to the SDK. Only the SDK's own HTTP/WebSocket traffic goes through the proxy — inner token requests bypass it entirely. + +**RTL6** uses a JWT `authCallback` for both the Realtime and REST clients: each invocation calls a local `generateJWT({ keyName, keySecret })` helper and returns the signed JWT directly, with no outbound network call from the callback itself. + +### Fallback Host Behaviour + +With `endpoint: "localhost"`, fallback hosts are automatically disabled (REC2c2). This means: +- RSC15m/REC2c2: The SDK will NOT attempt fallback hosts after a 5xx error when fallback hosts are disabled +- The error propagates directly to the caller +- The proxy log will show only a single HTTP request (no fallback attempts) + +### Why Proxy Tests for REST Faults + +These tests verify behaviour that unit tests cover with mocked HTTP, but provide higher confidence because: +1. **Real HTTP connections** -- the SDK's actual HTTP client is exercised through the proxy +2. **Real token renewal** -- RSC10 exercises the full authCallback flow against the sandbox +3. **Real error responses** -- the proxy returns correctly-formatted HTTP error responses +4. **End-to-end verification** -- RTL6 confirms publish and history work through the proxy infrastructure diff --git a/uts/realtime/unit/auth/auth_callback_errors_test.md b/uts/realtime/unit/auth/auth_callback_errors_test.md new file mode 100644 index 000000000..21f348d28 --- /dev/null +++ b/uts/realtime/unit/auth/auth_callback_errors_test.md @@ -0,0 +1,664 @@ +# Auth Callback Error Handling Tests + +Spec points: `RSA4c`, `RSA4c2`, `RSA4c3`, `RSA4d`, `RSA4e`, `RSA4f` + +## Test Type +Unit test with mocked WebSocket client and authCallback (realtime tests); unit test with mocked HTTP client (REST test for RSA4e) + +## Mock Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. +See `rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. + +## Purpose + +These tests verify error handling when authentication via authCallback fails in various ways. The behaviour depends on: +- The type of error (generic error vs 403 vs invalid format vs timeout) +- The connection state when the error occurs (CONNECTING vs CONNECTED) +- Whether the context is realtime (connection state machine) or REST (request error) + +Key behaviours: +- Generic auth errors while CONNECTING -> DISCONNECTED with code 80019 (RSA4c2) +- Generic auth errors while CONNECTED -> stay CONNECTED, no side effects (RSA4c3) +- 403 errors -> FAILED with code 80019/statusCode 403 (RSA4d) +- Invalid token format -> treated as auth error per RSA4c (RSA4f) +- REST auth errors -> error with code 40170 (RSA4e) + +--- + +## RSA4c2 - authCallback error during CONNECTING transitions to DISCONNECTED + +**Test ID**: `realtime/unit/RSA4c2/callback-error-connecting-disconnected-0` + +| Spec | Requirement | +|------|-------------| +| RSA4c | If an attempt to authenticate using authCallback results in an error, then RSA4c2/3 apply | +| RSA4c2 | If the connection is CONNECTING, then the connection attempt should be treated as unsuccessful, and the connection should transition to DISCONNECTED or SUSPENDED. An ErrorInfo with code 80019, statusCode 401, and cause set to the underlying cause should be emitted with the state change and set as the connection errorReason | + +Tests that when authCallback throws an error during the initial connection (CONNECTING state), the connection transitions to DISCONNECTED with an ErrorInfo having code 80019, statusCode 401, and cause set to the underlying error. + +### Setup + +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + IF auth_callback_count == 1: + THROW ErrorInfo(code: 50000, statusCode: 500, message: "Auth server unavailable") + ELSE: + RETURN TokenDetails( + token: "valid-token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +state_changes = [] +client.connection.on((change) => { + state_changes.append(change) +}) + +client.connect() + +# authCallback fails on first attempt — connection should go to DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# RSA4c2: Connection transitioned to DISCONNECTED (not FAILED — it's retriable) +ASSERT client.connection.state == ConnectionState.disconnected + +# RSA4c2: errorReason has code 80019 wrapping the underlying cause +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +ASSERT client.connection.errorReason.statusCode == 401 + +# RSA4c2: cause is set to the underlying error from authCallback +ASSERT client.connection.errorReason.cause IS NOT null +ASSERT client.connection.errorReason.cause.code == 50000 + +# State change event carries the same error +disconnected_changes = state_changes.filter(c => c.current == ConnectionState.disconnected) +ASSERT disconnected_changes.length >= 1 +ASSERT disconnected_changes[0].reason IS NOT null +ASSERT disconnected_changes[0].reason.code == 80019 + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4c2 - authCallback timeout during CONNECTING transitions to DISCONNECTED + +**Test ID**: `realtime/unit/RSA4c2/callback-timeout-connecting-disconnected-1` + +| Spec | Requirement | +|------|-------------| +| RSA4c | If the attempt times out after realtimeRequestTimeout, then RSA4c2/3 apply | +| RSA4c2 | If the connection is CONNECTING, then the connection attempt should be treated as unsuccessful. An ErrorInfo with code 80019, statusCode 401, and cause set to the underlying cause should be emitted with the state change and set as the connection errorReason | + +Tests that when authCallback times out (exceeds realtimeRequestTimeout), the connection transitions to DISCONNECTED with error code 80019. + +### Setup + +```pseudo +enable_fake_timers() + +auth_callback = FUNCTION(params): + # Never returns — simulates a timeout + RETURN NEVER_RESOLVING_FUTURE + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + realtimeRequestTimeout: 10000, + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +state_changes = [] +client.connection.on((change) => { + state_changes.append(change) +}) + +client.connect() + +# Advance time past realtimeRequestTimeout +ADVANCE_TIME(11000) + +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# RSA4c2: Connection transitioned to DISCONNECTED +ASSERT client.connection.state == ConnectionState.disconnected + +# RSA4c2: errorReason has code 80019 +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +ASSERT client.connection.errorReason.statusCode == 401 + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4c3 - authCallback error while CONNECTED leaves connection CONNECTED + +**Test ID**: `realtime/unit/RSA4c3/callback-error-connected-stays-0` + +| Spec | Requirement | +|------|-------------| +| RSA4c | If an attempt to authenticate using authCallback results in an error | +| RSA4c3 | If the connection is CONNECTED, then the connection should remain CONNECTED | + +Tests that when authCallback fails during an RTN22 server-initiated reauth while the connection is CONNECTED, the connection stays CONNECTED with no side effects — no state change, no event, and errorReason is not set. The failed renewal is silently swallowed; when the token eventually expires, the next renewal attempt will surface the failure via the normal connection state machine. + +See https://github.com/ably/specification/issues/466 + +### Setup + +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + IF auth_callback_count == 1: + # First call succeeds (initial connection) + RETURN TokenDetails( + token: "initial-token", + expires: now() + 3600000 + ) + ELSE: + # Subsequent calls fail (reauth triggered by server) + THROW ErrorInfo(code: 50000, statusCode: 500, message: "Auth server unavailable") + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Record state changes from this point +state_changes = [] +client.connection.on((change) => state_changes.append(change)) + +# Server requests re-authentication (RTN22) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: AUTH +)) + +# Allow time for auth callback failure to propagate +AWAIT UNTIL auth_callback_count >= 2 + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# RSA4c3: Connection remains CONNECTED +ASSERT client.connection.state == ConnectionState.connected + +# No state changes at all — the auth failure is silently swallowed +ASSERT state_changes.length == 0 + +# errorReason is NOT set — the connection is healthy, the existing token is +# still valid, and there is no state change to associate the error with +ASSERT client.connection.errorReason IS null + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4d - authCallback returns 403 error during CONNECTING transitions to FAILED + +**Test ID**: `realtime/unit/RSA4d/callback-403-connecting-failed-0` + +| Spec | Requirement | +|------|-------------| +| RSA4d | If an authCallback results in an ErrorInfo with statusCode 403, the client library should transition to the FAILED state, with an ErrorInfo (code 80019, statusCode 403, cause set to the underlying cause) emitted with the state change and set as the connection errorReason | + +Tests that a 403 from authCallback during initial connection is treated as fatal and causes the connection to transition directly to FAILED (not DISCONNECTED). + +### Setup + +```pseudo +connection_attempted = false + +auth_callback = FUNCTION(params): + THROW ErrorInfo(code: 40300, statusCode: 403, message: "Account disabled") + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempted = true + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +state_changes = [] +client.connection.on((change) => { + state_changes.append(change) +}) + +client.connect() + +# authCallback returns 403 — connection should go directly to FAILED +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# RSA4d: Connection went to FAILED (not DISCONNECTED) +ASSERT client.connection.state == ConnectionState.failed + +# No WebSocket connection was attempted (auth failed before transport) +ASSERT connection_attempted == false + +# RSA4d: ErrorInfo has code 80019 and statusCode 403 +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +ASSERT client.connection.errorReason.statusCode == 403 + +# Cause is the original 403 error +ASSERT client.connection.errorReason.cause IS NOT null +ASSERT client.connection.errorReason.cause.code == 40300 +ASSERT client.connection.errorReason.cause.statusCode == 403 + +# State change event carries the error +failed_changes = state_changes.filter(c => c.current == ConnectionState.failed) +ASSERT failed_changes.length == 1 +ASSERT failed_changes[0].reason IS NOT null +ASSERT failed_changes[0].reason.code == 80019 +ASSERT failed_changes[0].reason.statusCode == 403 + +# No DISCONNECTED state was reached (went directly to FAILED) +disconnected_changes = state_changes.filter(c => c.current == ConnectionState.disconnected) +ASSERT disconnected_changes.length == 0 + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4d - authCallback 403 during RTN22 reauth transitions CONNECTED to FAILED + +**Test ID**: `realtime/unit/RSA4d/callback-403-reauth-failed-1` + +| Spec | Requirement | +|------|-------------| +| RSA4d | If an authCallback results in an ErrorInfo with statusCode 403 as part of an attempt to authenticate, the client library should transition to the FAILED state | +| RSA4d1 | An "attempt to authenticate" includes an RTN22 online reauth | + +Tests that a 403 from authCallback during server-initiated reauth (RTN22) causes the connection to transition from CONNECTED to FAILED, overriding RSA4c3. + +### Setup + +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + IF auth_callback_count == 1: + # First call succeeds (initial connection) + RETURN TokenDetails( + token: "initial-token", + expires: now() + 3600000 + ) + ELSE: + # Reauth fails with 403 + THROW ErrorInfo(code: 40300, statusCode: 403, message: "Account suspended") + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Server requests re-authentication (RTN22) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: AUTH +)) + +# authCallback returns 403 — connection should go to FAILED +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# RSA4d: FAILED with code 80019 and statusCode 403 +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +ASSERT client.connection.errorReason.statusCode == 403 +ASSERT client.connection.errorReason.cause IS NOT null +ASSERT client.connection.errorReason.cause.code == 40300 + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4f - authCallback returns invalid type treated as invalid format error + +**Test ID**: `realtime/unit/RSA4f/callback-invalid-type-format-0` + +| Spec | Requirement | +|------|-------------| +| RSA4f | The following conditions imply that the token is in an invalid format: the object passed by authCallback is neither a String, JsonObject, TokenRequest object, nor TokenDetails object | +| RSA4c | If the provided token is in an invalid format (as defined in RSA4f), then RSA4c2/3 apply | +| RSA4c2 | If the connection is CONNECTING, the connection should transition to DISCONNECTED or SUSPENDED. An ErrorInfo with code 80019, statusCode 401, and cause set to the underlying cause should be emitted with the state change and set as the connection errorReason | + +Tests that when authCallback returns an object that is not a String, JsonObject, TokenRequest, or TokenDetails (e.g. an integer or a list), it is treated as an invalid format error per RSA4f, and the connection transitions to DISCONNECTED with error code 80019 per RSA4c. + +### Setup + +```pseudo +auth_callback = FUNCTION(params): + # Return an invalid type — an integer is not a valid token format + RETURN 12345 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +client.connect() + +# Invalid format from authCallback — connection should go to DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# RSA4c2: Connection transitioned to DISCONNECTED +ASSERT client.connection.state == ConnectionState.disconnected + +# RSA4c2: errorReason has code 80019 +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +ASSERT client.connection.errorReason.statusCode == 401 + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4f - authCallback returns token string exceeding 128KiB treated as invalid format + +**Test ID**: `realtime/unit/RSA4f/callback-oversized-token-format-1` + +| Spec | Requirement | +|------|-------------| +| RSA4f | The token string or the JSON stringified JsonObject, TokenRequest or TokenDetails is greater than 128KiB implies the token is in an invalid format | +| RSA4c | If the provided token is in an invalid format (as defined in RSA4f), then RSA4c2/3 apply | + +Tests that when authCallback returns a token string larger than 128KiB, it is treated as an invalid format error per RSA4f and the connection transitions to DISCONNECTED with error code 80019. + +### Setup + +```pseudo +# Generate a token string larger than 128KiB (131072 bytes) +oversized_token = "x" * 131073 + +auth_callback = FUNCTION(params): + RETURN oversized_token + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +client.connect() + +# Oversized token — connection should go to DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# RSA4c2: Connection transitioned to DISCONNECTED +ASSERT client.connection.state == ConnectionState.disconnected + +# RSA4c2: errorReason has code 80019 +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +ASSERT client.connection.errorReason.statusCode == 401 + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4e - REST authCallback error produces error with code 40170 + +**Test ID**: `realtime/unit/RSA4e/rest-callback-error-40170-0` + +| Spec | Requirement | +|------|-------------| +| RSA4e | If in the course of a REST request an attempt to authenticate using authCallback fails due to a timeout, network error, a token in an invalid format (per RSA4f), or some other auth error condition other than an explicit ErrorInfo from Ably, the request should result in an error with code 40170, statusCode 401, and a suitable error message | + +Tests that when a REST client's authCallback fails with a non-Ably error (e.g. a generic exception), the resulting request error has code 40170 and statusCode 401. + +### Setup + +```pseudo +auth_callback = FUNCTION(params): + # Generic error — not an explicit ErrorInfo from Ably + THROW Error("Network failure connecting to auth server") + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authCallback: auth_callback, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +# Attempt a REST request that requires authentication +channel = client.channels.get("test-channel") +AWAIT channel.status() FAILS WITH error +``` + +### Assertions + +```pseudo +# RSA4e: Error has code 40170 and statusCode 401 +ASSERT error.code == 40170 +ASSERT error.statusCode == 401 + +# Error message should be descriptive +ASSERT error.message IS NOT null +ASSERT error.message.length > 0 +``` + +--- + +## Notes + +- **RSA4c vs RSA4d precedence:** RSA4d (403 -> FAILED) takes precedence over RSA4c (generic error -> DISCONNECTED). The spec says RSA4c applies "unless RSA4d applies." +- **RSA4d1 scope:** The 403 -> FAILED behaviour applies to connect sequence auth, RTN22 reauth, and explicit `authorize()` calls, but NOT to explicit `requestToken` calls. +- **RSA4e context:** RSA4e applies specifically to REST requests and explicit `requestToken` calls. For realtime, RSA4c applies instead. +- **RSA4c1 removal (specification#466):** RSA4c1 (ErrorInfo with code 80019) has been absorbed into RSA4c2. In the RSA4c3 case (auth failure while CONNECTED), errorReason is NOT set — the connection is healthy and the failure is silently swallowed until the token expires. +- **Overlap with connection_auth_test.md:** The existing `connection_auth_test.md` already covers RSA4c2 (authCallback error -> DISCONNECTED), RSA4c3 (authCallback error while CONNECTED), and RSA4d (403 -> FAILED). The tests in this file provide additional coverage for timeout scenarios, invalid format handling (RSA4f), and REST-specific behaviour (RSA4e). diff --git a/uts/realtime/unit/auth/connection_auth_test.md b/uts/realtime/unit/auth/connection_auth_test.md new file mode 100644 index 000000000..e52997731 --- /dev/null +++ b/uts/realtime/unit/auth/connection_auth_test.md @@ -0,0 +1,602 @@ +# Realtime Connection Authentication Tests + +Spec points: `RTN2e`, `RTN27b`, `RSA4`, `RSA4c`, `RSA4c1`, `RSA4c2`, `RSA4c3`, `RSA4d`, `RSA8d`, `RSA12a` + +## Test Type +Unit test with mocked WebSocket client and authCallback + +## Mock Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Purpose + +These tests verify realtime-specific authentication behavior for establishing and maintaining WebSocket connections. While general auth behavior (RSA1-17) is tested in `rest/unit/auth/`, these tests focus on how token authentication integrates with the realtime connection lifecycle. + +Key behaviors tested: +- Token acquisition occurs **before** WebSocket connection attempts (RTN2e, RTN27b) +- Token is included in WebSocket URL query parameters (RTN2e) +- Token caching and expiry handling for connection attempts +- authCallback integration with connection state machine + +--- + +## RTN2e/RTN27b - Token obtained before WebSocket connection + +**Test ID**: `realtime/unit/RTN2e/token-before-websocket-0` + +**Spec requirement:** When `authCallback` is configured but no token is provided, the library must obtain a token via the callback **before** opening the WebSocket connection. The token is then included in the WebSocket URL as the `accessToken` query parameter. + +This is implied by: +- RTN2e: "Depending on the authentication scheme, either `accessToken` contains the token string, or `key` contains the API key" +- RTN27b: "CONNECTING - the state whenever the library is actively attempting to connect to the server (whether trying to obtain a token, trying to open a transport, or waiting for a CONNECTED event)" + +Tests that when `authCallback` is configured without an existing token, the library: +1. Transitions to CONNECTING state +2. Invokes the authCallback to obtain a token +3. Opens WebSocket connection with the token in the URL +4. Does NOT make a connection attempt before obtaining the token + +### Setup + +```pseudo +callback_invoked = false +callback_invoked_time = null +connection_attempt_time = null +captured_ws_url = null + +auth_callback = FUNCTION(params): + callback_invoked = true + callback_invoked_time = current_time() + RETURN TokenDetails( + token: "callback-provided-token", + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_time = current_time() + captured_ws_url = conn.url + + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +# Client with authCallback but NO existing token +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# authCallback was invoked +ASSERT callback_invoked == true + +# authCallback was invoked BEFORE WebSocket connection attempt +ASSERT callback_invoked_time < connection_attempt_time + +# WebSocket URL contains the token from authCallback +ASSERT captured_ws_url.queryParameters["accessToken"] == "callback-provided-token" + +# WebSocket URL does NOT contain a key parameter (using token auth, not basic auth) +ASSERT captured_ws_url.queryParameters["key"] IS null + +# Connection succeeded +ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) +``` + +--- + +## RTN2e/RTN27b - authCallback error prevents connection attempt + +**Test ID**: `realtime/unit/RTN2e/callback-error-prevents-connect-1` + +**Spec requirement:** If `authCallback` fails during the initial token acquisition, the library should NOT attempt to open a WebSocket connection. + +Tests that authCallback errors are handled before any connection attempt is made. + +### Setup + +```pseudo +connection_attempted = false + +auth_callback = FUNCTION(params): + THROW Error("Auth callback failed") + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempted = true + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for DISCONNECTED or FAILED state +AWAIT_STATE client.connection.state IN [ConnectionState.disconnected, ConnectionState.failed] + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# No WebSocket connection was attempted +ASSERT connection_attempted == false + +# Error reason is set +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.statusCode == 401 + OR client.connection.errorReason.code == 40170 +CLOSE_CLIENT(client) +``` + +--- + +## RTN2e - authCallback TokenParams include clientId + +**Test ID**: `realtime/unit/RTN2e/callback-params-include-clientid-2` + +**Spec requirement:** When invoking `authCallback`, the library passes `TokenParams` that include any configured `clientId`. + +Tests that clientId is passed to authCallback via TokenParams (per RSA12a). + +### Setup + +```pseudo +received_params = null + +auth_callback = FUNCTION(params): + received_params = params + RETURN TokenDetails( + token: "token-for-client", + expires: now() + 3600000, + clientId: "my-client-id" + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + clientId: "my-client-id", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# authCallback received TokenParams with clientId +ASSERT received_params IS NOT null +ASSERT received_params.clientId == "my-client-id" +CLOSE_CLIENT(client) +``` + +--- + +## RTN2e - Multiple connections reuse valid token + +**Test ID**: `realtime/unit/RTN2e/reuse-valid-token-3` + +**Spec requirement:** If a valid (non-expired) token exists from a previous authCallback invocation, it should be reused for subsequent connection attempts without invoking authCallback again. + +Tests that valid tokens are cached and reused. + +### Setup + +```pseudo +callback_count = 0 + +auth_callback = FUNCTION(params): + callback_count++ + RETURN TokenDetails( + token: "reusable-token", + expires: now() + 3600000 # Valid for 1 hour + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# First connection +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Disconnect +client.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + +# Second connection +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# authCallback was only invoked once (token was reused) +ASSERT callback_count == 1 +CLOSE_CLIENT(client) +``` + +--- + +## RSA4c2 - authCallback error during CONNECTING causes DISCONNECTED + +**Test ID**: `realtime/unit/RSA4c2/callback-error-causes-disconnected-0` + +**Spec requirement (RSA4c):** If an attempt to authenticate using authCallback results in an error, then: +- **(RSA4c1)** An ErrorInfo with code 80019, statusCode 401, and cause set to the underlying cause should be emitted and set as the connection errorReason. +- **(RSA4c2)** If the connection is CONNECTING, the connection attempt should be treated as unsuccessful, transitioning to DISCONNECTED. + +Tests that when authCallback fails during initial connection, the client transitions to DISCONNECTED with error code 80019, and the underlying cause is preserved. + +### Setup +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + IF auth_callback_count == 1: + THROW ErrorInfo(code: 50000, statusCode: 500, message: "Auth server unavailable") + ELSE: + RETURN TokenDetails( + token: "valid-token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() + +# authCallback fails on first attempt — connection should go to DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds +``` + +### Assertions +```pseudo +# RSA4c1: errorReason has code 80019 wrapping the underlying cause +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +ASSERT client.connection.errorReason.statusCode == 401 +ASSERT client.connection.errorReason.cause IS NOT null +ASSERT client.connection.errorReason.cause.code == 50000 +CLOSE_CLIENT(client) +``` + +--- + +## RSA4c3 - authCallback error while CONNECTED leaves connection CONNECTED + +**Test ID**: `realtime/unit/RSA4c3/callback-error-stays-connected-0` + +**Spec requirement (RSA4c3):** If the connection is CONNECTED when an auth attempt fails, then the connection should remain CONNECTED. + +Tests that when authCallback fails during an RTN22 server-initiated reauth, the connection stays CONNECTED and errorReason is set with code 80019. + +### Setup +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + IF auth_callback_count == 1: + # First call succeeds (initial connection) + RETURN TokenDetails( + token: "initial-token", + expires: now() + 3600000 + ) + ELSE: + # Subsequent calls fail (reauth) + THROW ErrorInfo(code: 50000, statusCode: 500, message: "Auth server unavailable") + +captured_auth_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Record state changes +state_changes = [] +client.connection.on((change) => state_changes.append(change)) + +# Server requests re-authentication (RTN22) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: AUTH +)) + +# Wait for errorReason to be set (auth failure propagates asynchronously) +AWAIT UNTIL client.connection.errorReason IS NOT null + WITH timeout: 5 seconds +``` + +### Assertions +```pseudo +# RSA4c3: Connection remains CONNECTED +ASSERT client.connection.state == ConnectionState.connected + +# No state transitions away from connected occurred +non_connected_changes = state_changes.filter( + c => c.current != ConnectionState.connected +) +ASSERT non_connected_changes.length == 0 + +# RSA4c1: errorReason has code 80019 wrapping the underlying cause +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +ASSERT client.connection.errorReason.statusCode == 401 +ASSERT client.connection.errorReason.cause IS NOT null +ASSERT client.connection.errorReason.cause.code == 50000 + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4d - authCallback 403 error causes FAILED + +**Test ID**: `realtime/unit/RSA4d/callback-403-causes-failed-0` + +**Spec requirement (RSA4d):** If an authCallback results in an ErrorInfo with statusCode 403, the client library should transition to the FAILED state, with an ErrorInfo (code 80019, statusCode 403, cause set to the underlying cause). + +Tests that a 403 from authCallback is treated as fatal and causes FAILED state. + +### Setup +```pseudo +auth_callback = FUNCTION(params): + THROW ErrorInfo(code: 40300, statusCode: 403, message: "Account disabled") + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() + +# authCallback returns 403 — connection should go to FAILED +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions +```pseudo +# RSA4d: FAILED with code 80019 and statusCode 403 +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +ASSERT client.connection.errorReason.statusCode == 403 +ASSERT client.connection.errorReason.cause IS NOT null +ASSERT client.connection.errorReason.cause.code == 40300 + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4d - authCallback 403 during RTN22 reauth causes FAILED + +**Test ID**: `realtime/unit/RSA4d/callback-403-reauth-causes-failed-1` + +**Spec requirement (RSA4d):** If an authCallback results in an ErrorInfo with statusCode 403 during an attempt to re-authenticate, the connection transitions to FAILED. + +Tests that a 403 from authCallback during server-initiated reauth (RTN22) causes FAILED, even though the connection was previously CONNECTED. + +### Setup +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + IF auth_callback_count == 1: + # First call succeeds (initial connection) + RETURN TokenDetails( + token: "initial-token", + expires: now() + 3600000 + ) + ELSE: + # Reauth fails with 403 + THROW ErrorInfo(code: 40300, statusCode: 403, message: "Account suspended") + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Server requests re-authentication (RTN22) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: AUTH +)) + +# authCallback returns 403 — connection should go to FAILED +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions +```pseudo +# RSA4d: FAILED with code 80019 and statusCode 403 +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +ASSERT client.connection.errorReason.statusCode == 403 +ASSERT client.connection.errorReason.cause IS NOT null +ASSERT client.connection.errorReason.cause.code == 40300 + +CLOSE_CLIENT(client) +``` + +--- + +## Notes + +These tests verify the **pre-connection** token acquisition flow and **auth failure handling** during the connection lifecycle. For token **renewal** after connection failures (e.g., 401 errors from server), see: +- `../connection/connection_open_failures_test.md` (RTN14b) +- `../connection/connection_failures_test.md` (RTN15h2) diff --git a/uts/realtime/unit/auth/realtime_authorize.md b/uts/realtime/unit/auth/realtime_authorize.md new file mode 100644 index 000000000..33a748037 --- /dev/null +++ b/uts/realtime/unit/auth/realtime_authorize.md @@ -0,0 +1,940 @@ +# Realtime Authorize Tests + +Spec points: `RTC8`, `RTC8a`, `RTC8a1`, `RTC8a2`, `RTC8a3`, `RTC8b`, `RTC8b1`, `RTC8c` + +## Test Type +Unit test with mocked WebSocket client and authCallback + +## Mock Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Purpose + +These tests verify in-band reauthorization via `auth.authorize()` on a realtime client. +When called on a connected client, `authorize()` obtains a new token and sends an `AUTH` +protocol message to Ably. Ably responds with either a `CONNECTED` message (success, +emitting an UPDATE event) or an `ERROR` message (failure). The behaviour varies based +on the current connection state when `authorize()` is called. + +--- + +## RTC8a - authorize() on CONNECTED sends AUTH protocol message + +**Test ID**: `realtime/unit/RTC8a/authorize-connected-sends-auth-0` + +| Spec | Requirement | +|------|-------------| +| RTC8 | `auth.authorize` instructs the library to obtain a token and alter the current connection to use it | +| RTC8a | If CONNECTED, obtain a new token then send an AUTH ProtocolMessage with an auth attribute containing the token string | + +Tests that calling `authorize()` while connected obtains a new token via the +authCallback and sends an AUTH protocol message containing the new token. + +### Setup +```pseudo +auth_callback_count = 0 +captured_auth_messages = [] + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Track state changes during reauth +state_changes = [] +client.connection.on((change) => { + state_changes.append(change) +}) + +# When client sends AUTH, record it and respond with new CONNECTED +mock_ws.on_client_message((msg) => { + IF msg.action == AUTH: + captured_auth_messages.append(msg) + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key-2", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) +}) + +token_details = AWAIT client.auth.authorize() +``` + +### Assertions +```pseudo +# authCallback was called twice (initial connect + authorize) +ASSERT auth_callback_count == 2 + +# An AUTH protocol message was sent +ASSERT captured_auth_messages.length == 1 + +# AUTH message contains the new token +ASSERT captured_auth_messages[0].auth IS NOT null +ASSERT captured_auth_messages[0].auth.accessToken == "token-2" + +# authorize() resolved with the new token +ASSERT token_details.token == "token-2" + +# UPDATE events are emitted but are not state transitions (current == previous == connected) +state_transitions = state_changes.filter(c => c.current != c.previous) +ASSERT state_transitions.length == 0 +ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) +``` + +--- + +## RTC8a1 - Successful reauth emits UPDATE event + +**Test ID**: `realtime/unit/RTC8a1/successful-reauth-update-event-0` + +**Spec requirement:** If the authentication token change is successful, Ably sends a new CONNECTED ProtocolMessage. The connectionDetails must override existing defaults (RTN21). The Connection should emit an UPDATE event per RTN24. + +Tests that a successful in-band reauthorization emits an UPDATE event (not a +CONNECTED state change) and updates connection details. + +### Setup +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-1", + connectionKey: "connection-key-1", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Track events +update_events = [] +connected_events = [] +state_changes = [] + +client.connection.on(ConnectionEvent.update, (change) => { + update_events.append(change) +}) +client.connection.on(ConnectionState.connected, (change) => { + connected_events.append(change) +}) +client.connection.on((change) => { + state_changes.append(change) +}) + +# When client sends AUTH, respond with new CONNECTED (updated details) +mock_ws.on_client_message((msg) => { + IF msg.action == AUTH: + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-2", + connectionKey: "connection-key-2", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-2", + maxIdleInterval: 20000, + connectionStateTtl: 180000 + ) + )) +}) + +AWAIT client.auth.authorize() +``` + +### Assertions +```pseudo +# UPDATE event was emitted +ASSERT update_events.length == 1 +ASSERT update_events[0].previous == ConnectionState.connected +ASSERT update_events[0].current == ConnectionState.connected + +# No additional CONNECTED state event was emitted +ASSERT connected_events.length == 0 + +# UPDATE events are emitted but are not state transitions (current == previous == connected) +state_transitions = state_changes.filter(c => c.current != c.previous) +ASSERT state_transitions.length == 0 + +# Connection details were updated (RTN21) +# Note: Whether connection.id is updated from the reauth CONNECTED message +# is implementation-dependent. Some SDKs only set connection.id during initial +# transport activation. connection.key (via connectionDetails) MUST be updated +# per RTN21. +ASSERT client.connection.id == "connection-id-2" +ASSERT client.connection.key == "connection-key-2" +CLOSE_CLIENT(client) +``` + +--- + +## RTC8a1 - Capability downgrade causes channel FAILED + +**Test ID**: `realtime/unit/RTC8a1/capability-downgrade-channel-failed-1` + +**Spec requirement:** A test should exist where the capabilities are downgraded resulting in Ably sending an ERROR ProtocolMessage with a channel property, causing the channel to enter the FAILED state. The reason must be included in the channel state change event. + +Tests that after a successful reauth with reduced capabilities, Ably sends a +channel-level ERROR that causes the affected channel to enter FAILED state. + +### Setup +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach a channel +channel = client.channels.get("private-channel") + +mock_ws.on_client_message((msg) => { + IF msg.action == ATTACH AND msg.channel == "private-channel": + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: "private-channel", + flags: 0 + )) +}) + +channel.attach() +AWAIT_STATE channel.state == ChannelState.attached + +# Track channel state changes +channel_state_changes = [] +channel.on((change) => { + channel_state_changes.append(change) +}) + +# When client sends AUTH, respond with CONNECTED then channel-level ERROR +mock_ws.on_client_message((msg) => { + IF msg.action == AUTH: + # Reauth succeeds at connection level + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key-2", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + # Then server sends channel-level ERROR (capability downgrade) + # Implementation note: The channel-level ERROR must be delivered AFTER the + # CONNECTED message has been fully processed. Implementations may need to + # defer the ERROR delivery (e.g., via microtask/nextTick) to ensure ordering. + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ERROR, + channel: "private-channel", + error: ErrorInfo( + code: 40160, + statusCode: 401, + message: "Channel denied access based on given capability" + ) + )) +}) + +# Call authorize (to downgrade capabilities) +AWAIT client.auth.authorize() +AWAIT_STATE channel.state == ChannelState.failed +``` + +### Assertions +```pseudo +# Channel entered FAILED state +ASSERT channel.state == ChannelState.failed + +# Channel state change event includes the error reason +failed_changes = channel_state_changes.filter(c => c.current == ChannelState.failed) +ASSERT failed_changes.length == 1 +ASSERT failed_changes[0].reason IS NOT null +ASSERT failed_changes[0].reason.code == 40160 +ASSERT failed_changes[0].reason.statusCode == 401 + +# Connection remains CONNECTED (channel-level ERROR doesn't close connection) +ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) +``` + +--- + +## RTC8a2 - Failed reauth transitions connection to FAILED + +**Test ID**: `realtime/unit/RTC8a2/failed-reauth-connection-failed-0` + +**Spec requirement:** If the authentication token change fails, Ably will send an ERROR ProtocolMessage triggering the connection to transition to the FAILED state. A test should exist for a token change that fails (such as sending a new token with an incompatible clientId). + +Tests that a failed in-band reauthorization (e.g. incompatible clientId) causes +the connection to transition to FAILED. + +### Setup +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Track state changes +state_changes = [] +client.connection.on((change) => { + state_changes.append(change) +}) + +# When client sends AUTH, respond with connection-level ERROR (incompatible clientId) +mock_ws.on_client_message((msg) => { + IF msg.action == AUTH: + mock_ws.active_connection.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40012, + statusCode: 400, + message: "Incompatible clientId" + ) + )) +}) + +AWAIT client.auth.authorize() FAILS WITH error +ASSERT error.code == 40012 +``` + +### Assertions +```pseudo +# Connection transitioned to FAILED +ASSERT client.connection.state == ConnectionState.failed + +# Error reason is set on the connection +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 40012 + +# State changes include FAILED +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.failed +] +CLOSE_CLIENT(client) +``` + +--- + +## RTC8a3 - authorize() completes only after server response + +**Test ID**: `realtime/unit/RTC8a3/authorize-completes-after-response-0` + +**Spec requirement:** The authorize call should be indicated as completed with the new token or error only once realtime has responded to the AUTH with either a CONNECTED or ERROR respectively. + +Tests that the Future/Promise returned by `authorize()` does not resolve until +the server responds to the AUTH message. + +### Setup +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start authorize — do NOT await +authorize_future = client.auth.authorize() +authorize_completed = false +authorize_future.then((_) => { authorize_completed = true }) + +# Wait for the client to send the AUTH message (confirms token was obtained +# and AUTH was sent, but server hasn't responded yet) +auth_msg = AWAIT mock_ws.await_client_message(action: AUTH) + +# authorize() should NOT have completed yet (server hasn't responded) +ASSERT authorize_completed == false + +# Now send the server response +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key-2", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) +)) + +# Now await completion +token_details = AWAIT authorize_future +``` + +### Assertions +```pseudo +# authorize() completed after server response +ASSERT authorize_completed == true +ASSERT token_details.token == "token-2" +CLOSE_CLIENT(client) +``` + +--- + +## RTC8b - authorize() while CONNECTING halts current attempt + +**Test ID**: `realtime/unit/RTC8b/authorize-connecting-halts-attempt-0` + +**Spec requirement:** If the connection is in the CONNECTING state when auth.authorize is called, all current connection attempts should be halted, and after obtaining a new token the library should immediately initiate a connection attempt using the new token. + +Tests that calling `authorize()` while in the CONNECTING state cancels the +current connection attempt and reconnects with the new token. + +### Setup +```pseudo +auth_callback_count = 0 +captured_ws_urls = [] + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count = connection_attempt_count + 1 + captured_ws_urls.append(conn.url) + + IF connection_attempt_count == 1: + # First attempt: respond with success but delay CONNECTED + # (simulating CONNECTING state) + conn.respond_with_success() + # Don't send CONNECTED — client stays in CONNECTING + ELSE: + # Second attempt (after authorize): complete normally + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +# Start connection — will enter CONNECTING +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Call authorize while CONNECTING +token_details = AWAIT client.auth.authorize() +``` + +### Assertions +```pseudo +# authorize() completed successfully +ASSERT token_details.token == "token-2" + +# Connection is now CONNECTED +ASSERT client.connection.state == ConnectionState.connected + +# authCallback was called twice (initial + authorize) +ASSERT auth_callback_count == 2 + +# Two connection attempts were made +ASSERT connection_attempt_count == 2 + +# Second attempt used the new token +ASSERT captured_ws_urls[1].queryParameters["accessToken"] == "token-2" +CLOSE_CLIENT(client) +``` + +--- + +## RTC8b1 - authorize() while CONNECTING fails on FAILED state + +**Test ID**: `realtime/unit/RTC8b1/authorize-connecting-fails-on-failed-0` + +**Spec requirement:** The authorize call should be indicated as completed with the new token once the connection has moved to the CONNECTED state, or with an error if the connection instead moves to the FAILED, SUSPENDED, or CLOSED states. + +Tests that if the connection transitions to FAILED after `authorize()` is called +while CONNECTING, the authorize future completes with an error. + +### Setup +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count = connection_attempt_count + 1 + + IF connection_attempt_count == 1: + # First attempt: keep in CONNECTING + conn.respond_with_success() + ELSE: + # Second attempt (after authorize): fail with fatal error + conn.respond_with_success() + conn.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40101, + statusCode: 401, + message: "Invalid credentials" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Call authorize while CONNECTING — should fail +AWAIT client.auth.authorize() FAILS WITH error +ASSERT error.code == 40101 +``` + +### Assertions +```pseudo +# Connection is in FAILED state +ASSERT client.connection.state == ConnectionState.failed +CLOSE_CLIENT(client) +``` + +--- + +## RTC8c - authorize() from DISCONNECTED initiates connection + +**Test ID**: `realtime/unit/RTC8c/authorize-disconnected-initiates-connection-0` + +**Spec requirement:** If the connection is in the DISCONNECTED, SUSPENDED, FAILED, or CLOSED state when auth.authorize is called, after obtaining a token the library should move to the CONNECTING state and initiate a connection attempt using the new token, and RTC8b1 applies. + +Tests that calling `authorize()` from a non-connected state obtains a new token +and initiates a connection. + +### Setup +```pseudo +auth_callback_count = 0 +captured_ws_urls = [] + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count = connection_attempt_count + 1 + captured_ws_urls.append(conn.url) + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +# Client starts in INITIALIZED (autoConnect: false, connect() not called) +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +# Verify client is not connected +ASSERT client.connection.state == ConnectionState.initialized + +# Track state changes +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +# Call authorize from non-connected state +token_details = AWAIT client.auth.authorize() +``` + +### Assertions +```pseudo +# authorize() completed successfully +ASSERT token_details.token == "token-1" + +# Connection is now CONNECTED +ASSERT client.connection.state == ConnectionState.connected + +# State transitions included CONNECTING +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected +] + +# Connection used the token from authorize +ASSERT captured_ws_urls[0].queryParameters["accessToken"] == "token-1" +CLOSE_CLIENT(client) +``` + +--- + +## RTC8c - authorize() from FAILED initiates connection + +**Test ID**: `realtime/unit/RTC8c/authorize-failed-initiates-connection-1` + +**Spec requirement:** If the connection is in the FAILED state when auth.authorize is called, after obtaining a token the library should move to the CONNECTING state and initiate a connection attempt using the new token. + +Tests that `authorize()` can recover a FAILED connection by obtaining a new token +and reconnecting. + +### Setup +```pseudo +auth_callback_count = 0 +captured_ws_urls = [] + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count = connection_attempt_count + 1 + captured_ws_urls.append(conn.url) + conn.respond_with_success() + + IF connection_attempt_count == 1: + # First attempt: fail with fatal error + conn.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40101, + statusCode: 401, + message: "Invalid credentials" + ) + )) + ELSE: + # Second attempt (after authorize): succeed + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +# Connect — will fail +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.failed + +# Track state changes from FAILED onwards +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +# Call authorize from FAILED state +token_details = AWAIT client.auth.authorize() +``` + +### Assertions +```pseudo +# authorize() completed successfully +ASSERT token_details.token == "token-2" + +# Connection recovered to CONNECTED +ASSERT client.connection.state == ConnectionState.connected + +# State transitions went through CONNECTING +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected +] + +# Second connection used the new token +ASSERT captured_ws_urls[1].queryParameters["accessToken"] == "token-2" +CLOSE_CLIENT(client) +``` + +--- + +## RTC8c - authorize() from CLOSED initiates connection + +**Test ID**: `realtime/unit/RTC8c/authorize-closed-initiates-connection-2` + +**Spec requirement:** If the connection is in the CLOSED state when auth.authorize is called, after obtaining a token the library should move to the CONNECTING state and initiate a connection attempt using the new token. + +Tests that `authorize()` from CLOSED state opens a new connection. + +### Setup +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count = connection_attempt_count + 1 + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-" + str(connection_attempt_count), + connectionKey: "connection-key-" + str(connection_attempt_count), + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-" + str(connection_attempt_count), + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +# Connect, then close +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +client.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + +# Call authorize from CLOSED state +token_details = AWAIT client.auth.authorize() +``` + +### Assertions +```pseudo +# authorize() completed successfully +ASSERT token_details.token == "token-2" + +# Connection is now CONNECTED again +ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) +``` + +--- + +## Notes + +- **RTC8a4** (tests for both Ably token string and JWT token string) is covered implicitly: all tests above use opaque token strings. For unit tests, token format is irrelevant since tokens are passed through to the server without client-side parsing. Integration tests should verify both formats against the sandbox. +- For token **acquisition** before the initial connection, see `connection_auth_test.md` (RTN2e, RTN27b). +- For server-initiated reauthorization (RTN22), see `connection_failures_test.md`. diff --git a/uts/realtime/unit/auth/token_expiry_non_renewable_test.md b/uts/realtime/unit/auth/token_expiry_non_renewable_test.md new file mode 100644 index 000000000..a55d20a66 --- /dev/null +++ b/uts/realtime/unit/auth/token_expiry_non_renewable_test.md @@ -0,0 +1,234 @@ +# Token Expiry with Non-Renewable Token Tests + +Spec points: `RSA4a`, `RSA4a1`, `RSA4a2` + +## Test Type +Unit test with mocked WebSocket client + +## Mock Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Purpose + +These tests verify the behaviour when a token or tokenDetails is used to instantiate the library without any means to renew the token (no API key, authCallback, or authUrl). The library should warn at instantiation time and treat subsequent token errors as fatal (no retry, transition to FAILED). + +--- + +## RSA4a1 - Instantiation with non-renewable token logs info-level warning + +**Test ID**: `realtime/unit/RSA4a1/non-renewable-token-logs-warning-0` + +| Spec | Requirement | +|------|-------------| +| RSA4a | When a token or tokenDetails is used to instantiate the library, and no means to renew the token is provided (either an API key, authCallback or authUrl) | +| RSA4a1 | At instantiation time, a message at info log level with error code 40171 should be logged indicating that no means has been provided to renew the supplied token, including an associated url per TI5 | + +Tests that when a client is instantiated with only a token (no key, authCallback, or authUrl), an info-level log message with error code 40171 is emitted, including a help URL per TI5. + +### Setup + +```pseudo +captured_log_messages = [] + +log_handler = FUNCTION(level, message): + captured_log_messages.append({level: level, message: message}) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) +``` + +### Test Steps + +```pseudo +# Instantiate with token only — no key, no authCallback, no authUrl +client = Realtime(options: ClientOptions( + token: "non-renewable-token", + autoConnect: false, + useBinaryProtocol: false, + logHandler: log_handler, + logLevel: LOG_INFO +)) +``` + +### Assertions + +```pseudo +# A log message at info level with error code 40171 should have been emitted +info_messages = captured_log_messages.filter(m => m.level == LOG_INFO) +has_40171_message = info_messages.any(m => + m.message CONTAINS "40171" + OR m.message CONTAINS "no means" AND m.message CONTAINS "renew" +) +ASSERT has_40171_message == true + +# TI5: log message should include the help URL +has_help_url = info_messages.any(m => + m.message CONTAINS "https://help.ably.io/error/40171" +) +ASSERT has_help_url == true + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4a2 - Server token error with non-renewable token transitions to FAILED + +**Test ID**: `realtime/unit/RSA4a2/token-error-non-renewable-failed-0` + +| Spec | Requirement | +|------|-------------| +| RSA4a | When a token or tokenDetails is used to instantiate the library, and no means to renew the token is provided | +| RSA4a2 | If the server responds with a token error (401 HTTP status code and an Ably error value 40140 <= code < 40150), then the client library should indicate an error with error code 40171, not retry the request and, in the case of the realtime library, transition the connection to the FAILED state | + +Tests that when the server responds with a token error (e.g. 40142 "Token expired") and the client has no means to renew the token, the connection transitions to FAILED with error code 40171. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + # Server responds with token error (40142 = token expired) + conn.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40142, + statusCode: 401, + message: "Token expired" + ) + )) + } +) +install_mock(mock_ws) + +# Client with token only — no means to renew +client = Realtime(options: ClientOptions( + token: "expired-token", + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +state_changes = [] +client.connection.on((change) => { + state_changes.append(change) +}) + +client.connect() + +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Connection transitioned to FAILED (not DISCONNECTED — no retry) +ASSERT client.connection.state == ConnectionState.failed + +# Error reason has code 40171 (non-renewable token error) +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 40171 + +# State change event also carries the error +failed_changes = state_changes.filter(c => c.current == ConnectionState.failed) +ASSERT failed_changes.length == 1 +ASSERT failed_changes[0].reason IS NOT null +ASSERT failed_changes[0].reason.code == 40171 + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4a2 - Server token error with non-renewable token does not retry + +**Test ID**: `realtime/unit/RSA4a2/token-error-non-renewable-no-retry-1` + +| Spec | Requirement | +|------|-------------| +| RSA4a2 | The client library should not retry the request when a token error is received and no means to renew the token is provided | + +Tests that when a non-renewable token receives a token error, only one connection attempt is made (no retry). + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count = connection_attempt_count + 1 + conn.respond_with_success() + # Always respond with token error + conn.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40140, + statusCode: 401, + message: "Token error" + ) + )) + } +) +install_mock(mock_ws) + +# Client with token only — no means to renew +client = Realtime(options: ClientOptions( + token: "non-renewable-token", + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +client.connect() + +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Only one connection attempt was made (no retry) +ASSERT connection_attempt_count == 1 + +# Connection is in FAILED state +ASSERT client.connection.state == ConnectionState.failed + +# Error code is 40171 +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 40171 + +CLOSE_CLIENT(client) +``` + +--- + +## Notes + +- These tests complement the token renewal tests in `rest/unit/auth/token_renewal.md` (RSA4b) which cover the case where the client DOES have a means to renew tokens. +- For realtime auth callback error handling (when authCallback/authUrl IS provided but fails), see `connection_auth_test.md` (RSA4c, RSA4d). +- The error code 40171 indicates "Token expired with no means of renewal" and is distinct from the server's token error codes (40140-40149). diff --git a/uts/realtime/unit/channels/channel_additional_attached.md b/uts/realtime/unit/channels/channel_additional_attached.md new file mode 100644 index 000000000..76d1c6f2b --- /dev/null +++ b/uts/realtime/unit/channels/channel_additional_attached.md @@ -0,0 +1,200 @@ +# Additional ATTACHED Message Handling Tests + +Spec points: `RTL12` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL12 - Additional ATTACHED with resumed=false emits UPDATE with error + +**Test ID**: `realtime/unit/RTL12/update-emits-with-error-0` + +**Spec requirement:** An attached channel may receive an additional `ATTACHED` +`ProtocolMessage` from Ably at any point. If and only if the `resumed` flag is +false, this should result in the channel emitting an `UPDATE` event with a +`ChannelStateChange` object. The `ChannelStateChange` object should have both +`previous` and `current` attributes set to `attached`, the `reason` attribute +set to the `error` member of the `ATTACHED` `ProtocolMessage` (if any), and the +`resumed` attribute set per the `RESUMED` bitflag of the `ATTACHED` +`ProtocolMessage`. + +Tests that an additional ATTACHED message without the RESUMED flag emits an +UPDATE event with the correct attributes including the error reason. + +### Setup +```pseudo +channel_name = "test-RTL12-update-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +update_events = [] +channel.on(ChannelEvent.update).listen((change) => update_events.append(change)) + +# Server sends additional ATTACHED without RESUMED flag, with an error +# (e.g., loss of message continuity after transport resume) +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + error: ErrorInfo(code: 50000, statusCode: 500, message: "generic serverside failure") +)) + +AWAIT Future.delayed(Duration.zero) +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT length(update_events) == 1 +ASSERT update_events[0].event == ChannelEvent.update +ASSERT update_events[0].current == ChannelState.attached +ASSERT update_events[0].previous == ChannelState.attached +ASSERT update_events[0].resumed == false +ASSERT update_events[0].reason.code == 50000 +CLOSE_CLIENT(client) +``` + +--- + +## RTL12 - Additional ATTACHED with resumed=true does NOT emit UPDATE + +**Test ID**: `realtime/unit/RTL12/resumed-no-update-1` + +**Spec requirement:** The UPDATE event should only be emitted if and only if the +`resumed` flag is false. When `resumed` is true, the additional ATTACHED message +indicates a successful resume with no loss of continuity, and no event should be +emitted to the public channel emitter. + +Tests that an additional ATTACHED message with the RESUMED flag does not emit an +UPDATE event. + +### Setup +```pseudo +channel_name = "test-RTL12-no-update-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +update_events = [] +channel.on(ChannelEvent.update).listen((change) => update_events.append(change)) + +# Server sends additional ATTACHED WITH RESUMED flag +# This indicates successful resume with no loss of continuity +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: RESUMED +)) + +AWAIT Future.delayed(Duration.zero) +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT length(update_events) == 0 +CLOSE_CLIENT(client) +``` + +--- + +## RTL12 - Additional ATTACHED without error has null reason + +**Test ID**: `realtime/unit/RTL12/no-error-null-reason-2` + +**Spec requirement:** The `reason` attribute is set to the `error` member of the +`ATTACHED` `ProtocolMessage` (if any). + +Tests that when an additional ATTACHED message has no error field, the UPDATE +event's reason is null. + +### Setup +```pseudo +channel_name = "test-RTL12-no-error-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +update_events = [] +channel.on(ChannelEvent.update).listen((change) => update_events.append(change)) + +# Server sends additional ATTACHED without RESUMED flag and without error +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name +)) + +AWAIT Future.delayed(Duration.zero) +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT length(update_events) == 1 +ASSERT update_events[0].resumed == false +ASSERT update_events[0].reason IS null +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/channels/channel_annotations.md b/uts/realtime/unit/channels/channel_annotations.md new file mode 100644 index 000000000..fff3bf083 --- /dev/null +++ b/uts/realtime/unit/channels/channel_annotations.md @@ -0,0 +1,1014 @@ +# RealtimeChannel Annotations Tests + +Spec points: `RTL26`, `RTAN1`, `RTAN1a`, `RTAN1b`, `RTAN1c`, `RTAN1d`, `RTAN2`, `RTAN2a`, `RTAN3`, `RTAN3a`, `RTAN4`, `RTAN4a`, `RTAN4b`, `RTAN4c`, `RTAN4d`, `RTAN4e`, `RTAN4e1`, `RTAN5`, `RTAN5a` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL26 — channel.annotations returns RealtimeAnnotations + +**Test ID**: `realtime/unit/RTL26/annotations-attribute-type-0` + +**Spec requirement:** RTL26 — `RealtimeChannel#annotations` attribute contains the `RealtimeAnnotations` object for this channel. + +Tests that the channel exposes an `annotations` attribute of type `RealtimeAnnotations`. + +### Setup +```pseudo +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get("test-RTL26") +``` + +### Assertions +```pseudo +ASSERT channel.annotations IS RealtimeAnnotations +CLOSE_CLIENT(client) +``` + +--- + +## RTAN1a, RTAN1c — publish sends ANNOTATION ProtocolMessage with ANNOTATION_CREATE + +**Test ID**: `realtime/unit/RTAN1a/publish-sends-annotation-0` + +| Spec | Requirement | +|------|-------------| +| RTAN1a | Accepts same arguments and performs same validation, field setting, and data encoding as RSAN1 | +| RTAN1c | Must put annotation into array in `annotations` field of a `ProtocolMessage` with action `ANNOTATION`, channel set to channel name | + +Tests that `annotations.publish()` sends a correctly formatted ANNOTATION ProtocolMessage. + +### Setup +```pseudo +channel_name = "test-RTAN1-publish-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + captured_messages.append(msg) + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH + )) + ELSE IF msg.action == ANNOTATION: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1 + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + type: "com.example.reaction", + name: "like" +)) +``` + +### Assertions +```pseudo +annotation_pm = null +FOR pm IN captured_messages: + IF pm.action == ANNOTATION: + annotation_pm = pm +ASSERT annotation_pm IS NOT null + +ASSERT annotation_pm.channel == channel_name +ASSERT annotation_pm.annotations.length == 1 + +ann = annotation_pm.annotations[0] +ASSERT ann.action == AnnotationAction.ANNOTATION_CREATE # numeric: 0 +ASSERT ann.messageSerial == "msg-serial-1" +ASSERT ann.type == "com.example.reaction" +ASSERT ann.name == "like" +CLOSE_CLIENT(client) +``` + +--- + +## RTAN1a — publish validates type is required + +**Test ID**: `realtime/unit/RTAN1a/validates-type-required-1` + +**Spec requirement:** RTAN1a — Performs the same validation as RSAN1. Per RSAN1a3, the `type` field is required. + +Tests that publishing an annotation without a `type` field throws an error. + +### Setup +```pseudo +channel_name = "test-RTAN1a-validate-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps and Assertions +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + name: "like" +)) FAILS WITH error +ASSERT error IS NOT null # Error code is implementation-defined; RSAN1a3 does not mandate a specific code +CLOSE_CLIENT(client) +``` + +--- + +## RTAN1a — publish encodes data per RSL4 + +**Test ID**: `realtime/unit/RTAN1a/encodes-data-json-2` + +**Spec requirement:** RTAN1a — Performs the same data encoding as RSAN1. Per RSAN1c3, data must be encoded per RSL4. + +Tests that JSON data in an annotation is encoded following message encoding rules. + +### Setup +```pseudo +channel_name = "test-RTAN1a-encode-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + captured_messages.append(msg) + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH + )) + ELSE IF msg.action == ANNOTATION: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1 + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + type: "com.example.data", + data: { "key": "value", "nested": { "a": 1 } } +)) +``` + +### Assertions +```pseudo +annotation_pm = null +FOR pm IN captured_messages: + IF pm.action == ANNOTATION: + annotation_pm = pm +ASSERT annotation_pm IS NOT null + +ann = annotation_pm.annotations[0] +ASSERT ann.data IS String +ASSERT ann.encoding == "json" +ASSERT parse_json(ann.data) == { "key": "value", "nested": { "a": 1 } } +CLOSE_CLIENT(client) +``` + +--- + +## RTAN1b — publish has same connection and channel state conditions as message publishing + +**Test ID**: `realtime/unit/RTAN1b/publish-channel-state-0` + +**Spec requirement:** RTAN1b — Has the same connection and channel state conditions as message publishing, see RTL6c. + +Tests that annotation publish fails in FAILED and SUSPENDED channel states, matching the behaviour tested in `uts/test/realtime/unit/channels/channel_publish.md` (RTL6c4). The same connection and channel state preconditions apply. + +### Setup +```pseudo +channel_name = "test-RTAN1b-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Send ERROR to put channel in FAILED state + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 40160, message: "Not permitted") + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps and Assertions +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED + +# Attempt attach — will fail, putting channel in FAILED +TRY: + AWAIT channel.attach() +CATCH: + # Expected — channel is now FAILED + +ASSERT channel.state == FAILED + +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + type: "com.example.reaction", + name: "like" +)) FAILS WITH error +ASSERT error IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RTAN1d — publish indicates success/failure via ACK/NACK + +**Test ID**: `realtime/unit/RTAN1d/publish-ack-nack-0` + +**Spec requirement:** RTAN1d — Must indicate success or failure of the publish (once ACKed or NACKed) in the same way as `RealtimeChannel#publish`. + +Tests that the publish resolves on ACK and rejects on NACK. + +### Setup (ACK case) +```pseudo +channel_name = "test-RTAN1d-ack-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH + )) + ELSE IF msg.action == ANNOTATION: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1 + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps (ACK) +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +# Should resolve without error +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + type: "com.example.reaction", + name: "like" +)) +# If we get here, publish succeeded (no assertion needed beyond no throw) +CLOSE_CLIENT(client) +``` + +### Setup (NACK case) +```pseudo +channel_name = "test-RTAN1d-nack-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH + )) + ELSE IF msg.action == ANNOTATION: + mock_ws.active_connection.send_to_client(NACK( + msgSerial: msg.msgSerial, + count: 1, + error: ErrorInfo(code: 40160, message: "Not permitted") + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps and Assertions (NACK) +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + type: "com.example.reaction", + name: "like" +)) FAILS WITH error +ASSERT error.code == 40160 +CLOSE_CLIENT(client) +``` + +--- + +## RTAN2a — delete sends ANNOTATION ProtocolMessage with ANNOTATION_DELETE + +**Test ID**: `realtime/unit/RTAN2a/delete-sends-annotation-0` + +**Spec requirement:** RTAN2a — Must be identical to RTAN1 `publish()` except that the `Annotation.action` is set to `ANNOTATION_DELETE`, not `ANNOTATION_CREATE`. + +Tests that `annotations.delete()` sends ANNOTATION_DELETE. + +### Setup +```pseudo +channel_name = "test-RTAN2-delete-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + captured_messages.append(msg) + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH + )) + ELSE IF msg.action == ANNOTATION: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1 + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +AWAIT channel.annotations.delete("msg-serial-1", Annotation( + type: "com.example.reaction", + name: "like" +)) +``` + +### Assertions +```pseudo +annotation_pm = null +FOR pm IN captured_messages: + IF pm.action == ANNOTATION: + annotation_pm = pm +ASSERT annotation_pm IS NOT null + +ann = annotation_pm.annotations[0] +ASSERT ann.action == AnnotationAction.ANNOTATION_DELETE # numeric: 1 +ASSERT ann.messageSerial == "msg-serial-1" +ASSERT ann.type == "com.example.reaction" +ASSERT ann.name == "like" +CLOSE_CLIENT(client) +``` + +--- + +## RTAN3a — get is identical to RestAnnotations#get + +**Spec requirement:** RTAN3a — Is identical to `RestAnnotations#get`. + +`RealtimeAnnotations#get` uses the same underlying REST endpoint as `RestAnnotations#get`. The tests in `uts/test/rest/unit/channel/annotations.md` (covering RSAN3) should be used to verify that all the same behaviour, parameters, and return types apply when called on a `RealtimeChannel` instance. + +--- + +## RTAN4a, RTAN4b — subscribe delivers annotations from ANNOTATION ProtocolMessage + +**Test ID**: `realtime/unit/RTAN4a/subscribe-delivers-annotations-0` + +| Spec | Requirement | +|------|-------------| +| RTAN4a | Should support the same set of type signatures as `RealtimeChannel#subscribe` (RTL7), except `name` is called `type` | +| RTAN4b | When the library receives a `ProtocolMessage` with action `ANNOTATION`, every member of the `annotations` array should be delivered to registered listeners | + +Tests that subscribing to annotations delivers decoded Annotation objects when an ANNOTATION ProtocolMessage is received. + +### Setup +```pseudo +channel_name = "test-RTAN4-subscribe-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH | ANNOTATION_SUBSCRIBE + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name, RealtimeChannelOptions( + attachOnSubscribe: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +received_annotations = [] +channel.annotations.subscribe((annotation) => { + received_annotations.append(annotation) +}) + +# Server sends ANNOTATION ProtocolMessage with two annotations +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ANNOTATION, + channel: channel_name, + annotations: [ + { + "id": "ann-1", + "action": 0, + "type": "com.example.reaction", + "name": "like", + "clientId": "user-1", + "serial": "ann-serial-1", + "messageSerial": "msg-serial-1", + "timestamp": 1700000000000 + }, + { + "id": "ann-2", + "action": 0, + "type": "com.example.reaction", + "name": "heart", + "clientId": "user-2", + "serial": "ann-serial-2", + "messageSerial": "msg-serial-1", + "timestamp": 1700000001000 + } + ] +)) +``` + +### Assertions +```pseudo +ASSERT received_annotations.length == 2 + +ann1 = received_annotations[0] +ASSERT ann1 IS Annotation +ASSERT ann1.id == "ann-1" +ASSERT ann1.action == AnnotationAction.ANNOTATION_CREATE +ASSERT ann1.type == "com.example.reaction" +ASSERT ann1.name == "like" +ASSERT ann1.clientId == "user-1" +ASSERT ann1.serial == "ann-serial-1" +ASSERT ann1.messageSerial == "msg-serial-1" +ASSERT ann1.timestamp == 1700000000000 + +ann2 = received_annotations[1] +ASSERT ann2.name == "heart" +ASSERT ann2.clientId == "user-2" +CLOSE_CLIENT(client) +``` + +--- + +## RTAN4c — subscribe with type filter delivers only matching annotations + +**Test ID**: `realtime/unit/RTAN4c/subscribe-type-filter-0` + +**Spec requirement:** RTAN4c — If the user subscribes with a `type` (or array of types), the SDK must deliver only annotations whose `type` field exactly equals the requested type. + +Tests that type-filtered subscription only delivers matching annotations. + +### Setup +```pseudo +channel_name = "test-RTAN4c-filter-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH | ANNOTATION_SUBSCRIBE + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name, RealtimeChannelOptions( + attachOnSubscribe: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +reaction_annotations = [] +channel.annotations.subscribe( + type: "com.example.reaction", + listener: (annotation) => { + reaction_annotations.append(annotation) + } +) + +# Server sends mixed annotation types +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ANNOTATION, + channel: channel_name, + annotations: [ + { + "action": 0, + "type": "com.example.reaction", + "name": "like", + "messageSerial": "msg-serial-1", + "serial": "ann-serial-1", + "timestamp": 1700000000000 + }, + { + "action": 0, + "type": "com.example.comment", + "name": "text", + "messageSerial": "msg-serial-1", + "serial": "ann-serial-2", + "timestamp": 1700000001000 + }, + { + "action": 0, + "type": "com.example.reaction", + "name": "heart", + "messageSerial": "msg-serial-1", + "serial": "ann-serial-3", + "timestamp": 1700000002000 + } + ] +)) +``` + +### Assertions +```pseudo +# Only reaction annotations delivered +ASSERT reaction_annotations.length == 2 +ASSERT reaction_annotations[0].name == "like" +ASSERT reaction_annotations[1].name == "heart" +CLOSE_CLIENT(client) +``` + +--- + +## RTAN4d — subscribe implicitly attaches channel + +**Test ID**: `realtime/unit/RTAN4d/subscribe-implicit-attach-0` + +**Spec requirement:** RTAN4d — Has the same connection and channel state preconditions and return value as `RealtimeChannel#subscribe`, including implicitly attaching unless the user requests otherwise per RTL7g/RTL7h. + +Tests that subscribing to annotations triggers an implicit attach from INITIALIZED state when `attachOnSubscribe` is true (the default). + +### Setup +```pseudo +channel_name = "test-RTAN4d-attach-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH | ANNOTATION_SUBSCRIBE + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +# Default attachOnSubscribe is true +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED + +ASSERT channel.state == INITIALIZED + +channel.annotations.subscribe((annotation) => {}) + +# Wait for implicit attach to complete +AWAIT_STATE channel.state == ATTACHED +``` + +### Assertions +```pseudo +ASSERT channel.state == ATTACHED +CLOSE_CLIENT(client) +``` + +--- + +## RTAN4e — subscribe warns when ANNOTATION_SUBSCRIBE mode not granted + +**Test ID**: `realtime/unit/RTAN4e/subscribe-warns-no-mode-0` + +**Spec requirement:** RTAN4e — Once the channel is in the attached state, the channel modes are checked for the presence of the `ANNOTATION_SUBSCRIBE` mode. If missing, the library should log a warning. + +Tests that a warning is logged when the channel is attached without ANNOTATION_SUBSCRIBE mode. + +### Setup +```pseudo +channel_name = "test-RTAN4e-warn-${random_id()}" +log_messages = [] + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Respond with ATTACHED but WITHOUT ANNOTATION_SUBSCRIBE flag + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH + )) + } +) + +client = Realtime( + options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + logHandler: (level, message) => { + IF level == WARN: + log_messages.append(message) + } + ), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name, RealtimeChannelOptions( + attachOnSubscribe: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +channel.annotations.subscribe((annotation) => {}) +``` + +### Assertions +```pseudo +# A warning should have been logged about ANNOTATION_SUBSCRIBE mode +ASSERT log_messages.length >= 1 +found_warning = false +FOR msg IN log_messages: + IF msg CONTAINS "ANNOTATION_SUBSCRIBE": + found_warning = true +ASSERT found_warning == true +CLOSE_CLIENT(client) +``` + +--- + +## RTAN4e1 — subscribe does not warn when not attached and attachOnSubscribe is false + +**Test ID**: `realtime/unit/RTAN4e1/no-warn-unattached-0` + +**Spec requirement:** RTAN4e1 — This check does not apply if `attachOnSubscribe` has been set to `false` and the channel is not attached. + +Tests that no ANNOTATION_SUBSCRIBE warning is emitted when the channel is not attached and attachOnSubscribe is false. + +### Setup +```pseudo +channel_name = "test-RTAN4e1-${random_id()}" +log_messages = [] + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + } +) + +client = Realtime( + options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + logHandler: (level, message) => { + IF level == WARN: + log_messages.append(message) + } + ), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name, RealtimeChannelOptions( + attachOnSubscribe: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED + +# Channel is INITIALIZED, not attached +ASSERT channel.state == INITIALIZED + +channel.annotations.subscribe((annotation) => {}) +``` + +### Assertions +```pseudo +# No warning about ANNOTATION_SUBSCRIBE should be logged +found_warning = false +FOR msg IN log_messages: + IF msg CONTAINS "ANNOTATION_SUBSCRIBE": + found_warning = true +ASSERT found_warning == false +CLOSE_CLIENT(client) +``` + +--- + +## RTAN5a — unsubscribe removes listeners + +**Test ID**: `realtime/unit/RTAN5a/unsubscribe-removes-listeners-0` + +**Spec requirement:** RTAN5a — Should support the same set of type signatures as `RealtimeChannel#unsubscribe` (RTL8), except that the `name` argument is called `type`. + +Tests that unsubscribing removes annotation listeners. + +### Setup +```pseudo +channel_name = "test-RTAN5-unsub-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH | ANNOTATION_SUBSCRIBE + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name, RealtimeChannelOptions( + attachOnSubscribe: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +received_annotations = [] +listener = (annotation) => { + received_annotations.append(annotation) +} +channel.annotations.subscribe(listener) + +# Send first annotation — should be received +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ANNOTATION, + channel: channel_name, + annotations: [ + { + "action": 0, + "type": "com.example.reaction", + "name": "like", + "messageSerial": "msg-serial-1", + "serial": "ann-serial-1", + "timestamp": 1700000000000 + } + ] +)) + +ASSERT received_annotations.length == 1 + +# Unsubscribe +channel.annotations.unsubscribe(listener) + +# Send second annotation — should NOT be received +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ANNOTATION, + channel: channel_name, + annotations: [ + { + "action": 0, + "type": "com.example.reaction", + "name": "heart", + "messageSerial": "msg-serial-1", + "serial": "ann-serial-2", + "timestamp": 1700000001000 + } + ] +)) +``` + +### Assertions +```pseudo +# Only the first annotation was received +ASSERT received_annotations.length == 1 +ASSERT received_annotations[0].name == "like" +CLOSE_CLIENT(client) +``` + +--- + +## RTAN5a — unsubscribe with type removes only type-filtered listener + +**Test ID**: `realtime/unit/RTAN5a/unsubscribe-type-filter-1` + +Tests that unsubscribing with a type filter only removes that specific type's listener. + +### Setup +```pseudo +channel_name = "test-RTAN5a-typed-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH | ANNOTATION_SUBSCRIBE + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name, RealtimeChannelOptions( + attachOnSubscribe: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +reaction_received = [] +comment_received = [] + +reaction_listener = (ann) => { reaction_received.append(ann) } +comment_listener = (ann) => { comment_received.append(ann) } + +channel.annotations.subscribe(type: "com.example.reaction", listener: reaction_listener) +channel.annotations.subscribe(type: "com.example.comment", listener: comment_listener) + +# Unsubscribe only reactions +channel.annotations.unsubscribe(type: "com.example.reaction", listener: reaction_listener) + +# Send both types +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ANNOTATION, + channel: channel_name, + annotations: [ + { + "action": 0, + "type": "com.example.reaction", + "name": "like", + "messageSerial": "msg-serial-1", + "serial": "ann-serial-1", + "timestamp": 1700000000000 + }, + { + "action": 0, + "type": "com.example.comment", + "name": "text", + "messageSerial": "msg-serial-1", + "serial": "ann-serial-2", + "timestamp": 1700000001000 + } + ] +)) +``` + +### Assertions +```pseudo +# Reactions unsubscribed, comments still active +ASSERT reaction_received.length == 0 +ASSERT comment_received.length == 1 +ASSERT comment_received[0].type == "com.example.comment" +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/channels/channel_attach.md b/uts/realtime/unit/channels/channel_attach.md new file mode 100644 index 000000000..ae887eb59 --- /dev/null +++ b/uts/realtime/unit/channels/channel_attach.md @@ -0,0 +1,955 @@ +# RealtimeChannel Attach Tests + +Spec points: `RTL4`, `RTL4a`, `RTL4b`, `RTL4c`, `RTL4c1`, `RTL4f`, `RTL4g`, `RTL4h`, `RTL4i`, `RTL4j`, `RTL4k`, `RTL4l`, `RTL4m` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL4a - Attach when already attached is no-op + +**Test ID**: `realtime/unit/RTL4a/already-attached-noop-0` + +**Spec requirement:** If already ATTACHED nothing is done. + +Tests that calling attach on an already-attached channel returns immediately. + +### Setup +```pseudo +channel_name = "test-RTL4a-${random_id()}" +attach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_message_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# First attach +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached +ASSERT attach_message_count == 1 + +# Second attach - should be no-op +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT attach_message_count == 1 # No additional ATTACH message sent +CLOSE_CLIENT(client) +``` + +--- + +## RTL4h - Attach while attaching waits for completion + +**Test ID**: `realtime/unit/RTL4h/attach-while-attaching-0` + +**Spec requirement:** If the channel is in a pending state ATTACHING, do the attach operation after the completion of the pending request. + +Tests that calling attach while already attaching waits for the first attach to complete. + +### Setup +```pseudo +channel_name = "test-RTL4h-${random_id()}" +attach_message_count = 0 +attach_responses_sent = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_message_count++ + # Delay response to allow second attach call + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start first attach (don't await) +attach_future_1 = channel.attach() + +# Wait for channel to enter attaching state +AWAIT_STATE channel.state == ChannelState.attaching + +# Start second attach while first is pending +attach_future_2 = channel.attach() + +# Now send the ATTACHED response +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name +)) + +# Both should complete +AWAIT attach_future_1 +AWAIT attach_future_2 +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT attach_message_count == 1 # Only one ATTACH message sent +CLOSE_CLIENT(client) +``` + +--- + +## RTL4h - Attach while detaching waits then attaches + +**Test ID**: `realtime/unit/RTL4h/attach-while-detaching-1` + +**Spec requirement:** If the channel is in a pending state DETACHING, do the attach operation after the completion of the pending request. + +Tests that calling attach while detaching waits for detach to complete, then attaches. + +### Setup +```pseudo +channel_name = "test-RTL4h-detaching-${random_id()}" +messages_from_client = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + messages_from_client.append(msg) + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == DETACH: + # Delay DETACHED response + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach first +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Start detach (don't await) +detach_future = channel.detach() +AWAIT_STATE channel.state == ChannelState.detaching + +# Start attach while detaching +attach_future = channel.attach() + +# Send DETACHED response +mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name +)) + +# Wait for detach to complete +AWAIT detach_future + +# Now ATTACH should be sent and we wait for it +AWAIT attach_future +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +# Should have: ATTACH, DETACH, ATTACH +attach_messages = filter(messages_from_client, (m) => m.action == ATTACH) +ASSERT length(attach_messages) == 2 +CLOSE_CLIENT(client) +``` + +--- + +## RTL4g - Attach from failed state proceeds with attach + +**Test ID**: `realtime/unit/RTL4g/attach-from-failed-0` + +**Spec requirement:** If the channel is in the FAILED state, the attach request proceeds with a channel attach described in RTL4b, RTL4i and RTL4c. + +Tests that a channel in the FAILED state can be re-attached. errorReason clearing is verified as part of the RTL4c behavior (successful attach clears errorReason). + +### Setup +```pseudo +channel_name = "test-RTL4g-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + # First attach fails + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 40160, message: "Denied") + )) + ELSE: + # Second attach succeeds + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# First attach fails +AWAIT channel.attach() FAILS WITH error +ASSERT channel.state == ChannelState.failed +ASSERT channel.errorReason IS NOT null + +# Second attach from failed state +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +# RTL4c: successful attach clears errorReason +ASSERT channel.errorReason IS null +CLOSE_CLIENT(client) +``` + +--- + +## RTL4c - Successful attach clears errorReason + +**Test ID**: `realtime/unit/RTL4c/clears-error-reason-0` + +**Spec requirement:** When the confirmation ATTACHED ProtocolMessage is received, the channel's errorReason is set to null. + +Tests that errorReason is cleared on any successful attach, not just from the FAILED state. This test uses a SUSPENDED channel (which has errorReason set from a previous error) to verify the clearing applies to all successful attaches. + +### Setup +```pseudo +channel_name = "test-RTL4c-error-clear-${random_id()}" + +enable_fake_timers() + +late mock_ws +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + fallbackHosts: [], + suspendedRetryTimeout: 2000 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Simulate disconnect — push connection through to SUSPENDED +mock_ws.onConnectionAttempt = (conn) => conn.respond_with_refused() +mock_ws.active_connection.simulate_disconnect() + +LOOP up to 30 times: + ADVANCE_TIME(5000) + IF client.connection.state == ConnectionState.suspended: + BREAK + +AWAIT_STATE client.connection.state == ConnectionState.suspended +ASSERT channel.state == ChannelState.suspended + +# Channel should have errorReason set from the connection failure +ASSERT channel.errorReason IS NOT null + +# Allow reconnection to succeed +mock_ws.onConnectionAttempt = (conn) => conn.respond_with_success(CONNECTED_MESSAGE) + +LOOP up to 10 times: + ADVANCE_TIME(2500) + IF client.connection.state == ConnectionState.connected: + BREAK + +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +# errorReason cleared by the successful attach (RTL4c) +ASSERT channel.state == ChannelState.attached +ASSERT channel.errorReason IS null +CLOSE_CLIENT(client) +``` + +--- + +## RTL4b - Attach fails when connection is closed + +**Test ID**: `realtime/unit/RTL4b/fails-connection-closed-0` + +**Spec requirement:** If the connection state is CLOSED, CLOSING, SUSPENDED or FAILED, the attach request results in an error. + +Tests that attach fails when connection is in closed state. + +### Setup +```pseudo +channel_name = "test-RTL4b-closed-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Close the connection +AWAIT client.close() +ASSERT client.connection.state == ConnectionState.closed + +# Try to attach +AWAIT channel.attach() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code IS NOT null +ASSERT channel.state != ChannelState.attached +CLOSE_CLIENT(client) +``` + +--- + +## RTL4b - Attach fails when connection is failed + +**Test ID**: `realtime/unit/RTL4b/fails-connection-failed-1` + +**Spec requirement:** If the connection state is FAILED, the attach request results in an error. + +Tests that attach fails when connection is in failed state. + +### Setup +```pseudo +channel_name = "test-RTL4b-failed-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED_MESSAGE) + # Server sends fatal error + mock_ws.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: 80000, message: "Fatal error") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.failed + +# Try to attach +AWAIT channel.attach() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT channel.state != ChannelState.attached +CLOSE_CLIENT(client) +``` + +--- + +## RTL4b - Attach fails when connection is suspended + +**Test ID**: `realtime/unit/RTL4b/fails-connection-suspended-2` + +**Spec requirement:** If the connection state is SUSPENDED, the attach request results in an error. + +Tests that attach fails when connection is in suspended state. + +### Setup +```pseudo +channel_name = "test-RTL4b-suspended-${random_id()}" + +# Configure client with short suspend timeout for testing +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + channelRetryTimeout: 100 # Short timeout for testing +)) +channel = client.channels.get(channel_name) + +# Mock that refuses all connections to trigger suspended state +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_refused() +) +install_mock(mock_ws) +``` + +### Test Steps +```pseudo +client.connect() + +# Wait for connection to enter suspended state after retries exhausted +AWAIT_STATE client.connection.state == ConnectionState.suspended + +# Try to attach +AWAIT channel.attach() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT channel.state != ChannelState.attached +CLOSE_CLIENT(client) +``` + +--- + +## RTL4i - Attach queued when connection is connecting + +**Test ID**: `realtime/unit/RTL4i/queued-while-connecting-0` + +**Spec requirement:** If the connection state is INITIALIZED, CONNECTING or DISCONNECTED, the channel should be put into the ATTACHING state. + +Tests that attach transitions channel to attaching when connection is connecting. + +### Setup +```pseudo +channel_name = "test-RTL4i-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Delay connection response + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Start connecting but don't complete +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Start attach while connection is still connecting +attach_future = channel.attach() + +# Channel should immediately enter attaching +AWAIT_STATE channel.state == ChannelState.attaching +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attaching +# Attach message not yet sent (connection not ready) +CLOSE_CLIENT(client) +``` + +--- + +## RTL4i - Attach completes when connection becomes connected + +**Test ID**: `realtime/unit/RTL4i/completes-on-connected-1` + +**Spec requirement:** Attach message will be sent once the connection becomes CONNECTED. + +Tests that queued attach completes when connection is established. + +### Setup +```pseudo +channel_name = "test-RTL4i-connected-${random_id()}" +attach_message_received = false + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Delay connection response + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_message_received = true + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Start connecting +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Start attach while connecting +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching +ASSERT attach_message_received == false + +# Complete connection +pending_conn = AWAIT mock_ws.await_connection_attempt() +pending_conn.respond_with_success(CONNECTED_MESSAGE) + +# Wait for attach to complete +AWAIT attach_future +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT attach_message_received == true +CLOSE_CLIENT(client) +``` + +--- + +## RTL4c - Attach sends ATTACH message and transitions to attaching + +**Test ID**: `realtime/unit/RTL4c/sends-attach-message-1` + +**Spec requirement:** An ATTACH ProtocolMessage is sent to the server, the state transitions to ATTACHING. + +Tests the normal attach flow. + +### Setup +```pseudo +channel_name = "test-RTL4c-${random_id()}" +captured_attach_message = null + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + captured_attach_message = msg + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +state_during_attach = null +channel.on(ChannelEvent.attaching).listen((change) => { + state_during_attach = channel.state +}) + +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT state_during_attach == ChannelState.attaching +ASSERT channel.state == ChannelState.attached +ASSERT captured_attach_message IS NOT null +ASSERT captured_attach_message.action == ATTACH +ASSERT captured_attach_message.channel == channel_name +CLOSE_CLIENT(client) +``` + +--- + +## RTL4c1 - ATTACH message includes channelSerial when available + +**Test ID**: `realtime/unit/RTL4c1/includes-channel-serial-0` + +**Spec requirement:** The ATTACH ProtocolMessage channelSerial field must be set to the RTL15b channelSerial. If the RTL15b channelSerial is not set, the field may be set to null or omitted. + +Tests that channelSerial is included in ATTACH message when available. Uses setOptions (RTL16a) to trigger a reattach without going through DETACHED state, since RTL15b1 clears channelSerial on DETACHED. + +### Setup +```pseudo +channel_name = "test-RTL4c1-${random_id()}" +captured_attach_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + captured_attach_messages.append(msg) + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + channelSerial: "serial-from-server-1" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# First attach - no channelSerial yet +AWAIT channel.attach() + +# Trigger reattach via setOptions (RTL16a) — does NOT go through DETACHED, +# so channelSerial is preserved (RTL15b1 only clears on DETACHED/SUSPENDED/FAILED) +AWAIT channel.setOptions(ChannelOptions(modes: [subscribe])) +``` + +### Assertions +```pseudo +ASSERT length(captured_attach_messages) == 2 +# First attach has no channelSerial (or null) +ASSERT captured_attach_messages[0].channelSerial IS null OR captured_attach_messages[0].channelSerial IS NOT SET +# Second attach (reattach via setOptions) includes channelSerial +ASSERT captured_attach_messages[1].channelSerial == "serial-from-server-1" +CLOSE_CLIENT(client) +``` + +--- + +## RTL4f - Attach times out and transitions to suspended + +**Test ID**: `realtime/unit/RTL4f/timeout-to-suspended-0` + +**Spec requirement:** If an ATTACHED ProtocolMessage is not received within realtimeRequestTimeout, the attach request should be treated as though it has failed and the channel should transition to the SUSPENDED state. + +Tests attach timeout behavior. + +### Setup +```pseudo +channel_name = "test-RTL4f-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Don't respond - simulate timeout + } +) +install_mock(mock_ws) + +# Use short timeout for testing +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 100 # 100ms timeout for testing +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +attach_future = channel.attach() + +# Advance time past timeout +ADVANCE_TIME(150) + +AWAIT attach_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.suspended +ASSERT error IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RTL4k - ATTACH includes params from ChannelOptions + +**Test ID**: `realtime/unit/RTL4k/includes-channel-params-0` + +**Spec requirement:** If the user has specified a non-empty params object in the ChannelOptions, it must be included in a params field of the ATTACH ProtocolMessage. + +Tests that channel params are included in ATTACH message. + +### Setup +```pseudo +channel_name = "test-RTL4k-${random_id()}" +captured_attach_message = null + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + captured_attach_message = msg + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) + +channel_options = RealtimeChannelOptions( + params: {"rewind": "1", "delta": "vcdiff"} +) +channel = client.channels.get(channel_name, channel_options) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT captured_attach_message IS NOT null +ASSERT captured_attach_message.params IS NOT null +ASSERT captured_attach_message.params["rewind"] == "1" +ASSERT captured_attach_message.params["delta"] == "vcdiff" +CLOSE_CLIENT(client) +``` + +--- + +## RTL4l - ATTACH includes modes as flags + +**Test ID**: `realtime/unit/RTL4l/modes-encoded-as-flags-0` + +**Spec requirement:** If the user has specified a modes array in the ChannelOptions, it must be encoded as a bitfield and set as the flags field of the ATTACH ProtocolMessage. + +Tests that channel modes are encoded in ATTACH flags. + +### Setup +```pseudo +channel_name = "test-RTL4l-${random_id()}" +captured_attach_message = null + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + captured_attach_message = msg + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) + +channel_options = RealtimeChannelOptions( + modes: [ChannelMode.publish, ChannelMode.subscribe] +) +channel = client.channels.get(channel_name, channel_options) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT captured_attach_message IS NOT null +ASSERT captured_attach_message.flags IS NOT null +# Flags should include PUBLISH (131072, TR3r bit 17) and SUBSCRIBE (262144, TR3s bit 18) bits +ASSERT (captured_attach_message.flags AND 131072) != 0 # PUBLISH bit set +ASSERT (captured_attach_message.flags AND 262144) != 0 # SUBSCRIBE bit set +CLOSE_CLIENT(client) +``` + +--- + +## RTL4m - Channel modes populated from ATTACHED response + +**Test ID**: `realtime/unit/RTL4m/modes-from-attached-0` + +**Spec requirement:** On receipt of an ATTACHED, the client library should decode the flags into an array of ChannelModes and expose it as a read-only modes field. + +Tests that modes are decoded from ATTACHED flags. + +### Setup +```pseudo +channel_name = "test-RTL4m-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 393216 # PUBLISH (131072, TR3r) + SUBSCRIBE (262144, TR3s) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT channel.modes IS NOT null +ASSERT ChannelMode.publish IN channel.modes +ASSERT ChannelMode.subscribe IN channel.modes +CLOSE_CLIENT(client) +``` + +--- + +## RTL4j - ATTACH_RESUME flag set for reattach + +**Test ID**: `realtime/unit/RTL4j/attach-resume-flag-0` + +**Spec requirement:** If the attach is not a clean attach, the library should set the ATTACH_RESUME flag in the ATTACH message. Per RTL4j1, `attachResume` is cleared when the channel enters DETACHING or FAILED, so a detach+reattach IS a clean attach and should NOT have ATTACH_RESUME. A reattach while still attached (e.g. via setOptions) is NOT a clean attach and SHOULD have ATTACH_RESUME. + +Tests that ATTACH_RESUME flag is set on reattach while attached, but not on a clean attach. + +### Setup +```pseudo +channel_name = "test-RTL4j-${random_id()}" +captured_attach_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + captured_attach_messages.append(msg) + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# First attach - clean attach +AWAIT channel.attach() + +# Reattach while still attached (via setOptions) — not a clean attach +AWAIT channel.setOptions(params: {rewind: "1"}) +``` + +### Assertions +```pseudo +ASSERT length(captured_attach_messages) == 2 +# First attach should NOT have ATTACH_RESUME flag +ASSERT (captured_attach_messages[0].flags AND 32) == 0 # ATTACH_RESUME = 32 +# Second attach (reattach while attached) SHOULD have ATTACH_RESUME flag +ASSERT (captured_attach_messages[1].flags AND 32) != 0 # ATTACH_RESUME = 32 +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/channels/channel_attributes.md b/uts/realtime/unit/channels/channel_attributes.md new file mode 100644 index 000000000..72d4a6c4f --- /dev/null +++ b/uts/realtime/unit/channels/channel_attributes.md @@ -0,0 +1,373 @@ +# RealtimeChannel Attributes + +Spec points: `RTL23`, `RTL24` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL23 - RealtimeChannel name attribute + +**Test ID**: `realtime/unit/RTL23/name-attribute-0` + +**Spec requirement:** `RealtimeChannel#name` attribute is a string containing the +channel's name. + +Tests that the channel name attribute returns the name used when getting the channel. + +### Setup +```pseudo +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Assertions +```pseudo +channel = client.channels.get("my-channel") +ASSERT channel.name == "my-channel" + +# Also works with special characters +channel2 = client.channels.get("namespace:channel-name") +ASSERT channel2.name == "namespace:channel-name" +CLOSE_CLIENT(client) +``` + +--- + +## RTL24 - errorReason set on channel error + +**Test ID**: `realtime/unit/RTL24/error-reason-channel-error-0` + +**Spec requirement:** `RealtimeChannel#errorReason` attribute is an optional +`ErrorInfo` object which is set by the library when an error occurs on the channel. + +Tests that errorReason is populated when a channel receives an ERROR ProtocolMessage +(RTL14). + +### Setup +```pseudo +channel_name = "test-RTL24-error-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 0 + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Verify errorReason is initially null +ASSERT channel.errorReason IS null + +# Send an ERROR ProtocolMessage for this channel +mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo( + message: "Channel error occurred", + code: 90001, + statusCode: 500 + ) +)) + +AWAIT_STATE channel.state == ChannelState.failed +``` + +### Assertions +```pseudo +ASSERT channel.errorReason IS NOT null +ASSERT channel.errorReason.code == 90001 +ASSERT channel.errorReason.statusCode == 500 +ASSERT channel.errorReason.message == "Channel error occurred" +CLOSE_CLIENT(client) +``` + +--- + +## RTL24 - errorReason set on attach failure + +**Test ID**: `realtime/unit/RTL24/error-reason-attach-failure-1` + +**Spec requirement:** `RealtimeChannel#errorReason` is set by the library when an +error occurs on the channel, as described by RTL4g. + +Tests that errorReason is populated when an attach is rejected by the server. + +### Setup +```pseudo +channel_name = "test-RTL24-attach-fail-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Reject attach with DETACHED + error + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo( + message: "Permission denied", + code: 40160, + statusCode: 401 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach should fail +AWAIT channel.attach() FAILS WITH error +``` + +### Assertions +```pseudo +# errorReason is set from the DETACHED response error +ASSERT channel.errorReason IS NOT null +ASSERT channel.errorReason.code == 40160 +ASSERT channel.errorReason.statusCode == 401 +CLOSE_CLIENT(client) +``` + +--- + +## RTL4c/RTL24 - errorReason cleared on successful attach + +**Test ID**: `realtime/unit/RTL4c/error-cleared-on-attach-0` + +**Spec requirement (RTL4c):** When the confirmation ATTACHED ProtocolMessage is received, the channel's errorReason is set to null. + +Tests that errorReason is reset to null after a successful attach following a +previous error. + +### Setup +```pseudo +channel_name = "test-RTL24-clear-attach-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + # First attach: reject + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo( + message: "Temporary error", + code: 50000, + statusCode: 500 + ) + )) + ELSE: + # Subsequent attaches: succeed + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 0 + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# First attach fails — errorReason set +AWAIT channel.attach() FAILS WITH error +ASSERT channel.errorReason IS NOT null +ASSERT channel.errorReason.code == 50000 + +# Second attach succeeds — errorReason cleared +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT channel.errorReason IS null +CLOSE_CLIENT(client) +``` + +--- + +## RTL4c/RTL24 - errorReason cleared on successful attach, preserved through detach + +**Test ID**: `realtime/unit/RTL4c/error-cleared-preserved-detach-1` + +**Spec requirement (RTL4c):** When the confirmation ATTACHED ProtocolMessage is received, the channel's errorReason is set to null. + +Tests that after an error puts the channel in FAILED, a successful re-attach +clears errorReason (RTL4c), and a subsequent detach preserves the null value +(detach does not set errorReason). + +Note: To reliably set errorReason, we use an ERROR ProtocolMessage (which +transitions the channel to FAILED via RTL14). After the ERROR puts +the channel in FAILED, we reattach (which clears errorReason via RTL4c), +then verify detach leaves errorReason null. + +### Setup +```pseudo +channel_name = "test-RTL24-clear-detach-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 0 + )) + IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Send ERROR — channel transitions to FAILED, errorReason is set (RTL14) +mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo( + message: "Channel error", + code: 90002, + statusCode: 500 + ) +)) + +AWAIT_STATE channel.state == ChannelState.failed + +ASSERT channel.errorReason IS NOT null +ASSERT channel.errorReason.code == 90002 + +# Reattach — errorReason cleared on successful attach +AWAIT channel.attach() +ASSERT channel.errorReason IS null + +# Now detach — errorReason stays null +AWAIT channel.detach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached +ASSERT channel.errorReason IS null +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/channels/channel_connection_state.md b/uts/realtime/unit/channels/channel_connection_state.md new file mode 100644 index 000000000..3b3c48461 --- /dev/null +++ b/uts/realtime/unit/channels/channel_connection_state.md @@ -0,0 +1,963 @@ +# RealtimeChannel Connection State Side Effects Tests + +Spec points: `RTL3`, `RTL3a`, `RTL3b`, `RTL3c`, `RTL3d`, `RTL3e`, `RTL4c1` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL3e - DISCONNECTED has no effect on ATTACHED channel + +**Test ID**: `realtime/unit/RTL3e/disconnected-attached-noop-0` + +**Spec requirement:** If the connection state enters the DISCONNECTED state, it will have no effect on the channel states. + +Tests that a channel in the ATTACHED state is unaffected when the connection transitions to DISCONNECTED. + +### Setup +```pseudo +channel_name = "test-RTL3e-attached-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Record channel state changes from this point +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Simulate transport failure - connection goes to DISCONNECTED +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected +``` + +### Assertions +```pseudo +# Channel state must remain ATTACHED +ASSERT channel.state == ChannelState.attached + +# No channel state change events should have been emitted +ASSERT length(channel_state_changes) == 0 +CLOSE_CLIENT(client) +``` + +--- + +## RTL3e - DISCONNECTED has no effect on ATTACHING channel + +**Test ID**: `realtime/unit/RTL3e/disconnected-attaching-noop-1` + +**Spec requirement:** If the connection state enters the DISCONNECTED state, it will have no effect on the channel states. + +Tests that a channel in the ATTACHING state is unaffected when the connection transitions to DISCONNECTED. + +### Setup +```pseudo +channel_name = "test-RTL3e-attaching-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Do NOT respond - leave channel in ATTACHING state + pass + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach but don't await - server won't respond +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Record channel state changes from this point +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Simulate transport failure - connection goes to DISCONNECTED +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected +``` + +### Assertions +```pseudo +# Channel state must remain ATTACHING +ASSERT channel.state == ChannelState.attaching + +# No channel state change events should have been emitted +ASSERT length(channel_state_changes) == 0 +CLOSE_CLIENT(client) +``` + +--- + +## RTL3a - FAILED connection transitions ATTACHED channel to FAILED + +**Test ID**: `realtime/unit/RTL3a/failed-attached-to-failed-0` + +**Spec requirement:** If the connection state enters the FAILED state, then an ATTACHING or ATTACHED channel state will transition to FAILED and set the `RealtimeChannel#errorReason`. + +Tests that attached channels transition to FAILED when the connection enters FAILED state. + +### Setup +```pseudo +channel_name = "test-RTL3a-attached-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Record channel state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Server sends a fatal connection-level ERROR +mock_ws.active_connection.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40198, + statusCode: 403, + message: "Account disabled" + ) +)) +AWAIT_STATE client.connection.state == ConnectionState.failed +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.failed +ASSERT channel.errorReason IS NOT null +ASSERT channel.errorReason.code == 40198 + +# Channel state change event was emitted +ASSERT length(channel_state_changes) >= 1 +failed_change = channel_state_changes.find(c => c.current == ChannelState.failed) +ASSERT failed_change IS NOT null +ASSERT failed_change.previous == ChannelState.attached +ASSERT failed_change.reason IS NOT null +ASSERT failed_change.reason.code == 40198 +CLOSE_CLIENT(client) +``` + +--- + +## RTL3a - FAILED connection transitions ATTACHING channel to FAILED + +**Test ID**: `realtime/unit/RTL3a/failed-attaching-to-failed-1` + +**Spec requirement:** If the connection state enters the FAILED state, then an ATTACHING or ATTACHED channel state will transition to FAILED and set the `RealtimeChannel#errorReason`. + +Tests that a channel in the ATTACHING state transitions to FAILED when the connection enters FAILED. + +### Setup +```pseudo +channel_name = "test-RTL3a-attaching-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Do NOT respond - leave channel in ATTACHING state + pass + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach but don't await - server won't respond +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Record channel state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Server sends a fatal connection-level ERROR +mock_ws.active_connection.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40198, + statusCode: 403, + message: "Account disabled" + ) +)) +AWAIT_STATE client.connection.state == ConnectionState.failed + +# The pending attach should fail +AWAIT attach_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.failed +ASSERT channel.errorReason IS NOT null + +failed_change = channel_state_changes.find(c => c.current == ChannelState.failed) +ASSERT failed_change IS NOT null +ASSERT failed_change.previous == ChannelState.attaching +CLOSE_CLIENT(client) +``` + +--- + +## RTL3a - Channels in other states are unaffected by FAILED connection + +**Test ID**: `realtime/unit/RTL3a/other-states-unaffected-2` + +**Spec requirement:** If the connection state enters the FAILED state, then an ATTACHING or ATTACHED channel state will transition to FAILED. + +Tests that channels in INITIALIZED, DETACHED, SUSPENDED, or FAILED states are not affected when the connection enters FAILED. + +### Setup +```pseudo +initialized_channel_name = "test-RTL3a-init-${random_id()}" +detached_channel_name = "test-RTL3a-detached-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +initialized_channel = client.channels.get(initialized_channel_name) +detached_channel = client.channels.get(detached_channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Leave initialized_channel in INITIALIZED state (never attach) +ASSERT initialized_channel.state == ChannelState.initialized + +# Attach then detach to get to DETACHED state +AWAIT detached_channel.attach() +AWAIT detached_channel.detach() +ASSERT detached_channel.state == ChannelState.detached + +# Record state changes on both channels +init_changes = [] +detached_changes = [] +initialized_channel.on().listen((change) => init_changes.append(change)) +detached_channel.on().listen((change) => detached_changes.append(change)) + +# Server sends a fatal connection-level ERROR +mock_ws.active_connection.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40198, + statusCode: 403, + message: "Account disabled" + ) +)) +AWAIT_STATE client.connection.state == ConnectionState.failed +``` + +### Assertions +```pseudo +# Channels not in ATTACHING/ATTACHED should be unaffected +ASSERT initialized_channel.state == ChannelState.initialized +ASSERT detached_channel.state == ChannelState.detached +ASSERT length(init_changes) == 0 +ASSERT length(detached_changes) == 0 +CLOSE_CLIENT(client) +``` + +--- + +## RTL3b - CLOSED connection transitions ATTACHED channel to DETACHED + +**Test ID**: `realtime/unit/RTL3b/closed-attached-to-detached-0` + +**Spec requirement:** If the connection state enters the CLOSED state, then an ATTACHING or ATTACHED channel state will transition to DETACHED. + +Tests that an attached channel transitions to DETACHED when the connection is explicitly closed. + +### Setup +```pseudo +channel_name = "test-RTL3b-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Record channel state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Close the connection +client.close() +AWAIT_STATE client.connection.state == ConnectionState.closed +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached + +detached_change = channel_state_changes.find(c => c.current == ChannelState.detached) +ASSERT detached_change IS NOT null +ASSERT detached_change.previous == ChannelState.attached +CLOSE_CLIENT(client) +``` + +--- + +## RTL3b - CLOSED connection transitions ATTACHING channel to DETACHED + +**Test ID**: `realtime/unit/RTL3b/closed-attaching-to-detached-1` + +**Spec requirement:** If the connection state enters the CLOSED state, then an ATTACHING or ATTACHED channel state will transition to DETACHED. + +Tests that a channel in the ATTACHING state transitions to DETACHED when the connection is closed. + +### Setup +```pseudo +channel_name = "test-RTL3b-attaching-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Do NOT respond - leave channel in ATTACHING state + pass + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach but don't await - server won't respond +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Record channel state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Close the connection +client.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + +# The pending attach should fail +AWAIT attach_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached + +detached_change = channel_state_changes.find(c => c.current == ChannelState.detached) +ASSERT detached_change IS NOT null +ASSERT detached_change.previous == ChannelState.attaching +CLOSE_CLIENT(client) +``` + +--- + +## RTL3c - SUSPENDED connection transitions ATTACHED channel to SUSPENDED + +**Test ID**: `realtime/unit/RTL3c/suspended-attached-to-suspended-0` + +**Spec requirement:** If the connection state enters the SUSPENDED state, then an ATTACHING or ATTACHED channel state will transition to SUSPENDED. + +Tests that an attached channel transitions to SUSPENDED when the connection enters SUSPENDED state. + +### Setup +```pseudo +channel_name = "test-RTL3c-${random_id()}" + +enable_fake_timers() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + fallbackHosts: [], + disconnectedRetryTimeout: 1000 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Record channel state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Simulate disconnect - all reconnection attempts will fail +mock_ws.onConnectionAttempt = (conn) => conn.respond_with_refused() +mock_ws.active_connection.simulate_disconnect() + +# Connection must exhaust disconnectedRetryTimeout retries within connectionStateTtl +# to transition from DISCONNECTED to SUSPENDED. The total time advance must exceed +# connectionStateTtl (from connectionDetails, per RTN21). +LOOP up to 30 times: + ADVANCE_TIME(5000) + IF client.connection.state == ConnectionState.suspended: + BREAK + +AWAIT_STATE client.connection.state == ConnectionState.suspended +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.suspended + +suspended_change = channel_state_changes.find(c => c.current == ChannelState.suspended) +ASSERT suspended_change IS NOT null +ASSERT suspended_change.previous == ChannelState.attached +CLOSE_CLIENT(client) +``` + +--- + +## RTL3c - SUSPENDED connection transitions ATTACHING channel to SUSPENDED + +**Test ID**: `realtime/unit/RTL3c/suspended-attaching-to-suspended-1` + +**Spec requirement:** If the connection state enters the SUSPENDED state, then an ATTACHING or ATTACHED channel state will transition to SUSPENDED. + +Tests that a channel in the ATTACHING state transitions to SUSPENDED when the connection enters SUSPENDED state. + +### Setup +```pseudo +channel_name = "test-RTL3c-attaching-${random_id()}" + +enable_fake_timers() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Do NOT respond - leave channel in ATTACHING state + pass + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + fallbackHosts: [], + disconnectedRetryTimeout: 1000 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach but don't await - server won't respond +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Record channel state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Simulate disconnect - all reconnection attempts will fail +mock_ws.onConnectionAttempt = (conn) => conn.respond_with_refused() +mock_ws.active_connection.simulate_disconnect() + +# Connection must exhaust disconnectedRetryTimeout retries within connectionStateTtl +# to transition from DISCONNECTED to SUSPENDED. The total time advance must exceed +# connectionStateTtl (from connectionDetails, per RTN21). +LOOP up to 30 times: + ADVANCE_TIME(5000) + IF client.connection.state == ConnectionState.suspended: + BREAK + +AWAIT_STATE client.connection.state == ConnectionState.suspended +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.suspended + +suspended_change = channel_state_changes.find(c => c.current == ChannelState.suspended) +ASSERT suspended_change IS NOT null +ASSERT suspended_change.previous == ChannelState.attaching +CLOSE_CLIENT(client) +``` + +--- + +## RTL3d, RTL4c1 - CONNECTED connection re-attaches ATTACHED channels with channelSerial + +**Test ID**: `realtime/unit/RTL3d/reattach-attached-with-serial-0` + +| Spec | Requirement | +|------|-------------| +| RTL3d | If the connection state enters the CONNECTED state, any channels in the ATTACHING, ATTACHED, or SUSPENDED states should be transitioned to ATTACHING and initiate an RTL4c attach sequence | +| RTL4c1 | The ATTACH ProtocolMessage channelSerial field must be set to the RTL15b channelSerial | + +Tests that when a connection is re-established, previously attached channels are re-attached automatically, and that the re-attach ATTACH message includes the channel's stored channelSerial. + +### Setup +```pseudo +channel_name = "test-RTL3d-attached-${random_id()}" +attach_messages = [] + +late mock_ws +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "serial-001" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + disconnectedRetryTimeout: 100 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached +ASSERT length(attach_messages) == 1 + +# Record channel state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Simulate disconnect +mock_ws.active_connection.simulate_disconnect() + +# Wait for reconnection and re-attach +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached + +# A second ATTACH message was sent for the re-attach +ASSERT length(attach_messages) == 2 + +# RTL4c1: The re-attach ATTACH message must include the channelSerial +# from the previous ATTACHED response +ASSERT attach_messages[1].channelSerial == "serial-001" + +# Channel transitioned through ATTACHING during re-attach +ASSERT channel_state_changes CONTAINS_IN_ORDER [ + ChannelState.attaching, + ChannelState.attached +] +CLOSE_CLIENT(client) +``` + +--- + +## RTL3d - CONNECTED connection re-attaches SUSPENDED channels + +**Test ID**: `realtime/unit/RTL3d/reattach-suspended-channels-1` + +**Spec requirement:** If the connection state enters the CONNECTED state, any channels in the ATTACHING, ATTACHED, or SUSPENDED states should be transitioned to ATTACHING and initiate an RTL4c attach sequence. + +Tests that suspended channels are re-attached when the connection is re-established. + +### Setup +```pseudo +channel_name = "test-RTL3d-suspended-${random_id()}" +attach_message_count = 0 + +enable_fake_timers() + +late mock_ws +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_message_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + fallbackHosts: [], + disconnectedRetryTimeout: 1000, + suspendedRetryTimeout: 2000 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached +ASSERT attach_message_count == 1 + +# Simulate disconnect - all reconnection attempts will fail +mock_ws.onConnectionAttempt = (conn) => conn.respond_with_refused() +mock_ws.active_connection.simulate_disconnect() + +# Connection must exhaust disconnectedRetryTimeout retries within connectionStateTtl +# to transition from DISCONNECTED to SUSPENDED. The total time advance must exceed +# connectionStateTtl (from connectionDetails, per RTN21). +LOOP up to 30 times: + ADVANCE_TIME(5000) + IF client.connection.state == ConnectionState.suspended: + BREAK + +AWAIT_STATE client.connection.state == ConnectionState.suspended +ASSERT channel.state == ChannelState.suspended + +# Record channel state changes from this point +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Allow reconnection to succeed +mock_ws.onConnectionAttempt = (conn) => conn.respond_with_success(CONNECTED_MESSAGE) + +# Advance time past suspendedRetryTimeout to trigger retry +LOOP up to 10 times: + ADVANCE_TIME(2500) + IF client.connection.state == ConnectionState.connected: + BREAK + +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached + +# An ATTACH message was sent for the re-attach +ASSERT attach_message_count >= 2 + +# Channel transitioned from SUSPENDED through ATTACHING to ATTACHED +ASSERT channel_state_changes CONTAINS_IN_ORDER [ + ChannelState.attaching, + ChannelState.attached +] +CLOSE_CLIENT(client) +``` + +--- + +## RTL3d - Channels in INITIALIZED or DETACHED are not re-attached on CONNECTED + +**Test ID**: `realtime/unit/RTL3d/init-detached-not-reattached-2` + +**Spec requirement:** If the connection state enters the CONNECTED state, any channels in the ATTACHING, ATTACHED, or SUSPENDED states should be transitioned to ATTACHING. + +Tests that channels in INITIALIZED or DETACHED states are not affected when the connection becomes CONNECTED. + +### Setup +```pseudo +initialized_channel_name = "test-RTL3d-init-${random_id()}" +detached_channel_name = "test-RTL3d-detached-${random_id()}" +attach_messages = [] + +late mock_ws +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + disconnectedRetryTimeout: 100 +)) +initialized_channel = client.channels.get(initialized_channel_name) +detached_channel = client.channels.get(detached_channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Leave initialized_channel in INITIALIZED state +ASSERT initialized_channel.state == ChannelState.initialized + +# Attach then detach to get to DETACHED state +AWAIT detached_channel.attach() +AWAIT detached_channel.detach() +ASSERT detached_channel.state == ChannelState.detached + +attach_count_before = length(attach_messages) + +# Record state changes +init_changes = [] +detached_changes = [] +initialized_channel.on().listen((change) => init_changes.append(change)) +detached_channel.on().listen((change) => detached_changes.append(change)) + +# Simulate disconnect and reconnect +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions +```pseudo +# Neither channel should have been re-attached +ASSERT initialized_channel.state == ChannelState.initialized +ASSERT detached_channel.state == ChannelState.detached +ASSERT length(init_changes) == 0 +ASSERT length(detached_changes) == 0 + +# No new ATTACH messages for these channels +attach_count_after = length(attach_messages) +new_attach_channels = [m.channel FOR m IN attach_messages[attach_count_before:]] +ASSERT initialized_channel_name NOT IN new_attach_channels +ASSERT detached_channel_name NOT IN new_attach_channels +CLOSE_CLIENT(client) +``` + +--- + +## RTL3d - Multiple channels re-attached on CONNECTED + +**Test ID**: `realtime/unit/RTL3d/multiple-channels-reattached-3` + +**Spec requirement:** If the connection state enters the CONNECTED state, any channels in the ATTACHING, ATTACHED, or SUSPENDED states should be transitioned to ATTACHING and initiate an RTL4c attach sequence. + +Tests that multiple channels in eligible states are all re-attached when the connection is restored. + +### Setup +```pseudo +channel1_name = "test-RTL3d-multi1-${random_id()}" +channel2_name = "test-RTL3d-multi2-${random_id()}" +attach_messages = [] + +late mock_ws +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + disconnectedRetryTimeout: 100 +)) +channel1 = client.channels.get(channel1_name) +channel2 = client.channels.get(channel2_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel1.attach() +AWAIT channel2.attach() +ASSERT channel1.state == ChannelState.attached +ASSERT channel2.state == ChannelState.attached + +attach_count_before = length(attach_messages) + +# Simulate disconnect and reconnect +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT_STATE channel1.state == ChannelState.attached +AWAIT_STATE channel2.state == ChannelState.attached +``` + +### Assertions +```pseudo +ASSERT channel1.state == ChannelState.attached +ASSERT channel2.state == ChannelState.attached + +# Both channels should have received new ATTACH messages +new_attach_channels = [m.channel FOR m IN attach_messages[attach_count_before:]] +ASSERT channel1_name IN new_attach_channels +ASSERT channel2_name IN new_attach_channels +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/channels/channel_delta_decoding.md b/uts/realtime/unit/channels/channel_delta_decoding.md new file mode 100644 index 000000000..674fe657c --- /dev/null +++ b/uts/realtime/unit/channels/channel_delta_decoding.md @@ -0,0 +1,1277 @@ +# Channel Delta Decoding Tests + +Spec points: `RTL18`, `RTL18a`, `RTL18b`, `RTL18c`, `RTL19`, `RTL19a`, `RTL19b`, `RTL19c`, `RTL20`, `RTL21`, `PC3`, `PC3a` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Mock VCDiff Infrastructure + +See `uts/test/realtime/unit/helpers/mock_vcdiff.md` for the full Mock VCDiff Infrastructure specification. + +> **Transport encoding note:** On JSON transport (the default for unit tests), +> binary vcdiff delta payloads cannot be transmitted as raw bytes — they must be +> base64-encoded. Mock message constructions in these tests use raw data with +> `encoding: "vcdiff"` for clarity. Implementations using JSON transport should +> adapt mock messages to use `base64_encode(delta)` as the data, with `/base64` +> appended to the encoding field (e.g., `"vcdiff/base64"` or `"utf-8/vcdiff/base64"`). +> The SDK's decoding pipeline processes encoding steps right-to-left: base64-decode +> first, then apply vcdiff, then decode utf-8 if present. + +--- + +## RTL21 - Messages in array decoded in ascending index order + +**Test ID**: `realtime/unit/RTL21/ascending-index-order-0` + +**Spec requirement:** The messages in the `messages` array of a `ProtocolMessage` should each be decoded in ascending order of their index in the array. + +Tests that when a ProtocolMessage contains multiple messages where later messages +are deltas referencing earlier messages, they are decoded correctly because +processing happens in array order. + +### Setup +```pseudo +channel_name = "test-RTL21-order-${random_id()}" +encoder = MockVCDiffEncoder() +decoder = MockVCDiffDecoder() + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: decoder } +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Send a ProtocolMessage with 3 messages: +# - msg-1: non-delta (establishes base) +# - msg-2: delta referencing msg-1 +# - msg-3: delta referencing msg-2 +# This only works if messages are decoded in order [0], [1], [2] + +base_data = "first message" +second_data = "second message" +third_data = "third message" + +delta_1_to_2 = encoder.encode(base_data, second_data) +delta_2_to_3 = encoder.encode(second_data, third_data) + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "serial:0", + messages: [ + { + id: "serial:0", + data: base_data, + encoding: null + }, + { + id: "serial:1", + data: delta_1_to_2, + encoding: "vcdiff", + extras: { delta: { from: "serial:0", format: "vcdiff" } } + }, + { + id: "serial:2", + data: delta_2_to_3, + encoding: "vcdiff", + extras: { delta: { from: "serial:1", format: "vcdiff" } } + } + ] +)) + +AWAIT length(received_messages) == 3 +``` + +### Assertions +```pseudo +ASSERT received_messages[0].data == "first message" +ASSERT received_messages[1].data == "second message" +ASSERT received_messages[2].data == "third message" +CLOSE_CLIENT(client) +``` + +--- + +## RTL19b - Non-delta message stores base payload + +**Test ID**: `realtime/unit/RTL19b/stores-base-payload-0` + +**Spec requirement:** In the case of a non-delta message, the resulting `data` value is stored as the base payload. + +Tests that after receiving a non-delta message, its data is stored as the base +payload so that a subsequent delta message can reference it. + +### Setup +```pseudo +channel_name = "test-RTL19b-base-${random_id()}" +encoder = MockVCDiffEncoder() +decoder = MockVCDiffDecoder() + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: decoder } +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Send non-delta message to establish base +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-1:0", + messages: [ + { + id: "msg-1:0", + data: "base payload", + encoding: null + } + ] +)) + +AWAIT length(received_messages) == 1 + +# Send delta referencing the base +delta = encoder.encode("base payload", "updated payload") + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-2:0", + messages: [ + { + id: "msg-2:0", + data: delta, + encoding: "vcdiff", + extras: { delta: { from: "msg-1:0", format: "vcdiff" } } + } + ] +)) + +AWAIT length(received_messages) == 2 +``` + +### Assertions +```pseudo +ASSERT received_messages[0].data == "base payload" +ASSERT received_messages[1].data == "updated payload" +CLOSE_CLIENT(client) +``` + +--- + +## RTL19b - JSON-encoded non-delta message stores wire-form base payload + +**Test ID**: `realtime/unit/RTL19b/json-wire-form-base-1` + +**Spec requirement:** In the case of a non-delta message, the resulting `data` value +is stored as the base payload. + +Tests that when a non-delta message has `encoding: "json"`, the base payload stored +for subsequent delta decoding is the raw JSON **string** (the wire form after base64 +decoding, if any, but **before** json/utf-8 decoding), not the parsed object. This +matches the ably-js behaviour where `lastPayload` is only updated by `base64` +(outermost) and `vcdiff` steps, never by `json` or `utf-8`. + +This is critical because the vcdiff delta is computed by the server against the +wire-form payload. Storing the fully-decoded object (e.g., a Map) instead of the +JSON string would cause vcdiff decoding to fail with "no base payload available" +since the stored value would not be a String or Uint8List. + +### Setup +```pseudo +channel_name = "test-RTL19b-json-base-${random_id()}" +encoder = MockVCDiffEncoder() +decoder = MockVCDiffDecoder() + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: decoder } +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Send a non-delta message with JSON encoding. +# The wire data is a JSON string; after decoding, the subscriber sees a Map. +# The base payload stored for delta decoding should be the JSON string, +# not the parsed Map. +json_string = '{"foo":"bar","count":1}' + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-1:0", + messages: [ + { + id: "msg-1:0", + data: json_string, + encoding: "json" + } + ] +)) + +AWAIT length(received_messages) == 1 + +# Send a delta referencing the JSON string base. +# The delta is computed against the JSON string, not the parsed object. +new_json_string = '{"foo":"baz","count":2}' +delta = encoder.encode(json_string, new_json_string) + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-2:0", + messages: [ + { + id: "msg-2:0", + data: delta, + encoding: "utf-8/vcdiff", + extras: { delta: { from: "msg-1:0", format: "vcdiff" } } + } + ] +)) + +AWAIT length(received_messages) == 2 +``` + +### Assertions +```pseudo +# First message: subscriber receives the parsed JSON object +ASSERT received_messages[0].data == { "foo": "bar", "count": 1 } + +# Second message: delta decoded against JSON string base, then utf-8 decoded +# to produce the new JSON string, which is delivered as-is (no json encoding +# step in the delta message's encoding) +ASSERT received_messages[1].data == new_json_string +CLOSE_CLIENT(client) +``` + +--- + +## RTL19a - Base64 encoding step decoded before storing base payload + +**Test ID**: `realtime/unit/RTL19a/base64-decoded-before-store-0` + +**Spec requirement:** When processing any message (whether a delta or a full message), if the message `encoding` string ends in `base64`, the message `data` should be base64-decoded (and the `encoding` string modified accordingly per RSL6). + +Tests that a base64-encoded non-delta message is decoded before its data is +stored as the base payload, so that subsequent delta application uses the decoded +(binary) form. + +### Setup +```pseudo +channel_name = "test-RTL19a-base64-${random_id()}" +encoder = MockVCDiffEncoder() +decoder = MockVCDiffDecoder() + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: decoder } +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# The base payload is binary data [0x48, 0x65, 0x6C, 0x6C, 0x6F] ("Hello") +# Sent as base64-encoded string +base_binary = [0x48, 0x65, 0x6C, 0x6C, 0x6F] +base_as_base64 = "SGVsbG8=" + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-1:0", + messages: [ + { + id: "msg-1:0", + data: base_as_base64, + encoding: "base64" + } + ] +)) + +AWAIT length(received_messages) == 1 + +# Now send a delta that references the binary base payload +new_binary = [0x57, 0x6F, 0x72, 0x6C, 0x64] # "World" +delta = encoder.encode(base_binary, new_binary) + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-2:0", + messages: [ + { + id: "msg-2:0", + data: base64_encode(delta), + encoding: "vcdiff/base64", + extras: { delta: { from: "msg-1:0", format: "vcdiff" } } + } + ] +)) + +AWAIT length(received_messages) == 2 +``` + +### Assertions +```pseudo +# First message decoded from base64 to binary +ASSERT received_messages[0].data == base_binary + +# Second message delta-decoded using the binary base, then delivered as binary +ASSERT received_messages[1].data == new_binary +CLOSE_CLIENT(client) +``` + +--- + +## RTL19c - Delta application result stored as new base payload + +**Test ID**: `realtime/unit/RTL19c/delta-result-becomes-base-0` + +**Spec requirement:** In the case of a delta message with a `vcdiff` encoding step, the `vcdiff` decoder must be used to decode the base payload of the delta message, applying that delta to the stored base payload. The direct result of that vcdiff delta application, before performing any further decoding steps, is stored as the updated base payload. + +Tests that after decoding a delta message, the decoded result becomes the new +base payload for subsequent deltas (chained deltas). + +### Setup +```pseudo +channel_name = "test-RTL19c-chain-${random_id()}" +encoder = MockVCDiffEncoder() +decoder = MockVCDiffDecoder() + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: decoder } +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Message 1: non-delta, establishes base +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-1:0", + messages: [ + { + id: "msg-1:0", + data: "value-A", + encoding: null + } + ] +)) + +AWAIT length(received_messages) == 1 + +# Message 2: delta from msg-1 to value-B +delta_A_to_B = encoder.encode("value-A", "value-B") + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-2:0", + messages: [ + { + id: "msg-2:0", + data: delta_A_to_B, + encoding: "vcdiff", + extras: { delta: { from: "msg-1:0", format: "vcdiff" } } + } + ] +)) + +AWAIT length(received_messages) == 2 + +# Message 3: delta from msg-2 to value-C +# This verifies the base was updated to value-B after decoding msg-2 +delta_B_to_C = encoder.encode("value-B", "value-C") + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-3:0", + messages: [ + { + id: "msg-3:0", + data: delta_B_to_C, + encoding: "vcdiff", + extras: { delta: { from: "msg-2:0", format: "vcdiff" } } + } + ] +)) + +AWAIT length(received_messages) == 3 +``` + +### Assertions +```pseudo +ASSERT received_messages[0].data == "value-A" +ASSERT received_messages[1].data == "value-B" +ASSERT received_messages[2].data == "value-C" +CLOSE_CLIENT(client) +``` + +--- + +## RTL20 - Delta with mismatched base message ID triggers recovery + +**Test ID**: `realtime/unit/RTL20/mismatched-id-triggers-recovery-0` + +**Spec requirement:** The `id` of the last received message on each channel must be stored along with the base payload. When processing a delta message, the stored last message `id` must be compared against the delta reference `id` in `Message.extras.delta.from`. If the delta reference `id` does not equal the stored `id`, the message decoding must fail and the recovery procedure from RTL18 must be executed. + +Tests that when a delta message references a message ID that doesn't match the +stored last message ID, the client initiates decode failure recovery. + +### Setup +```pseudo +channel_name = "test-RTL20-mismatch-${random_id()}" +encoder = MockVCDiffEncoder() +decoder = MockVCDiffDecoder() + +state_changes = [] +attach_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: decoder } +)) +channel = client.channels.get(channel_name) +channel.on((change) => state_changes.append(change)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Establish base with msg-1 +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-1:0", + channelSerial: "serial-1", + messages: [ + { + id: "msg-1:0", + data: "base payload", + encoding: null + } + ] +)) + +# Wait for message to be processed +AWAIT Future.delayed(Duration.zero) + +# Clear state tracking from initial attach +state_changes = [] +initial_attach_count = length(attach_messages) + +# Send delta that references wrong message ID (msg-999 instead of msg-1) +delta = encoder.encode("base payload", "new payload") + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-2:0", + messages: [ + { + id: "msg-2:0", + data: delta, + encoding: "vcdiff", + extras: { delta: { from: "msg-999:0", format: "vcdiff" } } + } + ] +)) + +# RTL18c: channel transitions to ATTACHING and sends ATTACH +AWAIT_STATE channel.state == ChannelState.attaching +``` + +### Assertions +```pseudo +# RTL18c: A new ATTACH message was sent for recovery +ASSERT length(attach_messages) > initial_attach_count + +# RTL18c: The ATTACH message includes channelSerial from previous message +recovery_attach = attach_messages[length(attach_messages) - 1] +ASSERT recovery_attach.channelSerial == "serial-1" + +# RTL18c: Channel state went to ATTACHING with error code 40018 +ASSERT state_changes CONTAINS_IN_ORDER [ + ChannelState.attaching +] +attaching_change = FIND state_changes WHERE current == ChannelState.attaching +ASSERT attaching_change.reason.code == 40018 +CLOSE_CLIENT(client) +``` + +--- + +## RTL20 - Last message ID updated after successful decode + +**Test ID**: `realtime/unit/RTL20/last-id-updated-on-decode-1` + +**Spec requirement:** The `id` of the last received message on each channel must be stored along with the base payload. + +Tests that the stored last message ID is updated to the ID of the last message +in a ProtocolMessage after successful decoding, and is used correctly for the +next delta's base reference check. + +### Setup +```pseudo +channel_name = "test-RTL20-id-update-${random_id()}" +encoder = MockVCDiffEncoder() +decoder = MockVCDiffDecoder() + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: decoder } +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Send ProtocolMessage with 2 messages in the array +# The last message ID should be stored as "serial:1" (the last in the array) +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "serial:0", + messages: [ + { + id: "serial:0", + data: "first", + encoding: null + }, + { + id: "serial:1", + data: "second", + encoding: null + } + ] +)) + +AWAIT length(received_messages) == 2 + +# Now send a delta that references "serial:1" (the last message ID) +# This should succeed because the stored ID matches +delta = encoder.encode("second", "third") + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-2:0", + messages: [ + { + id: "msg-2:0", + data: delta, + encoding: "vcdiff", + extras: { delta: { from: "serial:1", format: "vcdiff" } } + } + ] +)) + +AWAIT length(received_messages) == 3 +``` + +### Assertions +```pseudo +# The delta was decoded successfully, confirming the stored ID was "serial:1" +ASSERT received_messages[0].data == "first" +ASSERT received_messages[1].data == "second" +ASSERT received_messages[2].data == "third" +CLOSE_CLIENT(client) +``` + +--- + +## PC3, PC3a - VCDiff plugin decodes delta messages + +**Test ID**: `realtime/unit/PC3/vcdiff-plugin-decodes-0` + +| Spec | Requirement | +|------|-------------| +| PC3 | A plugin provided with PluginType key `vcdiff` should be capable of decoding vcdiff-encoded messages | +| PC3a | The base argument of VCDiffDecoder.decode should receive the stored base payload; if the base is a string it should be UTF-8 encoded to binary before being passed | + +Tests that the vcdiff plugin is used to decode delta-encoded messages and that +string base payloads are UTF-8 encoded to binary before being passed to the +decoder. + +### Setup +```pseudo +channel_name = "test-PC3-decode-${random_id()}" +encoder = MockVCDiffEncoder() + +# Use a wrapping decoder that records the arguments it receives +decode_calls = [] + +recording_decoder = MockVCDiffDecoder( + onDecode: (delta, base) => { + decode_calls.append({ delta: delta, base: base }) + } +) + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: recording_decoder } +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Send a string non-delta message (establishes string base payload) +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-1:0", + messages: [ + { + id: "msg-1:0", + data: "hello world", + encoding: null + } + ] +)) + +AWAIT length(received_messages) == 1 + +# Send a delta message referencing the string base +delta = encoder.encode("hello world", "goodbye world") + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-2:0", + messages: [ + { + id: "msg-2:0", + data: delta, + encoding: "vcdiff", + extras: { delta: { from: "msg-1:0", format: "vcdiff" } } + } + ] +)) + +AWAIT length(received_messages) == 2 +``` + +### Assertions +```pseudo +# PC3: The decoder was called to decode the delta +ASSERT length(decode_calls) == 1 + +# PC3a: The base argument was UTF-8 encoded to binary +# "hello world" as UTF-8 bytes +ASSERT decode_calls[0].base == utf8_encode("hello world") + +# PC3a: The delta argument is the raw delta payload +ASSERT decode_calls[0].delta == delta + +# The decoded message was delivered to the subscriber +ASSERT received_messages[1].data == "goodbye world" +CLOSE_CLIENT(client) +``` + +--- + +## PC3 - No vcdiff plugin causes FAILED state + +**Test ID**: `realtime/unit/PC3/no-plugin-fails-1` + +**Spec requirement:** A plugin provided with the PluginType key `vcdiff` should be capable of decoding vcdiff-encoded messages. Without it, vcdiff-encoded messages cannot be decoded. + +Tests that when a vcdiff-encoded message is received but no vcdiff plugin is +registered, the channel transitions to FAILED with error code 40019. + +### Setup +```pseudo +channel_name = "test-PC3-no-plugin-${random_id()}" + +state_changes = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +# No vcdiff plugin registered +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +channel.on((change) => state_changes.append(change)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +state_changes = [] + +# Send a delta-encoded message without a vcdiff plugin registered +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-1:0", + messages: [ + { + id: "msg-1:0", + data: "some-delta-data", + encoding: "vcdiff", + extras: { delta: { from: "msg-0:0", format: "vcdiff" } } + } + ] +)) + +# Channel should transition to FAILED +AWAIT_STATE channel.state == ChannelState.failed +``` + +### Assertions +```pseudo +# Channel is FAILED with error code 40019 (no vcdiff plugin) +ASSERT channel.state == ChannelState.failed +ASSERT channel.errorReason.code == 40019 +CLOSE_CLIENT(client) +``` + +--- + +## RTL18 - Decode failure triggers recovery (RTL18a, RTL18b, RTL18c) + +**Test ID**: `realtime/unit/RTL18/decode-failure-recovery-0` + +| Spec | Requirement | +|------|-------------| +| RTL18a | Log error with code 40018 | +| RTL18b | Discard the message | +| RTL18c | Send ATTACH with channelSerial set to previous message's channelSerial, transition to ATTACHING, wait for ATTACHED confirmation. ChannelStateChange.reason should have code 40018. | + +Tests that when vcdiff decoding fails, the client discards the message, +transitions to ATTACHING, and sends an ATTACH with the correct channelSerial for +recovery. + +### Setup +```pseudo +channel_name = "test-RTL18-recovery-${random_id()}" + +state_changes = [] +attach_messages = [] +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +# Use a decoder that always fails +failing_decoder = FailingMockVCDiffDecoder() + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: failing_decoder } +)) +channel = client.channels.get(channel_name) +channel.on((change) => state_changes.append(change)) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Establish base with a non-delta message first +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-1:0", + channelSerial: "serial-100", + messages: [ + { + id: "msg-1:0", + data: "base payload", + encoding: null + } + ] +)) + +AWAIT length(received_messages) == 1 + +# Clear state tracking from initial attach +state_changes = [] +initial_attach_count = length(attach_messages) + +# Send a delta message — the failing decoder will throw during decode +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-2:0", + channelSerial: "serial-200", + messages: [ + { + id: "msg-2:0", + data: "fake-delta-payload", + encoding: "vcdiff", + extras: { delta: { from: "msg-1:0", format: "vcdiff" } } + } + ] +)) + +# RTL18c: channel transitions to ATTACHING for recovery +AWAIT_STATE channel.state == ChannelState.attaching +``` + +### Assertions +```pseudo +# RTL18b: The failed delta message was NOT delivered to subscribers +ASSERT length(received_messages) == 1 +ASSERT received_messages[0].data == "base payload" + +# RTL18c: A new ATTACH was sent for recovery +ASSERT length(attach_messages) > initial_attach_count +recovery_attach = attach_messages[length(attach_messages) - 1] + +# RTL18c: The ATTACH includes channelSerial from the previous successful message +ASSERT recovery_attach.channelSerial == "serial-100" + +# RTL18c: Channel state went to ATTACHING with error code 40018 +ASSERT state_changes CONTAINS_IN_ORDER [ + ChannelState.attaching +] +attaching_change = FIND state_changes WHERE current == ChannelState.attaching +ASSERT attaching_change.reason.code == 40018 +CLOSE_CLIENT(client) +``` + +--- + +## RTL18c - Recovery completes when server sends ATTACHED + +**Test ID**: `realtime/unit/RTL18c/recovery-completes-on-attached-0` + +**Spec requirement:** Send an ATTACH ProtocolMessage and wait for a confirmation ATTACHED, as per RTL4c and RTL4f. + +Tests that after decode failure recovery, the channel returns to ATTACHED state +when the server confirms with an ATTACHED ProtocolMessage, and that new messages +can be received afterwards. + +### Setup +```pseudo +channel_name = "test-RTL18c-complete-${random_id()}" +encoder = MockVCDiffEncoder() + +state_changes = [] +received_messages = [] +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +# Use a decoder that fails on first call, then succeeds +decode_attempt = 0 +conditional_decoder = MockVCDiffDecoder( + onDecode: (delta, base) => { + decode_attempt++ + IF decode_attempt == 1: + THROW "Simulated decode failure" + } +) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: conditional_decoder } +)) +channel = client.channels.get(channel_name) +channel.on((change) => state_changes.append(change)) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Establish base +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-1:0", + channelSerial: "serial-1", + messages: [ + { + id: "msg-1:0", + data: "original base", + encoding: null + } + ] +)) + +AWAIT length(received_messages) == 1 + +# Send delta that will fail on first decode attempt +# This triggers recovery → ATTACHING → ATTACHED +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-2:0", + channelSerial: "serial-2", + messages: [ + { + id: "msg-2:0", + data: "bad-delta", + encoding: "vcdiff", + extras: { delta: { from: "msg-1:0", format: "vcdiff" } } + } + ] +)) + +# Recovery: ATTACHING → server auto-responds with ATTACHED +AWAIT_STATE channel.state == ChannelState.attached + +state_changes = [] + +# After recovery, server resends from channelSerial with a fresh non-delta +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-3:0", + channelSerial: "serial-3", + messages: [ + { + id: "msg-3:0", + data: "fresh after recovery", + encoding: null + } + ] +)) + +AWAIT length(received_messages) == 2 +``` + +### Assertions +```pseudo +# Channel recovered and is now attached +ASSERT channel.state == ChannelState.attached + +# Messages received: the original base and the fresh message after recovery +# (the failed delta msg-2 was discarded per RTL18b) +ASSERT received_messages[0].data == "original base" +ASSERT received_messages[1].data == "fresh after recovery" +CLOSE_CLIENT(client) +``` + +--- + +## RTL18 - Only one recovery in progress at a time + +**Test ID**: `realtime/unit/RTL18/single-recovery-at-time-1` + +**Spec requirement:** The client must automatically execute the recovery procedure. (Implied: concurrent decode failures should not trigger multiple simultaneous recovery attempts.) + +Tests that if multiple delta decode failures occur in quick succession, only one +recovery ATTACH is sent (the recovery flag prevents duplicate recovery attempts). + +### Setup +```pseudo +channel_name = "test-RTL18-single-recovery-${random_id()}" + +attach_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_messages.append(msg) + # Do NOT auto-respond with ATTACHED — leave recovery in progress + IF length(attach_messages) == 1: + # Only respond to the initial attach + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +failing_decoder = FailingMockVCDiffDecoder() + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: failing_decoder } +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +initial_attach_count = length(attach_messages) + +# Establish base +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-1:0", + channelSerial: "serial-1", + messages: [ + { + id: "msg-1:0", + data: "base", + encoding: null + } + ] +)) + +AWAIT Future.delayed(Duration.zero) + +# Send first delta that will fail — triggers recovery +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-2:0", + messages: [ + { + id: "msg-2:0", + data: "bad-delta-1", + encoding: "vcdiff", + extras: { delta: { from: "msg-1:0", format: "vcdiff" } } + } + ] +)) + +AWAIT_STATE channel.state == ChannelState.attaching + +# Send second delta that also fails — recovery already in progress +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-3:0", + messages: [ + { + id: "msg-3:0", + data: "bad-delta-2", + encoding: "vcdiff", + extras: { delta: { from: "msg-2:0", format: "vcdiff" } } + } + ] +)) + +AWAIT Future.delayed(Duration.zero) +``` + +### Assertions +```pseudo +# Only one recovery ATTACH was sent (not two) +recovery_attaches = length(attach_messages) - initial_attach_count +ASSERT recovery_attaches == 1 +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/channels/channel_detach.md b/uts/realtime/unit/channels/channel_detach.md new file mode 100644 index 000000000..40a1a16a7 --- /dev/null +++ b/uts/realtime/unit/channels/channel_detach.md @@ -0,0 +1,800 @@ +# RealtimeChannel Detach Tests + +Spec points: `RTL5`, `RTL5a`, `RTL5b`, `RTL5d`, `RTL5e`, `RTL5f`, `RTL5i`, `RTL5j`, `RTL5k`, `RTL5l` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL5a - Detach when initialized is no-op + +**Test ID**: `realtime/unit/RTL5a/detach-initialized-noop-0` + +**Spec requirement:** If the channel state is INITIALIZED or DETACHED nothing is done. + +Tests that detach on an initialized channel returns immediately. + +### Setup +```pseudo +channel_name = "test-RTL5a-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +ASSERT channel.state == ChannelState.initialized + +# Detach from initialized state - should be no-op +AWAIT channel.detach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.initialized OR channel.state == ChannelState.detached +# No state change events should have been emitted (or only to detached) +CLOSE_CLIENT(client) +``` + +--- + +## RTL5a - Detach when already detached is no-op + +**Test ID**: `realtime/unit/RTL5a/detach-already-detached-noop-1` + +**Spec requirement:** If the channel state is INITIALIZED or DETACHED nothing is done. + +Tests that detach on an already-detached channel returns immediately. + +### Setup +```pseudo +channel_name = "test-RTL5a-detached-${random_id()}" +detach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == DETACH: + detach_message_count++ + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach then detach +AWAIT channel.attach() +AWAIT channel.detach() +ASSERT channel.state == ChannelState.detached +ASSERT detach_message_count == 1 + +# Second detach - should be no-op +AWAIT channel.detach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached +ASSERT detach_message_count == 1 # No additional DETACH message sent +CLOSE_CLIENT(client) +``` + +--- + +## RTL5i - Detach while detaching waits for completion + +**Test ID**: `realtime/unit/RTL5i/detach-while-detaching-0` + +**Spec requirement:** If the channel is in a pending state DETACHING, do the detach operation after the completion of the pending request. + +Tests that calling detach while already detaching waits for the first detach to complete. + +### Setup +```pseudo +channel_name = "test-RTL5i-${random_id()}" +detach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == DETACH: + detach_message_count++ + # Delay response to allow second detach call + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() + +# Start first detach (don't await) +detach_future_1 = channel.detach() + +# Wait for channel to enter detaching state +AWAIT_STATE channel.state == ChannelState.detaching + +# Start second detach while first is pending +detach_future_2 = channel.detach() + +# Now send the DETACHED response +mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name +)) + +# Both should complete +AWAIT detach_future_1 +AWAIT detach_future_2 +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached +ASSERT detach_message_count == 1 # Only one DETACH message sent +CLOSE_CLIENT(client) +``` + +--- + +## RTL5i - Detach while attaching waits then detaches + +**Test ID**: `realtime/unit/RTL5i/detach-while-attaching-1` + +**Spec requirement:** If the channel is in a pending state ATTACHING, do the detach operation after the completion of the pending request. + +Tests that calling detach while attaching waits for attach to complete, then detaches. + +> **Implementation note:** When detach is called while the channel is ATTACHING, +> the attach future/promise may be rejected in some implementations (since the +> intent has changed to detach). Other implementations may resolve the attach +> future when ATTACHED arrives, before proceeding to detach. Both behaviors are +> acceptable — implementations should handle both outcomes and suppress unhandled +> rejection errors from the superseded attach operation. + +### Setup +```pseudo +channel_name = "test-RTL5i-attaching-${random_id()}" +messages_from_client = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + messages_from_client.append(msg) + IF msg.action == ATTACH: + # Delay response + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach (don't await) +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Start detach while attaching +detach_future = channel.detach() + +# Send ATTACHED response - attach completes +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name +)) + +# Wait for both operations +AWAIT attach_future +AWAIT detach_future +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached +# Should have: ATTACH, DETACH +ASSERT length(messages_from_client) == 2 +ASSERT messages_from_client[0].action == ATTACH +ASSERT messages_from_client[1].action == DETACH +CLOSE_CLIENT(client) +``` + +--- + +## RTL5b - Detach from failed state results in error + +**Test ID**: `realtime/unit/RTL5b/detach-failed-errors-0` + +**Spec requirement:** If the channel state is FAILED, the detach request results in an error. + +Tests that detach fails when channel is in failed state. + +### Setup +```pseudo +channel_name = "test-RTL5b-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Fail the attachment + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 40160, message: "Not permitted") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach fails - channel enters failed state +AWAIT channel.attach() FAILS WITH attach_error +ASSERT channel.state == ChannelState.failed + +# Try to detach from failed state +AWAIT channel.detach() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT channel.state == ChannelState.failed # State unchanged +CLOSE_CLIENT(client) +``` + +--- + +## RTL5j - Detach from suspended transitions to detached + +**Test ID**: `realtime/unit/RTL5j/detach-suspended-to-detached-0` + +**Spec requirement:** If the channel state is SUSPENDED, the detach request transitions the channel immediately to the DETACHED state. + +Tests that detach from suspended state transitions directly to detached without sending DETACH message. + +### Setup +```pseudo +channel_name = "test-RTL5j-${random_id()}" +detach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Don't respond - let it timeout to suspended + ELSE IF msg.action == DETACH: + detach_message_count++ + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 100 # Short timeout +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach +attach_future = channel.attach() + +# Let it timeout to suspended +ADVANCE_TIME(150) +AWAIT attach_future FAILS WITH error +ASSERT channel.state == ChannelState.suspended + +# Detach from suspended +AWAIT channel.detach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached +ASSERT detach_message_count == 0 # No DETACH message sent - immediate transition +CLOSE_CLIENT(client) +``` + +--- + +## RTL5l - Detach when connection not connected transitions immediately + +**Test ID**: `realtime/unit/RTL5l/detach-not-connected-immediate-0` + +**Spec requirement:** If the connection state is anything other than CONNECTED and none of the preceding channel state conditions apply, the channel transitions immediately to the DETACHED state. + +Tests that detach transitions immediately to detached when connection is not connected. + +### Setup +```pseudo +channel_name = "test-RTL5l-${random_id()}" +detach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Delay connection + }, + onMessageFromClient: (msg) => { + IF msg.action == DETACH: + detach_message_count++ + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Start connecting but don't complete +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Put channel into attaching state +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Now detach while connection is still connecting +AWAIT channel.detach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached +ASSERT detach_message_count == 0 # No DETACH message sent +CLOSE_CLIENT(client) +``` + +--- + +### RTL5l - Detach ATTACHED channel when connection disconnected + +**Test ID**: `realtime/unit/RTL5l/detach-attached-when-disconnected-1` + +When an ATTACHED channel is detached while the connection is DISCONNECTED, +the channel transitions directly to DETACHED without sending a DETACH message +(since the transport is unavailable). + +#### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => + mock_ws.active_connection = conn + conn.respond_with_connected() +) +install_mock(mock_ws) + +client = create_realtime_client(ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +channel = client.channels.get("test-channel") +``` + +#### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach the channel +mock_ws.onMessageFromClient = (msg) => + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) +channel.attach() +AWAIT_STATE channel.state == ChannelState.attached + +# Disconnect the transport +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Now detach while disconnected +messages_sent = [] +mock_ws.onMessageFromClient = (msg) => messages_sent.push(msg) + +channel.detach() +AWAIT_STATE channel.state == ChannelState.detached +``` + +#### Assertions + +```pseudo +# Channel transitions directly to DETACHED +ASSERT channel.state == ChannelState.detached + +# No DETACH message was sent (transport is unavailable) +detach_messages = messages_sent.filter(m => m.action == DETACH) +ASSERT detach_messages.length == 0 +``` + +--- + +## RTL5d - Normal detach flow + +**Test ID**: `realtime/unit/RTL5d/normal-detach-flow-0` + +**Spec requirement:** A DETACH ProtocolMessage is sent to the server, the state transitions to DETACHING and the channel becomes DETACHED when the confirmation DETACHED ProtocolMessage is received. + +Tests the normal detach flow when connection is connected. + +### Setup +```pseudo +channel_name = "test-RTL5d-${random_id()}" +captured_detach_message = null + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == DETACH: + captured_detach_message = msg + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() + +state_during_detach = null +channel.on(ChannelEvent.detaching).listen((change) => { + state_during_detach = channel.state +}) + +AWAIT channel.detach() +``` + +### Assertions +```pseudo +ASSERT state_during_detach == ChannelState.detaching +ASSERT channel.state == ChannelState.detached +ASSERT captured_detach_message IS NOT null +ASSERT captured_detach_message.action == DETACH +ASSERT captured_detach_message.channel == channel_name +CLOSE_CLIENT(client) +``` + +--- + +## RTL5f - Detach timeout returns to previous state + +**Test ID**: `realtime/unit/RTL5f/timeout-returns-previous-state-0` + +**Spec requirement:** If a DETACHED ProtocolMessage is not received within realtimeRequestTimeout, the detach request should be treated as though it has failed and the channel will return to its previous state. + +Tests detach timeout behavior. + +### Setup +```pseudo +channel_name = "test-RTL5f-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == DETACH: + # Don't respond - simulate timeout + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 100 # Short timeout +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +detach_future = channel.detach() + +# Advance time past timeout +ADVANCE_TIME(150) + +AWAIT detach_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached # Returns to previous state +ASSERT error IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RTL5k - ATTACHED received while detaching sends new DETACH + +**Test ID**: `realtime/unit/RTL5k/attached-while-detaching-0` + +**Spec requirement:** If the channel receives an ATTACHED message while in the DETACHING or DETACHED state, it should send a new DETACH message and remain in (or transition to) the DETACHING state. + +Tests that unexpected ATTACHED message during detach triggers new DETACH. + +### Setup +```pseudo +channel_name = "test-RTL5k-${random_id()}" +detach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == DETACH: + detach_message_count++ + IF detach_message_count == 1: + # First DETACH: server sends ATTACHED instead of DETACHED + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE: + # Second DETACH: respond correctly + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() + +# Start detach +AWAIT channel.detach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached +ASSERT detach_message_count == 2 # Two DETACH messages sent +CLOSE_CLIENT(client) +``` + +--- + +## RTL5k - ATTACHED received while detached sends DETACH + +**Test ID**: `realtime/unit/RTL5k/attached-while-detached-1` + +**Spec requirement:** If the channel receives an ATTACHED message while in the DETACHED state, it should send a new DETACH message. + +Tests that unexpected ATTACHED message while detached triggers DETACH. + +### Setup +```pseudo +channel_name = "test-RTL5k-detached-${random_id()}" +detach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == DETACH: + detach_message_count++ + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +AWAIT channel.detach() +ASSERT channel.state == ChannelState.detached +ASSERT detach_message_count == 1 + +# Server unexpectedly sends ATTACHED while detached +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name +)) + +# Wait for client to respond +WAIT 100ms +``` + +### Assertions +```pseudo +ASSERT detach_message_count == 2 # Client sent another DETACH +ASSERT channel.state == ChannelState.detached +CLOSE_CLIENT(client) +``` + +--- + +## RTL5 - Detach emits state change events + +**Test ID**: `realtime/unit/RTL5/detach-state-change-events-0` + +**Spec requirement:** Channel emits state change events during detach. + +Tests that appropriate state change events are emitted during detach. + +### Setup +```pseudo +channel_name = "test-RTL5-events-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) + +state_changes = [] +channel.on().listen((change) => state_changes.append(change)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +state_changes.clear() # Clear attach state changes + +AWAIT channel.detach() +``` + +### Assertions +```pseudo +ASSERT length(state_changes) >= 2 + +# First event: detaching +ASSERT state_changes[0].current == ChannelState.detaching +ASSERT state_changes[0].previous == ChannelState.attached +ASSERT state_changes[0].event == ChannelEvent.detaching + +# Second event: detached +ASSERT state_changes[1].current == ChannelState.detached +ASSERT state_changes[1].previous == ChannelState.detaching +ASSERT state_changes[1].event == ChannelEvent.detached +CLOSE_CLIENT(client) +``` + +--- + +## [REMOVED] RTL5 - Detach clears errorReason + +**This test has been removed.** The features spec (RTL5a through RTL5l) does not specify that detach clears errorReason. Channel errorReason is cleared by a successful attach (RTL4c) and by connection reconnect (RTN11d). Detach is not among them. The original test scenario (FAILED → re-attach → detach → assert null) was accidentally correct because the re-attach cleared errorReason via RTL4c, not because detach did anything. diff --git a/uts/realtime/unit/channels/channel_error.md b/uts/realtime/unit/channels/channel_error.md new file mode 100644 index 000000000..c050b7940 --- /dev/null +++ b/uts/realtime/unit/channels/channel_error.md @@ -0,0 +1,367 @@ +# Channel ERROR Protocol Message Tests + +Spec points: `RTL14` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL14 - Channel ERROR transitions ATTACHED channel to FAILED + +**Test ID**: `realtime/unit/RTL14/attached-to-failed-0` + +**Spec requirement:** If an ERROR ProtocolMessage is received for this channel (the channel attribute matches this channel's name), then the channel should immediately transition to the FAILED state, and the RealtimeChannel.errorReason should be set. + +Tests that receiving a channel-scoped ERROR while ATTACHED causes the channel to transition to FAILED with the error. + +### Setup +```pseudo +channel_name = "test-RTL14-attached-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Record channel state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Server sends channel-scoped ERROR (e.g., permission revoked) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 40160, statusCode: 401, message: "Not permitted") +)) +AWAIT_STATE channel.state == ChannelState.failed +``` + +### Assertions +```pseudo +# Channel transitioned to FAILED +ASSERT channel.state == ChannelState.failed + +# errorReason is set +ASSERT channel.errorReason IS NOT null +ASSERT channel.errorReason.code == 40160 +ASSERT channel.errorReason.statusCode == 401 +ASSERT channel.errorReason.message CONTAINS "Not permitted" + +# State change event emitted +ASSERT length(channel_state_changes) == 1 +ASSERT channel_state_changes[0].current == ChannelState.failed +ASSERT channel_state_changes[0].previous == ChannelState.attached +ASSERT channel_state_changes[0].reason IS NOT null +ASSERT channel_state_changes[0].reason.code == 40160 + +# Connection stays open (channel-scoped ERROR does NOT close connection) +ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) +``` + +--- + +## RTL14 - Channel ERROR transitions ATTACHING channel to FAILED + +**Test ID**: `realtime/unit/RTL14/attaching-to-failed-1` + +**Spec requirement:** If an ERROR ProtocolMessage is received for this channel, the channel should immediately transition to FAILED. + +Tests that receiving a channel-scoped ERROR while ATTACHING causes the channel to transition to FAILED and the pending attach operation to fail. + +### Setup +```pseudo +channel_name = "test-RTL14-attaching-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Respond with channel ERROR instead of ATTACHED + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: msg.channel, + error: ErrorInfo(code: 40160, statusCode: 401, message: "Not permitted") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach should fail +AWAIT channel.attach() FAILS WITH error +``` + +### Assertions +```pseudo +# Channel is in FAILED state +ASSERT channel.state == ChannelState.failed + +# errorReason is set +ASSERT channel.errorReason IS NOT null +ASSERT channel.errorReason.code == 40160 + +# The error from attach() matches the channel error +ASSERT error.code == 40160 + +# Connection stays open +ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) +``` + +--- + +## RTL14 - Channel ERROR completes pending detach with error + +**Test ID**: `realtime/unit/RTL14/pending-detach-error-2` + +**Spec requirement:** If an ERROR ProtocolMessage is received for this channel, the channel should immediately transition to FAILED. + +Tests that if a channel ERROR is received while a detach is pending (DETACHING state), the channel transitions to FAILED and the pending detach operation fails with the error. + +### Setup +```pseudo +channel_name = "test-RTL14-detaching-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + ELSE IF msg.action == DETACH: + # Respond with ERROR instead of DETACHED + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: msg.channel, + error: ErrorInfo(code: 90198, statusCode: 500, message: "Detach failed") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Detach should fail +AWAIT channel.detach() FAILS WITH error +``` + +### Assertions +```pseudo +# Channel is in FAILED state (not DETACHED) +ASSERT channel.state == ChannelState.failed + +# errorReason is set +ASSERT channel.errorReason IS NOT null +ASSERT channel.errorReason.code == 90198 + +# The error from detach() matches +ASSERT error.code == 90198 + +# Connection stays open +ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) +``` + +--- + +## RTL14 - Channel ERROR does not affect other channels + +**Test ID**: `realtime/unit/RTL14/other-channels-unaffected-3` + +**Spec requirement:** The ERROR ProtocolMessage with a channel attribute only affects that specific channel. + +Tests that a channel-scoped ERROR only transitions the target channel to FAILED, leaving other channels unaffected. + +### Setup +```pseudo +channel_name_a = "test-RTL14-a-${random_id()}" +channel_name_b = "test-RTL14-b-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel_a = client.channels.get(channel_name_a) +channel_b = client.channels.get(channel_name_b) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel_a.attach() +AWAIT channel_b.attach() +ASSERT channel_a.state == ChannelState.attached +ASSERT channel_b.state == ChannelState.attached + +# Send ERROR only for channel A +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name_a, + error: ErrorInfo(code: 40160, statusCode: 401, message: "Not permitted") +)) +AWAIT_STATE channel_a.state == ChannelState.failed +``` + +### Assertions +```pseudo +# Channel A is FAILED +ASSERT channel_a.state == ChannelState.failed +ASSERT channel_a.errorReason IS NOT null + +# Channel B is unaffected +ASSERT channel_b.state == ChannelState.attached +ASSERT channel_b.errorReason IS null + +# Connection stays open +ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) +``` + +--- + +## RTL14 - Channel ERROR cancels pending timers + +**Test ID**: `realtime/unit/RTL14/cancels-pending-timers-4` + +**Spec requirement:** When the channel transitions to FAILED, any pending timers (attach timeout, channel retry) should be cancelled. + +Tests that receiving a channel ERROR while a channel retry timer is pending cancels the timer. + +### Setup +```pseudo +channel_name = "test-RTL14-timers-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + # Don't respond to subsequent attaches + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 100, + channelRetryTimeout: 200 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT attach_count == 1 + +# Trigger server-initiated DETACHED -> reattach -> timeout -> SUSPENDED +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90198, statusCode: 500, message: "Detach") +)) +ADVANCE_TIME(150) +AWAIT_STATE channel.state == ChannelState.suspended + +# Channel retry timer is now pending (channelRetryTimeout = 200ms) +# Send ERROR before the retry fires +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 40160, statusCode: 401, message: "Not permitted") +)) +AWAIT_STATE channel.state == ChannelState.failed + +attach_count_after_error = attach_count + +# Advance time well past the channelRetryTimeout +ADVANCE_TIME(500) +``` + +### Assertions +```pseudo +# Channel remains FAILED - no retry was attempted +ASSERT channel.state == ChannelState.failed +ASSERT attach_count == attach_count_after_error +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/channels/channel_get_message.md b/uts/realtime/unit/channels/channel_get_message.md new file mode 100644 index 000000000..c9ea1fad9 --- /dev/null +++ b/uts/realtime/unit/channels/channel_get_message.md @@ -0,0 +1,16 @@ +# RealtimeChannel GetMessage Tests + +Spec points: `RTL28` + +## Test Type +Unit test with mocked HTTP client + +--- + +## RTL28 - RealtimeChannel#getMessage is identical to RestChannel#getMessage + +**Test ID**: `realtime/unit/RTL28/identical-to-rest-0` + +**Spec requirement:** `RealtimeChannel#getMessage` function: same as `RestChannel#getMessage`. + +`RealtimeChannel#getMessage` uses the same underlying REST endpoint as `RestChannel#getMessage`. The tests in `uts/test/rest/unit/channel/get_message.md` (covering RSL11) should be used to verify that all the same behaviour, parameters, and return types apply when called on a `RealtimeChannel` instance. diff --git a/uts/realtime/unit/channels/channel_history.md b/uts/realtime/unit/channels/channel_history.md new file mode 100644 index 000000000..7f8fcd4d2 --- /dev/null +++ b/uts/realtime/unit/channels/channel_history.md @@ -0,0 +1,127 @@ +# RealtimeChannel History Tests + +Spec points: `RTL10`, `RTL10a`, `RTL10b`, `RTL10c` + +## Test Type +Unit test with mocked HTTP client + +--- + +## RTL10a - RealtimeChannel#history supports all RestChannel#history params + +**Test ID**: `realtime/unit/RTL10a/supports-rest-params-0` + +| Spec | Requirement | +|------|-------------| +| RTL10a | Supports all the same params as `RestChannel#history` | +| RTL10c | Returns a `PaginatedResult` page containing the first page of messages | + +`RealtimeChannel#history` uses the same underlying REST endpoint as `RestChannel#history`. The tests in `uts/test/rest/unit/channel/history.md` (covering RSL2) should be used to verify that all the same behaviour, parameters, and return types apply when called on a `RealtimeChannel` instance. + +--- + +## RTL10b - untilAttach parameter + +**Spec requirement:** Additionally supports the param `untilAttach`, which if true, will only retrieve messages prior to the moment that the channel was attached or emitted an UPDATE indicating loss of continuity. This bound is specified by passing the querystring param `fromSerial` with the `RealtimeChannel#properties.attachSerial` assigned to the channel in the ATTACHED ProtocolMessage (see RTL15a). If the `untilAttach` param is specified when the channel is not attached, it results in an error. + +### RTL10b - untilAttach adds fromSerial query parameter + +**Test ID**: `realtime/unit/RTL10b/adds-from-serial-0` + +Tests that when `untilAttach` is true and the channel is attached, the history request includes a `fromSerial` query parameter set to the channel's `attachSerial`. + +#### Setup +```pseudo +channel_name = "test-RTL10b-${random_id()}" +captured_requests = [] +attach_serial = "serial-abc:0" + +mock_http = MockHttpClient( + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, []) + } +) + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + channelSerial: attach_serial + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws, + httpClient: mock_http +) + +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +#### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() +ASSERT channel.state == ATTACHED + +AWAIT channel.history(untilAttach: true) +``` + +#### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.url.query_params["fromSerial"] == attach_serial +CLOSE_CLIENT(client) +``` + +### RTL10b - untilAttach errors when not attached + +**Test ID**: `realtime/unit/RTL10b/errors-when-not-attached-1` + +Tests that when `untilAttach` is true and the channel is not attached, the history call results in an error. + +#### Setup +```pseudo +channel_name = "test-RTL10b-err-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) + +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +#### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED + +ASSERT channel.state == INITIALIZED + +error = null +TRY: + AWAIT channel.history(untilAttach: true) +CATCH e: + error = e +``` + +#### Assertions +```pseudo +ASSERT error IS AblyException +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/channels/channel_message_versions.md b/uts/realtime/unit/channels/channel_message_versions.md new file mode 100644 index 000000000..c812d5734 --- /dev/null +++ b/uts/realtime/unit/channels/channel_message_versions.md @@ -0,0 +1,16 @@ +# RealtimeChannel GetMessageVersions Tests + +Spec points: `RTL31` + +## Test Type +Unit test with mocked HTTP client + +--- + +## RTL31 - RealtimeChannel#getMessageVersions is identical to RestChannel#getMessageVersions + +**Test ID**: `realtime/unit/RTL31/identical-to-rest-0` + +**Spec requirement:** `RealtimeChannel#getMessageVersions` function: same as `RestChannel#getMessageVersions`. + +`RealtimeChannel#getMessageVersions` uses the same underlying REST endpoint as `RestChannel#getMessageVersions`. The tests in `uts/test/rest/unit/channel/message_versions.md` (covering RSL14) should be used to verify that all the same behaviour, parameters, and return types apply when called on a `RealtimeChannel` instance. diff --git a/uts/realtime/unit/channels/channel_options.md b/uts/realtime/unit/channels/channel_options.md new file mode 100644 index 000000000..bb7d1bbb6 --- /dev/null +++ b/uts/realtime/unit/channels/channel_options.md @@ -0,0 +1,514 @@ +# ChannelOptions and Derived Channels Tests + +Spec points: `TB2`, `TB3`, `TB4`, `RTS3b`, `RTS3c`, `RTS3c1`, `RTS5`, `RTL16` + +## Test Type +Unit test - no network calls required for most tests + +These tests verify channel options and derived channel functionality. + +--- + +## TB2 - ChannelOptions attributes + +**Test ID**: `realtime/unit/TB2/channel-options-attributes-0` + +| Spec | Requirement | +|------|-------------| +| TB2b | `cipher` - CipherParams for encryption | +| TB2c | `params` - Dict of channel parameters | +| TB2d | `modes` - Array of ChannelMode | +| TB4 | `attachOnSubscribe` - boolean, defaults to true | + +Tests that ChannelOptions has all required attributes with correct defaults. + +### Setup +```pseudo +options = RealtimeChannelOptions() +``` + +### Assertions +```pseudo +ASSERT options.cipherParams IS null +ASSERT options.params IS null +ASSERT options.modes IS null +ASSERT options.attachOnSubscribe == true +``` + +--- + +## TB2c - ChannelOptions with params + +**Test ID**: `realtime/unit/TB2c/options-with-params-0` + +**Spec requirement:** `params` is a Dict of key/value pairs for channel parameters. + +Tests that channel options can be created with params. + +### Setup +```pseudo +options = RealtimeChannelOptions( + params: {"rewind": "1", "delta": "vcdiff"} +) +``` + +### Assertions +```pseudo +ASSERT options.params["rewind"] == "1" +ASSERT options.params["delta"] == "vcdiff" +``` + +--- + +## TB2d - ChannelOptions with modes + +**Test ID**: `realtime/unit/TB2d/options-with-modes-0` + +**Spec requirement:** `modes` is an array of ChannelMode. + +Tests that channel options can be created with modes. + +### Setup +```pseudo +options = RealtimeChannelOptions( + modes: [ChannelMode.publish, ChannelMode.subscribe] +) +``` + +### Assertions +```pseudo +ASSERT options.modes CONTAINS ChannelMode.publish +ASSERT options.modes CONTAINS ChannelMode.subscribe +ASSERT length(options.modes) == 2 +``` + +--- + +## TB3 - withCipherKey constructor + +**Test ID**: `realtime/unit/TB3/with-cipher-key-0` + +**Spec requirement:** Optional constructor that takes a key only. + +Tests the withCipherKey factory constructor. + +### Setup +```pseudo +# 256-bit key as base64 +key = "MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE=" +options = RealtimeChannelOptions.withCipherKey(key) +``` + +### Assertions +```pseudo +ASSERT options.cipherParams IS NOT null +ASSERT options.cipherParams.algorithm == "aes" +ASSERT options.cipherParams.keyLength == 256 +``` + +--- + +## TB4 - attachOnSubscribe default + +**Test ID**: `realtime/unit/TB4/attach-on-subscribe-default-0` + +**Spec requirement:** `attachOnSubscribe` defaults to true. + +Tests the default value of attachOnSubscribe. + +### Setup +```pseudo +options1 = RealtimeChannelOptions() +options2 = RealtimeChannelOptions(attachOnSubscribe: false) +``` + +### Assertions +```pseudo +ASSERT options1.attachOnSubscribe == true +ASSERT options2.attachOnSubscribe == false +``` + +--- + +## RTS3b - Options set on new channel + +**Test ID**: `realtime/unit/RTS3b/options-set-on-new-0` + +**Spec requirement:** If options are provided, the options are set on the RealtimeChannel when creating a new RealtimeChannel. + +Tests that get() with options sets them on new channels. + +### Setup +```pseudo +channel_name = "test-RTS3b-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) + +channelOptions = RealtimeChannelOptions( + params: {"rewind": "1"}, + modes: [ChannelMode.subscribe] +) +``` + +### Test Steps +```pseudo +channel = client.channels.get(channel_name, channelOptions) +``` + +### Assertions +```pseudo +ASSERT channel.options.params["rewind"] == "1" +ASSERT channel.options.modes CONTAINS ChannelMode.subscribe +CLOSE_CLIENT(client) +``` + +--- + +## RTS3c - Options updated on existing channel (soft-deprecated) + +**Test ID**: `realtime/unit/RTS3c/options-updated-existing-0` + +**Spec requirement:** Accessing an existing channel with options will update the options. + +Tests that get() with options updates existing channel (when no reattachment needed). + +### Setup +```pseudo +channel_name = "test-RTS3c-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) + +# Create channel with initial options +initialOptions = RealtimeChannelOptions(attachOnSubscribe: false) +channel = client.channels.get(channel_name, initialOptions) +``` + +### Test Steps +```pseudo +# Update with new options that don't require reattachment +newOptions = RealtimeChannelOptions( + cipherParams: CipherParams.fromKey(someKey), + attachOnSubscribe: true +) +sameChannel = client.channels.get(channel_name, newOptions) +``` + +### Assertions +```pseudo +ASSERT sameChannel IS SAME AS channel +ASSERT channel.options.cipherParams IS NOT null +ASSERT channel.options.attachOnSubscribe == true +CLOSE_CLIENT(client) +``` + +--- + +## RTS3c1 - Error if options would trigger reattachment + +**Test ID**: `realtime/unit/RTS3c1/error-reattach-params-0` + +**Spec requirement:** If a new set of ChannelOptions is supplied that would trigger a reattachment, it must raise an error. + +Tests that get() throws error when params/modes change on attached channel. + +### Setup +```pseudo +channel_name = "test-RTS3c1-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) + +# Create and attach channel +channel = client.channels.get(channel_name) +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached +``` + +### Test Steps +```pseudo +# Try to update with options that require reattachment +newOptions = RealtimeChannelOptions( + params: {"rewind": "1"} # params triggers reattachment +) + +client.channels.get(channel_name, newOptions) FAILS WITH error +ASSERT error.code == 40000 + +# Channel options should not have changed +ASSERT channel.options.params IS null +CLOSE_CLIENT(client) +``` + +--- + +## RTS3c1 - Error if modes change on attaching channel + +**Test ID**: `realtime/unit/RTS3c1/error-reattach-modes-1` + +**Spec requirement:** Must raise error if options would trigger reattachment on attaching channel. + +Tests error when modes change on attaching channel. + +### Setup +```pseudo +channel_name = "test-RTS3c1-attaching-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) + +channel = client.channels.get(channel_name) +# Put channel in attaching state (implementation detail) +``` + +### Test Steps +```pseudo +newOptions = RealtimeChannelOptions( + modes: [ChannelMode.subscribe] # modes triggers reattachment +) + +client.channels.get(channel_name, newOptions) FAILS WITH error +ASSERT error.code == 40000 +CLOSE_CLIENT(client) +``` + +--- + +## RTL16 - setOptions updates channel options + +**Test ID**: `realtime/unit/RTL16/set-options-updates-0` + +**Spec requirement:** setOptions takes a ChannelOptions object and sets or updates the stored channel options. + +Tests that setOptions updates the channel options. + +### Setup +```pseudo +channel_name = "test-RTL16-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +newOptions = RealtimeChannelOptions( + params: {"delta": "vcdiff"}, + attachOnSubscribe: false +) +AWAIT channel.setOptions(newOptions) +``` + +### Assertions +```pseudo +ASSERT channel.options.params["delta"] == "vcdiff" +ASSERT channel.options.attachOnSubscribe == false +CLOSE_CLIENT(client) +``` + +--- + +## RTL16a - setOptions triggers reattachment when needed + +**Test ID**: `realtime/unit/RTL16a/triggers-reattach-0` + +**Spec requirement:** If params or modes are provided and channel is attached, setOptions triggers reattachment. + +Tests that setOptions with params/modes on attached channel triggers reattachment. + +### Setup +```pseudo +channel_name = "test-RTL16a-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached +``` + +### Test Steps +```pseudo +stateChanges = [] +subscription = channel.on().listen((change) => stateChanges.append(change)) + +newOptions = RealtimeChannelOptions( + params: {"rewind": "1"} +) +AWAIT channel.setOptions(newOptions) +``` + +### Assertions +```pseudo +# Should have gone through attaching state +ASSERT stateChanges CONTAINS change WHERE change.current == ChannelState.attaching +ASSERT channel.state == ChannelState.attached +ASSERT channel.options.params["rewind"] == "1" +CLOSE_CLIENT(client) +``` + +--- + +## RTS5a - getDerived creates derived channel + +**Test ID**: `realtime/unit/RTS5a/creates-derived-channel-0` + +**Spec requirement:** Takes RealtimeChannel name and DeriveOptions to create a derived channel. + +Tests that getDerived creates a channel with the correct derived name. + +### Setup +```pseudo +base_channel_name = "test-RTS5a-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) + +deriveOptions = DeriveOptions(filter: "name == 'foo'") +``` + +### Test Steps +```pseudo +channel = client.channels.getDerived(base_channel_name, deriveOptions) +``` + +### Assertions +```pseudo +# Channel name should be encoded with filter +ASSERT channel.name STARTS WITH "[filter=" +ASSERT channel.name ENDS WITH "]" + base_channel_name +CLOSE_CLIENT(client) +``` + +--- + +## RTS5a1 - Derived channel filter is base64 encoded + +**Test ID**: `realtime/unit/RTS5a1/filter-base64-encoded-0` + +**Spec requirement:** The filter should be synthesized as [filter=]channelName. + +Tests that the filter expression is base64 encoded in the channel name. + +### Setup +```pseudo +base_channel_name = "test-RTS5a1-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) + +filter = "name == 'test'" +deriveOptions = DeriveOptions(filter: filter) +``` + +### Test Steps +```pseudo +channel = client.channels.getDerived(base_channel_name, deriveOptions) +expectedEncoded = base64_encode(filter) # "bmFtZSA9PSAndGVzdCc=" +``` + +### Assertions +```pseudo +ASSERT channel.name == "[filter=" + expectedEncoded + "]" + base_channel_name +CLOSE_CLIENT(client) +``` + +--- + +## RTS5a2 - Derived channel with params + +**Test ID**: `realtime/unit/RTS5a2/derived-with-params-0` + +**Spec requirement:** If channel options are provided with params, they are included in the derived channel name. + +Tests that channel params are included in the derived channel name. + +### Setup +```pseudo +base_channel_name = "test-RTS5a2-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) + +deriveOptions = DeriveOptions(filter: "type == 'message'") +channelOptions = RealtimeChannelOptions( + params: {"rewind": "1", "delta": "vcdiff"} +) +``` + +### Test Steps +```pseudo +channel = client.channels.getDerived(base_channel_name, deriveOptions, channelOptions) +``` + +### Assertions +```pseudo +# Parse the channel name to extract the qualifier and base name +# Expected format: [filter=?param1=val1¶m2=val2]baseName +ASSERT channel.name ENDS WITH "]" + base_channel_name + +# Extract the qualifier (everything between [ and ]) +qualifier = extract_between(channel.name, "[", "]") + +# Verify filter is present +ASSERT qualifier STARTS WITH "filter=" + +# Extract and parse params from qualifier (after the ?) +IF qualifier CONTAINS "?": + paramsString = qualifier.split("?")[1] + parsedParams = parse_query_string(paramsString) + ASSERT parsedParams["rewind"] == "1" + ASSERT parsedParams["delta"] == "vcdiff" + ASSERT length(parsedParams) == 2 +ELSE: + FAIL("Expected params in qualifier") +CLOSE_CLIENT(client) +``` + +--- + +## RTS5 - getDerived with options sets them on channel + +**Test ID**: `realtime/unit/RTS5/get-derived-with-options-0` + +**Spec requirement:** ChannelOptions can be provided as an optional third argument. + +Tests that getDerived passes options to the created channel. + +### Setup +```pseudo +base_channel_name = "test-RTS5-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) + +deriveOptions = DeriveOptions(filter: "true") +channelOptions = RealtimeChannelOptions( + modes: [ChannelMode.subscribe], + attachOnSubscribe: false +) +``` + +### Test Steps +```pseudo +channel = client.channels.getDerived(base_channel_name, deriveOptions, channelOptions) +``` + +### Assertions +```pseudo +ASSERT channel.options.modes CONTAINS ChannelMode.subscribe +ASSERT channel.options.attachOnSubscribe == false +CLOSE_CLIENT(client) +``` + +--- + +## DO2a - DeriveOptions filter attribute + +**Test ID**: `realtime/unit/DO2a/filter-attribute-0` + +**Spec requirement:** DeriveOptions has a filter attribute containing a JMESPath string expression. + +Tests the DeriveOptions class. + +### Setup +```pseudo +deriveOptions = DeriveOptions(filter: "name == 'event' && data.count > 10") +``` + +### Assertions +```pseudo +ASSERT deriveOptions.filter == "name == 'event' && data.count > 10" +``` diff --git a/uts/realtime/unit/channels/channel_properties.md b/uts/realtime/unit/channels/channel_properties.md new file mode 100644 index 000000000..2a85a7437 --- /dev/null +++ b/uts/realtime/unit/channels/channel_properties.md @@ -0,0 +1,577 @@ +# Channel Properties Tests + +Spec points: `RTL15`, `RTL15a`, `RTL15b`, `RTL15b1` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL15a - attachSerial is updated from ATTACHED message + +**Test ID**: `realtime/unit/RTL15a/attach-serial-from-attached-0` + +| Spec | Requirement | +|------|-------------| +| RTL15 | `RealtimeChannel#properties` is a `ChannelProperties` object with `attachSerial` and `channelSerial` | +| RTL15a | `attachSerial` is unset when instantiated, and updated with the `channelSerial` from each ATTACHED ProtocolMessage received | + +Tests that the channel's `attachSerial` property is initially unset, is set from the `channelSerial` field of the ATTACHED response, and is updated on subsequent ATTACHED messages (e.g. after reattach). + +### Setup +```pseudo +channel_name = "test-RTL15a-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "attach-serial-${attach_count}" + )) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Before connecting, attachSerial should be unset +ASSERT channel.properties.attachSerial IS null + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach +AWAIT channel.attach() +``` + +### Assertions +```pseudo +# attachSerial set from ATTACHED response +ASSERT channel.properties.attachSerial == "attach-serial-1" + +# Detach and reattach to get a new attachSerial +AWAIT channel.detach() +AWAIT channel.attach() + +# attachSerial updated from second ATTACHED response +ASSERT channel.properties.attachSerial == "attach-serial-2" +CLOSE_CLIENT(client) +``` + +--- + +## RTL15a - attachSerial updated on server-initiated reattach + +**Test ID**: `realtime/unit/RTL15a/attach-serial-server-reattach-1` + +**Spec requirement:** `attachSerial` is updated with the `channelSerial` from each ATTACHED ProtocolMessage received. + +Tests that when the server sends an unsolicited ATTACHED message (e.g. RTL2g update), the `attachSerial` is updated. + +### Setup +```pseudo +channel_name = "test-RTL15a-update-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "initial-serial" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.properties.attachSerial == "initial-serial" + +# Server sends unsolicited ATTACHED (e.g. RTL2g update) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + channelSerial: "updated-serial" +)) +AWAIT_STATE channel.properties.attachSerial == "updated-serial" +``` + +### Assertions +```pseudo +ASSERT channel.properties.attachSerial == "updated-serial" +CLOSE_CLIENT(client) +``` + +--- + +## RTL15b - channelSerial updated from ATTACHED message + +**Test ID**: `realtime/unit/RTL15b/channel-serial-from-attached-0` + +| Spec | Requirement | +|------|-------------| +| RTL15b | `channelSerial` is updated whenever a ProtocolMessage with MESSAGE, PRESENCE, ANNOTATION, OBJECT, or ATTACHED action is received, set to the `channelSerial` of that message, if and only if that field is populated | + +Tests that `channelSerial` is set from the ATTACHED response's `channelSerial` field. + +### Setup +```pseudo +channel_name = "test-RTL15b-attached-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "serial-001" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Before attach, channelSerial should be unset +ASSERT channel.properties.channelSerial IS null + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT channel.properties.channelSerial == "serial-001" +CLOSE_CLIENT(client) +``` + +--- + +## RTL15b - channelSerial updated from MESSAGE and PRESENCE actions + +**Test ID**: `realtime/unit/RTL15b/channel-serial-from-messages-1` + +**Spec requirement:** `channelSerial` is updated whenever a ProtocolMessage with MESSAGE, PRESENCE, ANNOTATION, OBJECT, or ATTACHED action is received. + +Tests that receiving MESSAGE and PRESENCE protocol messages with a `channelSerial` field updates the channel's `channelSerial` property. + +# Implementation note: Some SDKs auto-attach on subscribe. If using explicit +# attach() in tests, set attachOnSubscribe: false in channel options to prevent +# implicit attach from interfering with the test flow. + +### Setup +```pseudo +channel_name = "test-RTL15b-messages-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "serial-001" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.properties.channelSerial == "serial-001" + +# Server sends MESSAGE with channelSerial +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + channelSerial: "serial-002", + messages: [ + Message(name: "event", data: "data") + ] +)) +AWAIT_STATE channel.properties.channelSerial == "serial-002" + +# Server sends PRESENCE with channelSerial +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + channelSerial: "serial-003" +)) +AWAIT_STATE channel.properties.channelSerial == "serial-003" +``` + +### Assertions +```pseudo +ASSERT channel.properties.channelSerial == "serial-003" +CLOSE_CLIENT(client) +``` + +--- + +## RTL15b - channelSerial not updated when field is not populated + +**Test ID**: `realtime/unit/RTL15b/serial-not-updated-empty-2` + +**Spec requirement:** `channelSerial` is set to the channelSerial of the ProtocolMessage, if and only if that field is populated. + +Tests that receiving a protocol message without a `channelSerial` field does not clear or change the channel's existing `channelSerial`. + +### Setup +```pseudo +channel_name = "test-RTL15b-noupdate-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "serial-001" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.properties.channelSerial == "serial-001" + +# Server sends MESSAGE without channelSerial +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "event", data: "data") + ] +)) +``` + +### Assertions +```pseudo +# channelSerial should remain unchanged +ASSERT channel.properties.channelSerial == "serial-001" +CLOSE_CLIENT(client) +``` + +--- + +## RTL15b - channelSerial not updated from irrelevant actions + +**Test ID**: `realtime/unit/RTL15b/serial-not-updated-irrelevant-3` + +**Spec requirement:** `channelSerial` is updated only for MESSAGE, PRESENCE, ANNOTATION, OBJECT, or ATTACHED actions. + +Tests that receiving a protocol message with a different action (e.g. ERROR, DETACHED) does not update `channelSerial`, even if the message happens to contain a `channelSerial` field. + +### Setup +```pseudo +channel_name = "test-RTL15b-irrelevant-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "serial-001" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.properties.channelSerial == "serial-001" + +# Server sends DETACHED with a channelSerial field +# (RTL13a will trigger reattach, but the DETACHED itself should not update channelSerial) +# Record channelSerial before the DETACHED +serial_before = channel.properties.channelSerial + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + channelSerial: "serial-should-not-apply", + error: ErrorInfo(code: 90198, statusCode: 500, message: "Detached") +)) + +# Wait for the reattach to complete (RTL13a) +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +# channelSerial should be from the new ATTACHED, not from DETACHED +# The DETACHED action should not have updated channelSerial +# (RTL15b1 clears it on DETACHED/SUSPENDED/FAILED, then ATTACHED sets it fresh) +ASSERT attach_count == 2 +ASSERT channel.properties.channelSerial == "serial-001" +CLOSE_CLIENT(client) +``` + +--- + +## RTL15b1 - channelSerial cleared on DETACHED state + +**Test ID**: `realtime/unit/RTL15b1/serial-cleared-detached-0` + +| Spec | Requirement | +|------|-------------| +| RTL15b1 | If the channel enters the DETACHED, SUSPENDED, or FAILED state, it must clear its channelSerial | + +Tests that `channelSerial` is cleared when the channel transitions to DETACHED. + +### Setup +```pseudo +channel_name = "test-RTL15b1-detached-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "serial-001" + )) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.properties.channelSerial == "serial-001" + +AWAIT channel.detach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached +ASSERT channel.properties.channelSerial IS null +CLOSE_CLIENT(client) +``` + +--- + +## RTL15b1 - channelSerial cleared on SUSPENDED state + +**Test ID**: `realtime/unit/RTL15b1/serial-cleared-suspended-1` + +**Spec requirement:** If the channel enters the SUSPENDED state, it must clear its `channelSerial`. + +Tests that `channelSerial` is cleared when the channel transitions to SUSPENDED (e.g. due to attach timeout). + +### Setup +```pseudo +channel_name = "test-RTL15b1-suspended-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "serial-001" + )) + # Don't respond to second attach (causes timeout -> SUSPENDED) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 100 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.properties.channelSerial == "serial-001" + +# Trigger server-initiated DETACHED -> reattach attempt that will timeout +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90198, statusCode: 500, message: "Detached") +)) +AWAIT_STATE channel.state == ChannelState.attaching + +# Let attach timeout -> SUSPENDED +ADVANCE_TIME(150) +AWAIT_STATE channel.state == ChannelState.suspended +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.suspended +ASSERT channel.properties.channelSerial IS null +CLOSE_CLIENT(client) +``` + +--- + +## RTL15b1 - channelSerial cleared on FAILED state + +**Test ID**: `realtime/unit/RTL15b1/serial-cleared-failed-2` + +**Spec requirement:** If the channel enters the FAILED state, it must clear its `channelSerial`. + +Tests that `channelSerial` is cleared when the channel transitions to FAILED (e.g. due to channel ERROR). + +### Setup +```pseudo +channel_name = "test-RTL15b1-failed-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "serial-001" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.properties.channelSerial == "serial-001" + +# Server sends channel ERROR -> FAILED +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 40160, statusCode: 401, message: "Not permitted") +)) +AWAIT_STATE channel.state == ChannelState.failed +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.failed +ASSERT channel.properties.channelSerial IS null +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/channels/channel_publish.md b/uts/realtime/unit/channels/channel_publish.md new file mode 100644 index 000000000..cd8fa326d --- /dev/null +++ b/uts/realtime/unit/channels/channel_publish.md @@ -0,0 +1,2265 @@ +# RealtimeChannel Publish Tests + +Spec points: `RTL6`, `RTL6a`, `RTL6c`, `RTL6c1`, `RTL6c2`, `RTL6c4`, `RTL6c5`, `RTL6i`, `RTL6i1`, `RTL6i2`, `RTL6i3`, `RTL6j`, `RTN7d`, `RTN7e`, `RTN19a`, `RTN19a2`, `RTN19b` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL6i1 - Publish single message by name and data + +**Test ID**: `realtime/unit/RTL6i1/publish-name-and-data-0` + +**Spec requirement:** When `name` and `data` (or a `Message`) is provided, a single `ProtocolMessage` containing one `Message` is published to Ably. + +Tests that publishing with name and data sends a single MESSAGE ProtocolMessage with one message entry. + +### Setup +```pseudo +channel_name = "test-RTL6i1-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +channel.publish(name: "greeting", data: "hello") +``` + +### Assertions +```pseudo +ASSERT length(captured_messages) == 1 +ASSERT captured_messages[0].action == MESSAGE +ASSERT captured_messages[0].channel == channel_name +ASSERT length(captured_messages[0].messages) == 1 +ASSERT captured_messages[0].messages[0].name == "greeting" +ASSERT captured_messages[0].messages[0].data == "hello" +CLOSE_CLIENT(client) +``` + +--- + +## RTL6i2 - Publish array of Message objects + +**Test ID**: `realtime/unit/RTL6i2/publish-message-array-0` + +**Spec requirement:** When an array of `Message` objects is provided, a single `ProtocolMessage` is used to publish all `Message` objects in the array. + +Tests that publishing an array of messages sends them in a single ProtocolMessage. + +### Setup +```pseudo +channel_name = "test-RTL6i2-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +channel.publish(messages: [ + Message(name: "event1", data: "data1"), + Message(name: "event2", data: "data2"), + Message(name: "event3", data: "data3") +]) +``` + +### Assertions +```pseudo +ASSERT length(captured_messages) == 1 # Single ProtocolMessage +ASSERT length(captured_messages[0].messages) == 3 +ASSERT captured_messages[0].messages[0].name == "event1" +ASSERT captured_messages[0].messages[1].name == "event2" +ASSERT captured_messages[0].messages[2].name == "event3" +CLOSE_CLIENT(client) +``` + +--- + +## RTL6i3 - Null fields omitted from JSON wire encoding + +**Test ID**: `realtime/unit/RTL6i3/null-fields-json-0` + +**Spec requirement:** Allows `name` and or `data` to be `null`. If any of the values are `null`, then key is not sent to Ably i.e. a payload with a `null` value for `data` would be sent as follows `{ "name": "click" }`. + +Tests that when using the JSON protocol, null `name` or `data` fields are omitted from the encoded JSON representation on the wire (not sent as `"name": null`). + +### Setup +```pseudo +channel_name = "test-RTL6i3-json-${random_id()}" +captured_frames = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + }, + onTextDataFrame: (text) => { + decoded = JSON_DECODE(text) + IF decoded["action"] == MESSAGE_ACTION_INT: + captured_frames.append(decoded) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + useBinaryProtocol: false +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish with name only (null data) +channel.publish(name: "click", data: null) + +# Publish with data only (null name) +channel.publish(name: null, data: "payload") + +# Publish with both null +channel.publish(name: null, data: null) +``` + +### Assertions +```pseudo +ASSERT length(captured_frames) == 3 + +# First message: name present, data key absent +msg0 = captured_frames[0]["messages"][0] +ASSERT msg0["name"] == "click" +ASSERT "data" NOT IN msg0 + +# Second message: data present, name key absent +msg1 = captured_frames[1]["messages"][0] +ASSERT "name" NOT IN msg1 +ASSERT msg1["data"] == "payload" + +# Third message: both keys absent +msg2 = captured_frames[2]["messages"][0] +ASSERT "name" NOT IN msg2 +ASSERT "data" NOT IN msg2 +CLOSE_CLIENT(client) +``` + +--- + +## RTL6i3 - Null fields omitted from msgpack wire encoding + +**Test ID**: `realtime/unit/RTL6i3/null-fields-msgpack-1` + +**Spec requirement:** Allows `name` and or `data` to be `null`. If any of the values are `null`, then key is not sent to Ably. + +Tests that when using the msgpack protocol, null `name` or `data` fields are omitted from the encoded msgpack representation on the wire. + +### Setup +```pseudo +channel_name = "test-RTL6i3-msgpack-${random_id()}" +captured_frames = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + }, + onBinaryDataFrame: (bytes) => { + decoded = MSGPACK_DECODE(bytes) + IF decoded["action"] == MESSAGE_ACTION_INT: + captured_frames.append(decoded) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + useBinaryProtocol: true +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish with name only (null data) +channel.publish(name: "click", data: null) + +# Publish with data only (null name) +channel.publish(name: null, data: "payload") + +# Publish with both null +channel.publish(name: null, data: null) +``` + +### Assertions +```pseudo +ASSERT length(captured_frames) == 3 + +# First message: name present, data key absent +msg0 = captured_frames[0]["messages"][0] +ASSERT msg0["name"] == "click" +ASSERT "data" NOT IN msg0 + +# Second message: data present, name key absent +msg1 = captured_frames[1]["messages"][0] +ASSERT "name" NOT IN msg1 +ASSERT msg1["data"] == "payload" + +# Third message: both keys absent +msg2 = captured_frames[2]["messages"][0] +ASSERT "name" NOT IN msg2 +ASSERT "data" NOT IN msg2 +CLOSE_CLIENT(client) +``` + +--- + +## RTL6c1 - Publish immediately when CONNECTED and channel ATTACHED + +**Test ID**: `realtime/unit/RTL6c1/publish-when-attached-0` + +| Spec | Requirement | +|------|-------------| +| RTL6c1 | If the connection is `CONNECTED` and the channel is neither `SUSPENDED` nor `FAILED` then the messages are published immediately | + +Tests that messages are sent immediately to the server when the connection is CONNECTED and the channel is ATTACHED. + +### Setup +```pseudo +channel_name = "test-RTL6c1-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +ASSERT client.connection.state == ConnectionState.connected +ASSERT channel.state == ChannelState.attached + +channel.publish(name: "test", data: "immediate") +``` + +### Assertions +```pseudo +# Message should have been sent immediately (synchronously captured by mock) +ASSERT length(captured_messages) == 1 +ASSERT captured_messages[0].messages[0].name == "test" +ASSERT captured_messages[0].messages[0].data == "immediate" +CLOSE_CLIENT(client) +``` + +--- + +## RTL6c1 - Publish immediately when CONNECTED and channel ATTACHING + +**Test ID**: `realtime/unit/RTL6c1/publish-when-attaching-1` + +**Spec requirement:** If the connection is `CONNECTED` and the channel is neither `SUSPENDED` nor `FAILED` then the messages are published immediately. + +Tests that messages are sent immediately even when the channel is in the ATTACHING state (which is neither SUSPENDED nor FAILED). + +### Setup +```pseudo +channel_name = "test-RTL6c1-attaching-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Don't respond — leave channel in ATTACHING + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach but don't complete it — channel stays ATTACHING +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +channel.publish(name: "while-attaching", data: "data") +``` + +### Assertions +```pseudo +# Message should have been sent immediately (ATTACHING is neither SUSPENDED nor FAILED) +ASSERT length(captured_messages) == 1 +ASSERT captured_messages[0].messages[0].name == "while-attaching" +CLOSE_CLIENT(client) +``` + +--- + +## RTL6c1 - Publish immediately when CONNECTED and channel INITIALIZED + +**Test ID**: `realtime/unit/RTL6c1/publish-when-initialized-2` + +**Spec requirement:** If the connection is `CONNECTED` and the channel is neither `SUSPENDED` nor `FAILED` then the messages are published immediately. + +Tests that messages are sent immediately when the channel is in the INITIALIZED state (which is neither SUSPENDED nor FAILED). + +### Setup +```pseudo +channel_name = "test-RTL6c1-init-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT channel.state == ChannelState.initialized + +channel.publish(name: "before-attach", data: "data") +``` + +### Assertions +```pseudo +# Message should have been sent immediately +ASSERT length(captured_messages) == 1 +ASSERT captured_messages[0].messages[0].name == "before-attach" +CLOSE_CLIENT(client) +``` + +--- + +## RTL6c2 - Publish queued when connection is CONNECTING + +**Test ID**: `realtime/unit/RTL6c2/queued-when-connecting-0` + +| Spec | Requirement | +|------|-------------| +| RTL6c2 | If the connection is `INITIALIZED`, `CONNECTING` or `DISCONNECTED`; and the channel is neither `SUSPENDED` nor `FAILED`; and `ClientOptions#queueMessages` is `true`; then the message will be placed in a connection-wide message queue | + +Tests that messages published while the connection is CONNECTING are queued and sent once the connection becomes CONNECTED. + +### Setup +```pseudo +channel_name = "test-RTL6c2-connecting-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Don't respond yet — leave connection in CONNECTING + }, + onMessageFromClient: (msg) => { + IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Publish while CONNECTING — should be queued +channel.publish(name: "queued", data: "waiting") + +# Message should NOT have been sent yet +ASSERT length(captured_messages) == 0 + +# Complete the connection +pending_conn = AWAIT mock_ws.await_connection_attempt() +pending_conn.respond_with_success(CONNECTED_MESSAGE) +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions +```pseudo +# Queued message should now have been sent +ASSERT length(captured_messages) == 1 +ASSERT captured_messages[0].messages[0].name == "queued" +ASSERT captured_messages[0].messages[0].data == "waiting" +CLOSE_CLIENT(client) +``` + +--- + +## RTL6c2 - Publish queued when connection is DISCONNECTED + +**Test ID**: `realtime/unit/RTL6c2/queued-when-disconnected-1` + +**Spec requirement:** Messages are queued when connection is `DISCONNECTED` and `queueMessages` is true. + +Tests that messages published while the connection is DISCONNECTED are queued and sent once the connection reconnects. + +### Setup +```pseudo +channel_name = "test-RTL6c2-disconnected-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Simulate disconnect +mock_ws.active_connection.simulate_disconnect() + +# Record state changes to verify DISCONNECTED was reached +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Publish while DISCONNECTED — should be queued +channel.publish(name: "during-disconnect", data: "queued") + +# Message should NOT have been sent yet (no active connection) +message_count_before = length(captured_messages) +``` + +### Assertions +```pseudo +# After reconnection, the queued message should be sent +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT length(captured_messages) > message_count_before +# Find the queued message in captured messages +queued = filter(captured_messages, (m) => m.messages[0].name == "during-disconnect") +ASSERT length(queued) == 1 +CLOSE_CLIENT(client) +``` + +--- + +## RTL6c2 - Publish queued when connection is INITIALIZED + +**Test ID**: `realtime/unit/RTL6c2/queued-when-initialized-2` + +**Spec requirement:** Messages are queued when connection is `INITIALIZED` and `queueMessages` is true. + +Tests that messages published before `connect()` is called are queued and sent once the connection becomes CONNECTED. + +### Setup +```pseudo +channel_name = "test-RTL6c2-init-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +ASSERT client.connection.state == ConnectionState.initialized + +# Publish before connecting — should be queued +channel.publish(name: "pre-connect", data: "early") + +# Message should NOT have been sent +ASSERT length(captured_messages) == 0 + +# Now connect +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions +```pseudo +# Queued message should now have been sent +ASSERT length(captured_messages) == 1 +ASSERT captured_messages[0].messages[0].name == "pre-connect" +CLOSE_CLIENT(client) +``` + +--- + +## RTL6c4 - Publish fails when connection is SUSPENDED + +**Test ID**: `realtime/unit/RTL6c4/fails-conn-suspended-0` + +**Spec requirement:** In any other case the operation should result in an error. + +Tests that publishing fails immediately when the connection is SUSPENDED. + +### Setup +```pseudo +channel_name = "test-RTL6c4-suspended-${random_id()}" + +enable_fake_timers() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_refused() +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + disconnectedRetryTimeout: 1000, + connectionStateTtl: 5000 +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() + +# Advance time until connection enters SUSPENDED +LOOP up to 15 times: + ADVANCE_TIME(2000) + IF client.connection.state == ConnectionState.suspended: + BREAK + +AWAIT_STATE client.connection.state == ConnectionState.suspended + +# Publish should fail +channel.publish(name: "fail", data: "should-error") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RTL6c4 - Publish fails when connection is CLOSED + +**Test ID**: `realtime/unit/RTL6c4/fails-conn-closed-1` + +**Spec requirement:** In any other case the operation should result in an error. + +Tests that publishing fails when the connection is CLOSED. + +### Setup +```pseudo +channel_name = "test-RTL6c4-closed-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT client.close() +ASSERT client.connection.state == ConnectionState.closed + +channel.publish(name: "fail", data: "should-error") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RTL6c4 - Publish fails when connection is FAILED + +**Test ID**: `realtime/unit/RTL6c4/fails-conn-failed-2` + +**Spec requirement:** In any other case the operation should result in an error. + +Tests that publishing fails when the connection is FAILED. + +### Setup +```pseudo +channel_name = "test-RTL6c4-failed-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_error( + ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: 80000, message: "Fatal error") + ), + thenClose: true + ) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.failed + +channel.publish(name: "fail", data: "should-error") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RTL6c4 - Publish fails when channel is SUSPENDED + +**Test ID**: `realtime/unit/RTL6c4/fails-channel-suspended-3` + +**Spec requirement:** If the channel is SUSPENDED, publish results in an error regardless of connection state. + +Tests that publishing fails when the channel is in SUSPENDED state even though the connection is CONNECTED. + +### Setup +```pseudo +channel_name = "test-RTL6c4-ch-suspended-${random_id()}" +captured_messages = [] + +enable_fake_timers() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Don't respond on second attach — will timeout to SUSPENDED + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 100 +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach — will timeout and channel enters SUSPENDED +attach_future = channel.attach() +ADVANCE_TIME(150) +AWAIT attach_future FAILS WITH attach_error + +AWAIT_STATE channel.state == ChannelState.suspended + +# Publish should fail because channel is SUSPENDED +channel.publish(name: "fail", data: "should-error") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT length(captured_messages) == 0 # No MESSAGE sent to server +CLOSE_CLIENT(client) +``` + +--- + +## RTL6c4 - Publish fails when channel is FAILED + +**Test ID**: `realtime/unit/RTL6c4/fails-channel-failed-4` + +**Spec requirement:** Publishing to a FAILED channel results in an error (RTL6c3/RTL6c4). + +Tests that publishing fails when the channel is in FAILED state. + +### Setup +```pseudo +channel_name = "test-RTL6c4-ch-failed-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 40160, statusCode: 401, message: "Not permitted") + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach fails → channel enters FAILED +AWAIT channel.attach() FAILS WITH attach_error +ASSERT channel.state == ChannelState.failed + +# Publish should fail because channel is FAILED +channel.publish(name: "fail", data: "should-error") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT length(captured_messages) == 0 # No MESSAGE sent to server +CLOSE_CLIENT(client) +``` + +--- + +## RTL6c2 - Publish fails when queueMessages is false and connection not CONNECTED + +**Test ID**: `realtime/unit/RTL6c2/fails-no-queue-messages-3` + +**Spec requirement:** Messages are queued only when `queueMessages` is true. When false and connection is not CONNECTED, publish should fail. + +Tests that publishing fails immediately when queueMessages is false and the connection is not CONNECTED. + +### Setup +```pseudo +channel_name = "test-RTL6c2-noqueue-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Don't respond — leave connection in CONNECTING + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + queueMessages: false +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connecting + +channel.publish(name: "fail", data: "should-error") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RTL6c5 - Publish does not trigger implicit attach + +**Test ID**: `realtime/unit/RTL6c5/no-implicit-attach-0` + +**Spec requirement:** A publish should not trigger an implicit attach (in contrast to earlier version of this spec). + +Tests that publishing on an INITIALIZED channel does not cause the channel to begin attaching. + +### Setup +```pseudo +channel_name = "test-RTL6c5-${random_id()}" +attach_message_count = 0 +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_message_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT channel.state == ChannelState.initialized + +channel.publish(name: "no-attach", data: "test") +``` + +### Assertions +```pseudo +# Publish should have been sent (RTL6c1 — CONNECTED, channel not SUSPENDED/FAILED) +ASSERT length(captured_messages) == 1 + +# Channel should remain INITIALIZED — no implicit attach +ASSERT channel.state == ChannelState.initialized +ASSERT attach_message_count == 0 +CLOSE_CLIENT(client) +``` + +--- + +## RTL6c2 - Multiple queued messages sent in order after connection + +**Test ID**: `realtime/unit/RTL6c2/queued-messages-order-4` + +**Spec requirement:** Messages queued while not connected are delivered once the connection becomes CONNECTED. + +Tests that multiple messages queued before connection are all sent in the correct order once connected. + +### Setup +```pseudo +channel_name = "test-RTL6c2-order-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Don't respond yet — leave in CONNECTING + }, + onMessageFromClient: (msg) => { + IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Queue multiple messages +channel.publish(name: "first", data: "1") +channel.publish(name: "second", data: "2") +channel.publish(name: "third", data: "3") + +ASSERT length(captured_messages) == 0 + +# Complete the connection +pending_conn = AWAIT mock_ws.await_connection_attempt() +pending_conn.respond_with_success(CONNECTED_MESSAGE) +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions +```pseudo +# All messages should have been sent in order +ASSERT length(captured_messages) == 3 +ASSERT captured_messages[0].messages[0].name == "first" +ASSERT captured_messages[1].messages[0].name == "second" +ASSERT captured_messages[2].messages[0].name == "third" +CLOSE_CLIENT(client) +``` + +--- + +## RTL6i1 - Publish Message object + +**Test ID**: `realtime/unit/RTL6i1/publish-message-object-1` + +**Spec requirement:** When a `Message` is provided, a single `ProtocolMessage` containing one `Message` is published to Ably. + +Tests that publishing a Message object directly sends it correctly. + +### Setup +```pseudo +channel_name = "test-RTL6i1-obj-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +channel.publish(message: Message(name: "custom", data: {"key": "value"})) +``` + +### Assertions +```pseudo +ASSERT length(captured_messages) == 1 +ASSERT length(captured_messages[0].messages) == 1 +ASSERT captured_messages[0].messages[0].name == "custom" +ASSERT captured_messages[0].messages[0].data == {"key": "value"} +CLOSE_CLIENT(client) +``` + +--- + +## RTL6j - Publish returns PublishResult with serials from ACK + +**Test ID**: `realtime/unit/RTL6j/publish-result-serials-0` + +| Spec | Requirement | +|------|-------------| +| RTL6j | On success, returns a `PublishResult` object containing the serials of the published messages. The serials are obtained from the `ACK` `ProtocolMessage` response (see TR4s). | +| PBR1 | Contains the result of a publish operation | +| PBR2a | `serials` array of `String?` — an array of message serials corresponding 1:1 to the messages that were published | +| TR4s | `res` Array of `PublishResult` objects — present in `ACK` `ProtocolMessages`, contains one `PublishResult` per acknowledged `ProtocolMessage` in order | +| TR4g | `count` integer — number of `ProtocolMessages` being acknowledged | +| RTN7b | Every `ProtocolMessage` that expects an ACK must contain a unique serially incrementing `msgSerial` integer value starting at zero | + +Tests that `publish()` returns a `PublishResult` whose `serials` array contains the message serials from the ACK response. + +### Setup +```pseudo +channel_name = "test-RTL6j-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + # Respond with ACK containing PublishResult with serials + mock_ws.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: msg.msgSerial, + count: 1, + res: [PublishResult(serials: ["abc123"])] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +result = AWAIT channel.publish(name: "greeting", data: "hello") +``` + +### Assertions +```pseudo +# Publish should have been sent with msgSerial +ASSERT length(captured_messages) == 1 +ASSERT captured_messages[0].msgSerial == 0 + +# Result should be a PublishResult with serials from the ACK +ASSERT result IS PublishResult +ASSERT length(result.serials) == 1 +ASSERT result.serials[0] == "abc123" +CLOSE_CLIENT(client) +``` + +--- + +## RTL6j - Publish returns PublishResult with multiple serials for batch publish + +**Test ID**: `realtime/unit/RTL6j/batch-publish-serials-1` + +**Spec requirement:** When an array of messages is published, the `PublishResult` `serials` array contains one serial per message, corresponding 1:1 to the published messages (PBR2a). A serial may be null if the message was discarded due to a configured conflation rule. + +Tests that a batch publish of multiple messages returns a `PublishResult` with a serial for each message. + +### Setup +```pseudo +channel_name = "test-RTL6j-batch-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + # Respond with ACK containing serials for each message + mock_ws.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: msg.msgSerial, + count: 1, + res: [PublishResult(serials: ["serial-1", null, "serial-3"])] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +result = AWAIT channel.publish(messages: [ + Message(name: "event1", data: "data1"), + Message(name: "event2", data: "data2"), + Message(name: "event3", data: "data3") +]) +``` + +### Assertions +```pseudo +# Single ProtocolMessage with 3 messages +ASSERT length(captured_messages) == 1 +ASSERT length(captured_messages[0].messages) == 3 + +# Result should contain serials 1:1 with published messages +ASSERT result IS PublishResult +ASSERT length(result.serials) == 3 +ASSERT result.serials[0] == "serial-1" +ASSERT result.serials[1] == null # Conflated message +ASSERT result.serials[2] == "serial-3" +CLOSE_CLIENT(client) +``` + +--- + +## RTL6j - Sequential publishes get incrementing msgSerial + +**Test ID**: `realtime/unit/RTL6j/incrementing-msg-serial-2` + +**Spec requirement:** Every ProtocolMessage that expects an ACK must contain a unique serially incrementing `msgSerial` integer value starting at zero (RTN7b). + +Tests that successive publish calls assign incrementing `msgSerial` values to the outgoing ProtocolMessages, and that each publish resolves with the correct `PublishResult` from its corresponding ACK. + +### Setup +```pseudo +channel_name = "test-RTL6j-serial-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + # Respond with ACK, using msgSerial to generate distinct serials + mock_ws.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: msg.msgSerial, + count: 1, + res: [PublishResult(serials: ["serial-${msg.msgSerial}"])] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +result1 = AWAIT channel.publish(name: "first", data: "1") +result2 = AWAIT channel.publish(name: "second", data: "2") +result3 = AWAIT channel.publish(name: "third", data: "3") +``` + +### Assertions +```pseudo +# Each outgoing MESSAGE should have incrementing msgSerial +ASSERT length(captured_messages) == 3 +ASSERT captured_messages[0].msgSerial == 0 +ASSERT captured_messages[1].msgSerial == 1 +ASSERT captured_messages[2].msgSerial == 2 + +# Each publish should resolve with the correct PublishResult +ASSERT result1.serials[0] == "serial-0" +ASSERT result2.serials[0] == "serial-1" +ASSERT result3.serials[0] == "serial-2" +CLOSE_CLIENT(client) +``` + +--- + +## RTL6j - Publish NACK results in error + +**Test ID**: `realtime/unit/RTL6j/nack-results-error-3` + +| Spec | Requirement | +|------|-------------| +| RTN7a | All MESSAGE ProtocolMessages sent to Ably expect either an ACK or NACK to confirm success or failure | + +Tests that when the server responds with a NACK instead of an ACK, the publish future completes with an error. + +### Setup +```pseudo +channel_name = "test-RTL6j-nack-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + # Respond with NACK + mock_ws.send_to_client(ProtocolMessage( + action: NACK, + msgSerial: msg.msgSerial, + count: 1, + error: ErrorInfo(code: 40160, statusCode: 401, message: "Publish rejected") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.publish(name: "rejected", data: "data") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code == 40160 +ASSERT error.message == "Publish rejected" +CLOSE_CLIENT(client) +``` + +--- + +## RTN7e - Pending publishes fail when connection enters SUSPENDED + +**Test ID**: `realtime/unit/RTN7e/pending-fail-suspended-0` + +| Spec | Requirement | +|------|-------------| +| RTN7e | If a connection enters the SUSPENDED, CLOSED or FAILED state, and an ACK or NACK has not yet been received for a message submitted to the connection, the client should consider the delivery of those messages as failed, meaning their callback should be called with an error representing the reason for the state change, and they should be removed from any RTN19a retry queue. | + +Tests that messages awaiting ACK/NACK are failed with the state change reason when the connection enters SUSPENDED. + +### Setup +```pseudo +channel_name = "test-RTN7e-suspended-${random_id()}" + +enable_fake_timers() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + # Do NOT send ACK — leave message pending + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + disconnectedRetryTimeout: 1000, + connectionStateTtl: 5000 +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish but don't ACK — message stays pending +publish_future = channel.publish(name: "pending", data: "data") + +# Disconnect and refuse all reconnection attempts so connection enters SUSPENDED +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_refused() +) +install_mock(mock_ws) + +mock_ws.active_connection.simulate_disconnect() + +# Advance time until connection enters SUSPENDED +LOOP up to 15 times: + ADVANCE_TIME(2000) + IF client.connection.state == ConnectionState.suspended: + BREAK + +AWAIT_STATE client.connection.state == ConnectionState.suspended + +# The pending publish should now fail +AWAIT publish_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RTN7e - Pending publishes fail when connection enters CLOSED + +**Test ID**: `realtime/unit/RTN7e/pending-fail-closed-1` + +**Spec requirement:** If a connection enters the CLOSED state, pending messages are failed with an error representing the reason for the state change. + +Tests that messages awaiting ACK/NACK are failed when the connection is explicitly closed. + +### Setup +```pseudo +channel_name = "test-RTN7e-closed-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + # Do NOT send ACK — leave message pending + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish but don't ACK — message stays pending +publish_future = channel.publish(name: "pending", data: "data") + +# Close the connection +AWAIT client.close() +ASSERT client.connection.state == ConnectionState.closed + +# The pending publish should now fail +AWAIT publish_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RTN7e - Pending publishes fail when connection enters FAILED + +**Test ID**: `realtime/unit/RTN7e/pending-fail-failed-2` + +**Spec requirement:** If a connection enters the FAILED state, pending messages are failed with an error representing the reason for the state change. + +Tests that messages awaiting ACK/NACK are failed when the connection enters FAILED. + +### Setup +```pseudo +channel_name = "test-RTN7e-failed-${random_id()}" +connection_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + IF connection_count == 1: + conn.respond_with_success(CONNECTED_MESSAGE) + ELSE: + # Fatal error on reconnection attempt + conn.respond_with_success() + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + # Do NOT send ACK — leave message pending + # Send a fatal error to force FAILED state + mock_ws.active_connection.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: 80000, message: "Fatal error") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish — server responds with fatal ERROR instead of ACK +publish_future = channel.publish(name: "pending", data: "data") + +AWAIT_STATE client.connection.state == ConnectionState.failed + +# The pending publish should now fail +AWAIT publish_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RTN7e - Multiple pending publishes all fail on state change + +**Test ID**: `realtime/unit/RTN7e/multiple-pending-fail-3` + +**Spec requirement:** All messages awaiting ACK/NACK are failed when the connection enters a terminal state. + +Tests that when multiple publishes are pending and the connection enters CLOSED, all of them fail. + +### Setup +```pseudo +channel_name = "test-RTN7e-multi-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + # Do NOT send ACK — leave all messages pending + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish multiple messages, none will be ACK'd +future1 = channel.publish(name: "msg1", data: "data1") +future2 = channel.publish(name: "msg2", data: "data2") +future3 = channel.publish(name: "msg3", data: "data3") + +# Close the connection +AWAIT client.close() + +# All pending publishes should fail +AWAIT future1 FAILS WITH error1 +AWAIT future2 FAILS WITH error2 +AWAIT future3 FAILS WITH error3 +``` + +### Assertions +```pseudo +ASSERT error1 IS NOT null +ASSERT error2 IS NOT null +ASSERT error3 IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RTN7e - Error passed to publish callback represents the reason for the state change + +**Test ID**: `realtime/unit/RTN7e/error-represents-reason-4` + +**Spec requirement:** The client should consider the delivery of those messages as failed, meaning their callback should be called with an error representing the reason for the state change. + +Tests that the error passed to the publish callback contains the same reason that caused the connection state change (e.g. the ErrorInfo from a fatal ERROR ProtocolMessage). + +### Setup +```pseudo +channel_name = "test-RTN7e-error-reason-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + # Do NOT send ACK — leave message pending + # Send a fatal error to force FAILED state with a specific reason + mock_ws.active_connection.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: 80019, statusCode: 400, message: "Connection closed due to admin action") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish — server responds with fatal ERROR instead of ACK +publish_future = channel.publish(name: "pending", data: "data") + +AWAIT_STATE client.connection.state == ConnectionState.failed + +# The pending publish should fail with the same error that caused the state change +AWAIT publish_future FAILS WITH error +``` + +### Assertions +```pseudo +# The error should represent the reason for the state change +ASSERT error IS NOT null +ASSERT error.code == 80019 +ASSERT error.statusCode == 400 +ASSERT error.message == "Connection closed due to admin action" + +# Verify the connection's errorReason matches +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +CLOSE_CLIENT(client) +``` + +--- + +## RTN7d - Pending publishes fail on DISCONNECTED when queueMessages is false + +**Test ID**: `realtime/unit/RTN7d/fail-disconnected-no-queue-0` + +| Spec | Requirement | +|------|-------------| +| RTN7d | If the `queueMessages` client option (TO3g) has been set to false, then when a connection enters the DISCONNECTED state, any messages which have not yet been ACK'd should be considered to have failed, with the same effect as in RTN7e. | + +Tests that when queueMessages is false and the connection becomes DISCONNECTED, pending messages awaiting ACK/NACK are failed immediately. + +### Setup +```pseudo +channel_name = "test-RTN7d-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + # Do NOT send ACK — leave message pending + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + queueMessages: false +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish but don't ACK — message stays pending +publish_future = channel.publish(name: "pending", data: "data") + +# Disconnect — triggers DISCONNECTED state +mock_ws.active_connection.simulate_disconnect() + +# Record state changes to verify DISCONNECTED was reached +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# The pending publish should fail immediately on DISCONNECTED +AWAIT publish_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RTN7d - Pending publishes survive DISCONNECTED when queueMessages is true (default) + +**Test ID**: `realtime/unit/RTN7d/survive-disconnected-queue-1` + +**Spec requirement:** The RTN7d behavior (failing on DISCONNECTED) only applies when `queueMessages` is false. With the default `queueMessages: true`, pending messages should NOT be failed on DISCONNECTED — they are retained for resending per RTN19a. + +Tests that with the default queueMessages=true, pending messages are not failed when the connection enters DISCONNECTED. + +### Setup +```pseudo +channel_name = "test-RTN7d-default-${random_id()}" +captured_messages = [] + +enable_fake_timers() + +connection_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success(CONNECTED_MESSAGE) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + IF connection_count >= 2: + # ACK on reconnection + mock_ws.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: msg.msgSerial, + count: 1, + res: [PublishResult(serials: ["serial-ack"])] + )) + # First connection: do NOT ACK — leave pending + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish but don't ACK — message stays pending +publish_future = channel.publish(name: "pending", data: "data") + +# Disconnect +mock_ws.active_connection.simulate_disconnect() + +# Reconnect +ADVANCE_TIME(2000) +AWAIT_STATE client.connection.state == ConnectionState.connected + +# The publish should eventually succeed (resent on new transport, then ACK'd) +result = AWAIT publish_future +``` + +### Assertions +```pseudo +ASSERT result IS PublishResult +ASSERT result.serials[0] == "serial-ack" +CLOSE_CLIENT(client) +``` + +--- + +## RTN19a - Pending messages resent on new transport after disconnect + +**Test ID**: `realtime/unit/RTN19a/resent-on-new-transport-0` + +| Spec | Requirement | +|------|-------------| +| RTN19a | Any ProtocolMessage that is awaiting an ACK/NACK on the old transport will not receive the ACK/NACK on the new transport. The client library must therefore resend any ProtocolMessage that is awaiting an ACK/NACK to Ably in order to receive the expected ACK/NACK for that message. | + +Tests that after a transport disconnect and reconnect, messages that were awaiting ACK/NACK are resent on the new transport. + +### Setup +```pseudo +channel_name = "test-RTN19a-${random_id()}" +captured_messages = [] + +enable_fake_timers() + +connection_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success(CONNECTED_MESSAGE) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append({ + msg: msg, + connection: connection_count + }) + IF connection_count >= 2: + # ACK on second connection + mock_ws.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: msg.msgSerial, + count: 1, + res: [PublishResult(serials: ["serial-resent"])] + )) + # First connection: do NOT ACK + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish — will be sent on first transport, no ACK received +publish_future = channel.publish(name: "resend-me", data: "data") + +# Verify message was sent on first transport +first_transport_messages = filter(captured_messages, (m) => m.connection == 1 AND m.msg.action == MESSAGE) +ASSERT length(first_transport_messages) == 1 + +# Disconnect +mock_ws.active_connection.simulate_disconnect() + +# Reconnect +ADVANCE_TIME(2000) +AWAIT_STATE client.connection.state == ConnectionState.connected + +# The publish should succeed (resent and ACK'd on new transport) +result = AWAIT publish_future +``` + +### Assertions +```pseudo +# Message should have been sent on both transports +second_transport_messages = filter(captured_messages, (m) => m.connection == 2 AND m.msg.action == MESSAGE) +ASSERT length(second_transport_messages) >= 1 + +# The resent message should have the same content +ASSERT second_transport_messages[0].msg.messages[0].name == "resend-me" + +# Publish should have resolved successfully +ASSERT result IS PublishResult +ASSERT result.serials[0] == "serial-resent" +CLOSE_CLIENT(client) +``` + +--- + +## RTN19a2 - Resent messages keep same msgSerial on successful resume + +**Test ID**: `realtime/unit/RTN19a2/same-serial-on-resume-0` + +| Spec | Requirement | +|------|-------------| +| RTN19a2 | In the case of an RTN15c6 successful resume, the msgSerial of the reattempted ProtocolMessages should remain the same as for the original attempt. | +| RTN15c6 | A CONNECTED ProtocolMessage with the same connectionId as the current client (and no error property) indicates that the resume attempt was valid. | + +Tests that when messages are resent after a successful connection resume, they retain their original msgSerial values. + +### Setup +```pseudo +channel_name = "test-RTN19a2-resume-${random_id()}" +captured_messages = [] +original_connection_id = "connection-abc" + +enable_fake_timers() + +connection_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + # Both connections use the same connectionId = successful resume (RTN15c6) + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: original_connection_id, + connectionKey: "key-${connection_count}" + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append({ + msg: msg, + connection: connection_count + }) + IF connection_count >= 2: + mock_ws.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: msg.msgSerial, + count: 1, + res: [PublishResult(serials: ["serial-resumed"])] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish two messages — neither will be ACK'd +future1 = channel.publish(name: "msg1", data: "data1") +future2 = channel.publish(name: "msg2", data: "data2") + +# Capture original msgSerials +first_transport_msgs = filter(captured_messages, (m) => m.connection == 1 AND m.msg.action == MESSAGE) +original_serial_1 = first_transport_msgs[0].msg.msgSerial +original_serial_2 = first_transport_msgs[1].msg.msgSerial + +# Disconnect and reconnect (successful resume — same connectionId) +mock_ws.active_connection.simulate_disconnect() +ADVANCE_TIME(2000) +AWAIT_STATE client.connection.state == ConnectionState.connected + +result1 = AWAIT future1 +result2 = AWAIT future2 +``` + +### Assertions +```pseudo +# Messages resent on second transport should have the SAME msgSerials +second_transport_msgs = filter(captured_messages, (m) => m.connection == 2 AND m.msg.action == MESSAGE) +ASSERT length(second_transport_msgs) == 2 +ASSERT second_transport_msgs[0].msg.msgSerial == original_serial_1 +ASSERT second_transport_msgs[1].msg.msgSerial == original_serial_2 +CLOSE_CLIENT(client) +``` + +--- + +## RTN19a2 - Resent messages get new msgSerial on failed resume + +**Test ID**: `realtime/unit/RTN19a2/new-serial-failed-resume-1` + +| Spec | Requirement | +|------|-------------| +| RTN19a2 | In the case of an RTN15c7 failed resume, the message must be assigned a new msgSerial from the SDK's internal counter. | +| RTN15c7 | CONNECTED ProtocolMessage with a new connectionId and an ErrorInfo in the error field. The internal msgSerial counter should be reset so that the first message published will contain a msgSerial of 0. | + +Tests that when messages are resent after a failed connection resume, they are assigned new msgSerial values starting from 0. + +### Setup +```pseudo +channel_name = "test-RTN19a2-failed-resume-${random_id()}" +captured_messages = [] + +enable_fake_timers() + +connection_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + IF connection_count == 1: + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-first", + connectionKey: "key-first" + )) + ELSE: + # Failed resume — different connectionId + error (RTN15c7) + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-new", + connectionKey: "key-new", + error: ErrorInfo(code: 80018, message: "Connection not resumable") + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append({ + msg: msg, + connection: connection_count + }) + IF connection_count >= 2: + mock_ws.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: msg.msgSerial, + count: 1, + res: [PublishResult(serials: ["serial-new"])] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish two messages with msgSerials 0 and 1 — neither will be ACK'd +future1 = channel.publish(name: "msg1", data: "data1") +future2 = channel.publish(name: "msg2", data: "data2") + +# Verify original serials +first_transport_msgs = filter(captured_messages, (m) => m.connection == 1 AND m.msg.action == MESSAGE) +ASSERT first_transport_msgs[0].msg.msgSerial == 0 +ASSERT first_transport_msgs[1].msg.msgSerial == 1 + +# Disconnect and reconnect (failed resume — different connectionId + error) +mock_ws.active_connection.simulate_disconnect() +ADVANCE_TIME(2000) +AWAIT_STATE client.connection.state == ConnectionState.connected + +result1 = AWAIT future1 +result2 = AWAIT future2 +``` + +### Assertions +```pseudo +# Messages resent on second transport should have NEW msgSerials starting from 0 +# (RTN15c7 resets the internal msgSerial counter) +second_transport_msgs = filter(captured_messages, (m) => m.connection == 2 AND m.msg.action == MESSAGE) +ASSERT length(second_transport_msgs) == 2 +ASSERT second_transport_msgs[0].msg.msgSerial == 0 +ASSERT second_transport_msgs[1].msg.msgSerial == 1 +CLOSE_CLIENT(client) +``` + +--- + +## RTN19b - Pending ATTACH resent on new transport after disconnect + +**Test ID**: `realtime/unit/RTN19b/attach-resent-on-reconnect-0` + +| Spec | Requirement | +|------|-------------| +| RTN19b | If there are any pending channels i.e. in the ATTACHING or DETACHING state, the respective ATTACH or DETACH message should be resent to Ably. | + +Tests that after a transport disconnect and reconnect, channels in the ATTACHING state have their ATTACH message resent. + +### Setup +```pseudo +channel_name = "test-RTN19b-attach-${random_id()}" +captured_attach_messages = [] + +enable_fake_timers() + +connection_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success(CONNECTED_MESSAGE) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + captured_attach_messages.append({ + msg: msg, + connection: connection_count + }) + IF connection_count >= 2: + # Respond with ATTACHED on second connection + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + # First connection: don't respond — leave channel ATTACHING + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach but don't respond — channel stays ATTACHING +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Verify ATTACH was sent on first transport +first_transport_attaches = filter(captured_attach_messages, (m) => m.connection == 1) +ASSERT length(first_transport_attaches) == 1 +ASSERT first_transport_attaches[0].msg.channel == channel_name + +# Disconnect and reconnect +mock_ws.active_connection.simulate_disconnect() +ADVANCE_TIME(2000) +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach should complete (ATTACH resent and responded to on new transport) +AWAIT attach_future +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached + +# ATTACH should have been resent on second transport +second_transport_attaches = filter(captured_attach_messages, (m) => m.connection == 2) +ASSERT length(second_transport_attaches) >= 1 +ASSERT second_transport_attaches[0].msg.channel == channel_name +CLOSE_CLIENT(client) +``` + +--- + +## RTN19b - Pending DETACH resent on new transport after disconnect + +**Test ID**: `realtime/unit/RTN19b/detach-resent-on-reconnect-1` + +**Spec requirement:** If there are any pending channels in the DETACHING state, the respective DETACH message should be resent to Ably. + +Tests that after a transport disconnect and reconnect, channels in the DETACHING state have their DETACH message resent. + +### Setup +```pseudo +channel_name = "test-RTN19b-detach-${random_id()}" +captured_detach_messages = [] + +enable_fake_timers() + +connection_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success(CONNECTED_MESSAGE) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + ELSE IF msg.action == DETACH: + captured_detach_messages.append({ + msg: msg, + connection: connection_count + }) + IF connection_count >= 2: + # Respond with DETACHED on second connection + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: msg.channel + )) + # First connection: don't respond — leave channel DETACHING + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Start detach but don't respond — channel stays DETACHING +detach_future = channel.detach() +AWAIT_STATE channel.state == ChannelState.detaching + +# Verify DETACH was sent on first transport +first_transport_detaches = filter(captured_detach_messages, (m) => m.connection == 1) +ASSERT length(first_transport_detaches) == 1 + +# Disconnect and reconnect +mock_ws.active_connection.simulate_disconnect() +ADVANCE_TIME(2000) +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Detach should complete (DETACH resent and responded to on new transport) +AWAIT detach_future +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached + +# DETACH should have been resent on second transport +second_transport_detaches = filter(captured_detach_messages, (m) => m.connection == 2) +ASSERT length(second_transport_detaches) >= 1 +ASSERT second_transport_detaches[0].msg.channel == channel_name +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/channels/channel_server_initiated_detach.md b/uts/realtime/unit/channels/channel_server_initiated_detach.md new file mode 100644 index 000000000..e7b41661f --- /dev/null +++ b/uts/realtime/unit/channels/channel_server_initiated_detach.md @@ -0,0 +1,612 @@ +# Server-Initiated DETACHED and Channel Retry Tests + +Spec points: `RTL13`, `RTL13a`, `RTL13b`, `RTL13c` + +## Test Type +Unit test with mocked WebSocket and fake timers + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL13a - Server DETACHED on ATTACHED channel triggers immediate reattach + +**Test ID**: `realtime/unit/RTL13a/attached-reattach-triggered-0` + +| Spec | Requirement | +|------|-------------| +| RTL13 | If the channel receives a server-initiated DETACHED when ATTACHING, ATTACHED, or SUSPENDED, specific handling applies | +| RTL13a | If ATTACHED or SUSPENDED, an immediate reattach attempt should be made by sending ATTACH, transitioning to ATTACHING with the error from the DETACHED message | + +Tests that receiving a server-initiated DETACHED while ATTACHED causes the channel to transition to ATTACHING with the error, send a new ATTACH message, and successfully reattach. + +### Setup +```pseudo +channel_name = "test-RTL13a-attached-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached +ASSERT attach_count == 1 + +# Record channel state changes from this point +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Server sends unsolicited DETACHED with error +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90198, statusCode: 500, message: "Server detached channel") +)) + +# Channel should reattach automatically +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +# Two ATTACH messages total: initial + reattach +ASSERT attach_count == 2 + +# State change sequence: ATTACHING (with error) -> ATTACHED +ASSERT length(channel_state_changes) >= 2 +ASSERT channel_state_changes[0].current == ChannelState.attaching +ASSERT channel_state_changes[0].previous == ChannelState.attached +ASSERT channel_state_changes[0].reason IS NOT null +ASSERT channel_state_changes[0].reason.code == 90198 +ASSERT channel_state_changes[1].current == ChannelState.attached +CLOSE_CLIENT(client) +``` + +--- + +## RTL13a - Server DETACHED on SUSPENDED channel triggers immediate reattach + +**Test ID**: `realtime/unit/RTL13a/suspended-reattach-triggered-1` + +**Spec requirement:** If the channel is in the SUSPENDED state and receives a server-initiated DETACHED, an immediate reattach attempt should be made. + +Tests that receiving a server-initiated DETACHED while SUSPENDED causes the channel to transition to ATTACHING and reattach. + +### Setup +```pseudo +channel_name = "test-RTL13a-suspended-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + # First attach succeeds + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + ELSE IF attach_count == 2: + # Second attach (after timeout) - don't respond, causing timeout -> SUSPENDED + ELSE: + # Third attach (after server DETACHED) - succeed + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 100, + channelRetryTimeout: 60000 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Force channel into SUSPENDED state by triggering a reattach that times out: +# Send server-initiated DETACHED to trigger RTL13a reattach +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90198, statusCode: 500, message: "Detach 1") +)) +AWAIT_STATE channel.state == ChannelState.attaching + +# Let the reattach timeout -> SUSPENDED +ADVANCE_TIME(150) +AWAIT_STATE channel.state == ChannelState.suspended + +# Now send another server-initiated DETACHED while SUSPENDED +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90199, statusCode: 500, message: "Detach 2") +)) + +# Channel should immediately attempt to reattach and succeed +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +# 3 total ATTACH messages: initial + RTL13a reattach + RTL13a reattach from SUSPENDED +ASSERT attach_count == 3 +CLOSE_CLIENT(client) +``` + +--- + +## RTL13b - Failed reattach transitions to SUSPENDED with automatic retry + +**Test ID**: `realtime/unit/RTL13b/failed-reattach-suspended-retry-0` + +| Spec | Requirement | +|------|-------------| +| RTL13b | If the reattach fails, or if the channel was already ATTACHING, channel transitions to SUSPENDED. An automatic re-attach attempt is made after channelRetryTimeout. If that also fails (timeout or DETACHED), the cycle repeats indefinitely. | + +Tests that when a server-initiated DETACHED triggers a reattach that times out, the channel transitions to SUSPENDED and then automatically retries after the suspended retry timeout. + +### Setup +```pseudo +channel_name = "test-RTL13b-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + # First attach succeeds + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + ELSE IF attach_count == 2: + # Reattach after server DETACHED - don't respond (timeout) + ELSE IF attach_count == 3: + # Automatic retry after channelRetryTimeout - succeed + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 100, + channelRetryTimeout: 200 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Record state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Server sends unsolicited DETACHED +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90198, statusCode: 500, message: "Server detached") +)) + +# Channel should be in ATTACHING (RTL13a) +AWAIT_STATE channel.state == ChannelState.attaching +ASSERT attach_count == 2 + +# Let reattach timeout -> SUSPENDED (RTL13b) +ADVANCE_TIME(150) +AWAIT_STATE channel.state == ChannelState.suspended + +# Wait for channelRetryTimeout to trigger automatic retry and succeed +ADVANCE_TIME(250) +AWAIT_STATE channel.state == ChannelState.attached +ASSERT attach_count == 3 +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT attach_count == 3 + +# Verify state sequence: ATTACHING -> SUSPENDED -> ATTACHING -> ATTACHED +ASSERT channel_state_changes CONTAINS_IN_ORDER [ + ChannelState.attaching, + ChannelState.suspended, + ChannelState.attaching, + ChannelState.attached +] +CLOSE_CLIENT(client) +``` + +--- + +## RTL13b - Server DETACHED while already ATTACHING transitions directly to SUSPENDED + +**Test ID**: `realtime/unit/RTL13b/attaching-detached-to-suspended-1` + +**Spec requirement:** If the channel was already in the ATTACHING state when the server-initiated DETACHED is received, the channel transitions directly to SUSPENDED (with automatic retry). + +Tests that a server-initiated DETACHED received while ATTACHING goes directly to SUSPENDED without another reattach attempt first. + +### Setup +```pseudo +channel_name = "test-RTL13b-attaching-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + # First attach - don't respond immediately, leave channel in ATTACHING + ELSE IF attach_count == 2: + # Automatic retry from SUSPENDED - succeed + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 500, + channelRetryTimeout: 200 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach but don't await it (mock won't respond) +channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Record state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Server sends DETACHED while channel is still ATTACHING +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90198, statusCode: 500, message: "Server detached") +)) + +# Channel should go directly to SUSPENDED (RTL13b), not try another reattach +AWAIT_STATE channel.state == ChannelState.suspended +ASSERT attach_count == 1 # Only the original attach, no second attempt + +# Wait for channelRetryTimeout — automatic retry should succeed +ADVANCE_TIME(250) +AWAIT_STATE channel.state == ChannelState.attached +ASSERT attach_count == 2 +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached + +# Verify direct transition to SUSPENDED (no intermediate ATTACHING) +ASSERT channel_state_changes[0].current == ChannelState.suspended +ASSERT channel_state_changes[0].previous == ChannelState.attaching +ASSERT channel_state_changes[0].reason IS NOT null +ASSERT channel_state_changes[0].reason.code == 90198 +CLOSE_CLIENT(client) +``` + +--- + +## RTL13b - Repeated failures cycle SUSPENDED -> ATTACHING indefinitely + +**Test ID**: `realtime/unit/RTL13b/repeated-failure-cycle-2` + +**Spec requirement:** If the re-attach also fails (timeout or DETACHED), the SUSPENDED -> retry cycle repeats indefinitely. + +Tests that repeated reattach failures produce repeated SUSPENDED -> ATTACHING cycles. + +### Setup +```pseudo +channel_name = "test-RTL13b-repeat-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + # First attach succeeds + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + ELSE IF attach_count <= 3: + # Reattach attempts 2 and 3 - don't respond (timeout) + ELSE: + # Fourth attempt succeeds + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 100, + channelRetryTimeout: 200 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT attach_count == 1 + +# Record state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Server sends DETACHED +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90198, statusCode: 500, message: "Detach") +)) + +# Cycle 1: ATTACHING (reattach) -> timeout -> SUSPENDED -> retry +AWAIT_STATE channel.state == ChannelState.attaching +ADVANCE_TIME(150) +AWAIT_STATE channel.state == ChannelState.suspended +ADVANCE_TIME(250) +AWAIT_STATE channel.state == ChannelState.attaching +ASSERT attach_count == 3 + +# Cycle 2: ATTACHING (retry) -> timeout -> SUSPENDED -> retry +ADVANCE_TIME(150) +AWAIT_STATE channel.state == ChannelState.suspended +ADVANCE_TIME(250) + +# Fourth attempt succeeds +AWAIT_STATE channel.state == ChannelState.attached +ASSERT attach_count == 4 +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT attach_count == 4 + +# Verify repeated cycling +ASSERT channel_state_changes CONTAINS_IN_ORDER [ + ChannelState.attaching, + ChannelState.suspended, + ChannelState.attaching, + ChannelState.suspended, + ChannelState.attaching, + ChannelState.attached +] +CLOSE_CLIENT(client) +``` + +--- + +## RTL13c - Retry cancelled when connection is no longer CONNECTED + +**Test ID**: `realtime/unit/RTL13c/retry-cancelled-disconnected-0` + +| Spec | Requirement | +|------|-------------| +| RTL13c | If the connection is no longer CONNECTED, the automatic re-attach attempts described in RTL13b must be cancelled, as any implicit channel state changes will be covered by RTL3 | + +Tests that when the connection leaves the CONNECTED state, any pending automatic channel retry is cancelled. + +### Setup +```pseudo +channel_name = "test-RTL13c-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + # First attach succeeds + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + ELSE: + # Don't respond to reattach attempts (timeout) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 100, + channelRetryTimeout: 200 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT attach_count == 1 + +# Server sends DETACHED +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90198, statusCode: 500, message: "Detach") +)) + +# Reattach triggered (RTL13a) but will timeout +AWAIT_STATE channel.state == ChannelState.attaching +ADVANCE_TIME(150) +AWAIT_STATE channel.state == ChannelState.suspended + +# Now disconnect the connection BEFORE the channelRetryTimeout fires +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state != ConnectionState.connected + +# Record attach_count at this point +attach_count_after_disconnect = attach_count + +# Advance time well past the channelRetryTimeout +ADVANCE_TIME(500) +``` + +### Assertions +```pseudo +# No additional ATTACH messages should have been sent after disconnect +ASSERT attach_count == attach_count_after_disconnect + +# Channel state is now governed by RTL3, not RTL13 +# (connection DISCONNECTED does not affect channel state per RTL3e, +# so channel should still be SUSPENDED) +ASSERT channel.state == ChannelState.suspended +CLOSE_CLIENT(client) +``` + +--- + +## RTL13a - DETACHED while DETACHING is not server-initiated + +**Test ID**: `realtime/unit/RTL13a/detaching-not-server-initiated-2` + +**Spec requirement:** RTL13 applies when the channel receives a server-initiated DETACHED when it is in ATTACHING, ATTACHED, or SUSPENDED. A channel in the DETACHING state has explicitly requested a detach, so a DETACHED response in that state is handled by the normal detach flow (RTL5), not RTL13. + +Tests that receiving a DETACHED while DETACHING completes the normal detach flow rather than triggering a reattach. + +### Setup +```pseudo +channel_name = "test-RTL13-detaching-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +AWAIT channel.detach() +``` + +### Assertions +```pseudo +# Channel should be cleanly DETACHED, not re-attached +ASSERT channel.state == ChannelState.detached + +# Only one ATTACH message (the initial attach, no reattach) +ASSERT attach_count == 1 +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/channels/channel_state_events.md b/uts/realtime/unit/channels/channel_state_events.md new file mode 100644 index 000000000..0fc862d1f --- /dev/null +++ b/uts/realtime/unit/channels/channel_state_events.md @@ -0,0 +1,679 @@ +# RealtimeChannel State and Events Tests + +Spec points: `RTL2`, `RTL2a`, `RTL2b`, `RTL2d`, `RTL2g`, `RTL2i`, `TH1`, `TH2`, `TH3`, `TH5`, `TH6` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL2b - Channel state attribute + +**Test ID**: `realtime/unit/RTL2b/channel-state-attribute-0` + +**Spec requirement:** `RealtimeChannel#state` attribute is the current state of the channel, of type `ChannelState`. + +Tests that channel has a state attribute of type ChannelState. + +### Setup +```pseudo +channel_name = "test-RTL2b-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Assertions +```pseudo +ASSERT channel.state IS ChannelState +ASSERT channel.state == ChannelState.initialized +CLOSE_CLIENT(client) +``` + +--- + +## RTL2b - Channel initial state is initialized + +**Test ID**: `realtime/unit/RTL2b/initial-state-initialized-1` + +**Spec requirement:** Channel state attribute reflects the current state. + +Tests that a newly created channel starts in the initialized state. + +### Setup +```pseudo +channel_name = "test-RTL2b-init-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +``` + +### Test Steps +```pseudo +channel = client.channels.get(channel_name) +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.initialized +CLOSE_CLIENT(client) +``` + +--- + +## RTL2a - State change events emitted for every state change + +**Test ID**: `realtime/unit/RTL2a/state-change-events-emitted-0` + +**Spec requirement:** It emits a `ChannelState` `ChannelEvent` for every channel state change. + +Tests that state changes emit corresponding events. + +### Setup +```pseudo +channel_name = "test-RTL2a-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) + +state_changes = [] +channel.on().listen((change) => state_changes.append(change)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Trigger attach - should emit attaching then attached +mock_ws.onMessageFromClient = (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) +} + +AWAIT channel.attach() +``` + +### Assertions +```pseudo +# Should have emitted attaching and attached state changes +ASSERT length(state_changes) >= 2 +ASSERT state_changes[0].current == ChannelState.attaching +ASSERT state_changes[0].previous == ChannelState.initialized +ASSERT state_changes[1].current == ChannelState.attached +ASSERT state_changes[1].previous == ChannelState.attaching +CLOSE_CLIENT(client) +``` + +--- + +## RTL2d, TH1, TH2, TH5 - ChannelStateChange object structure + +**Test ID**: `realtime/unit/RTL2d/state-change-object-structure-0` + +| Spec | Requirement | +|------|-------------| +| RTL2d | A ChannelStateChange object is emitted as the first argument for every ChannelEvent | +| TH1 | Whenever the channel state changes, a ChannelStateChange object is emitted | +| TH2 | Contains current state and previous state attributes | +| TH5 | Contains the event that generated the state change | + +Tests the structure of ChannelStateChange objects. + +### Setup +```pseudo +channel_name = "test-RTL2d-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) + +captured_change = null +channel.on().listen((change) => { + IF change.current == ChannelState.attaching: + captured_change = change +}) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT captured_change IS NOT null +ASSERT captured_change IS ChannelStateChange +ASSERT captured_change.current == ChannelState.attaching +ASSERT captured_change.previous == ChannelState.initialized +ASSERT captured_change.event == ChannelEvent.attaching +CLOSE_CLIENT(client) +``` + +--- + +## RTL2d, TH3 - ChannelStateChange includes error reason when applicable + +**Test ID**: `realtime/unit/RTL2d/state-change-error-reason-1` + +**Spec requirement:** Any state change triggered by a ProtocolMessage that contains an error member should populate the reason with that error. + +Tests that error information is included in state change when present. + +### Setup +```pseudo +channel_name = "test-RTL2d-error-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Server rejects attachment with error + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo( + code: 40160, + statusCode: 401, + message: "Channel denied" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) + +captured_change = null +channel.on(ChannelEvent.failed).listen((change) => { + captured_change = change +}) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT captured_change IS NOT null +ASSERT captured_change.current == ChannelState.failed +ASSERT captured_change.reason IS NOT null +ASSERT captured_change.reason.code == 40160 +ASSERT captured_change.reason.message == "Channel denied" +CLOSE_CLIENT(client) +``` + +--- + +## RTL2 - Filtered event subscription + +**Test ID**: `realtime/unit/RTL2/filtered-event-subscription-0` + +**Spec requirement:** RealtimeChannel implements EventEmitter and emits ChannelEvent events. + +Tests that subscribing to a specific event only receives that event. + +### Setup +```pseudo +channel_name = "test-RTL2-filtered-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) + +attached_events = [] +channel.on(ChannelEvent.attached).listen((change) => attached_events.append(change)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() +``` + +### Assertions +```pseudo +# Should only receive attached event, not attaching +ASSERT length(attached_events) == 1 +ASSERT attached_events[0].current == ChannelState.attached +ASSERT attached_events[0].event == ChannelEvent.attached +CLOSE_CLIENT(client) +``` + +--- + +## RTL2g - UPDATE event for condition changes without state change + +**Test ID**: `realtime/unit/RTL2g/update-event-condition-change-0` + +**Spec requirement:** It emits an UPDATE ChannelEvent for changes to channel +conditions for which the ChannelState does not change, unless explicitly +prevented by a more specific condition (see RTL12). + +Tests that UPDATE events are emitted when channel conditions change without +state change. Per RTL12, the additional ATTACHED must NOT have the RESUMED flag +set (resumed=true suppresses the UPDATE event). + +### Setup +```pseudo +channel_name = "test-RTL2g-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) + +update_events = [] +channel.on(ChannelEvent.update).listen((change) => update_events.append(change)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Server sends another ATTACHED message without RESUMED flag +# (e.g., loss of message continuity after transport resume) +# Per RTL12, this should trigger UPDATE because resumed=false +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + # No RESUMED flag — indicates loss of continuity +)) + +# Wait for the event to be processed +AWAIT Future.delayed(Duration.zero) +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached # State unchanged +ASSERT length(update_events) == 1 +ASSERT update_events[0].event == ChannelEvent.update +ASSERT update_events[0].current == ChannelState.attached +ASSERT update_events[0].previous == ChannelState.attached +ASSERT update_events[0].resumed == false +CLOSE_CLIENT(client) +``` + +--- + +## RTL2g - No duplicate state events + +**Test ID**: `realtime/unit/RTL2g/no-duplicate-state-events-1` + +**Spec requirement:** The library must never emit a ChannelState ChannelEvent for a state equal to the previous state. + +Tests that state events are not emitted when state doesn't actually change. + +### Setup +```pseudo +channel_name = "test-RTL2g-nodup-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) + +all_events = [] +channel.on().listen((change) => all_events.append(change)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +initial_count = length(all_events) + +# Server sends another ATTACHED message (no RESUMED flag) +# Per RTL12, this triggers UPDATE (not a duplicate state event) +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name +)) + +AWAIT Future.delayed(Duration.zero) +``` + +### Assertions +```pseudo +# Should have received UPDATE event, not another ATTACHED state event +# Count all events where current == attached AND event == attached (state event) +attached_state_events = filter(all_events, (e) => + e.current == ChannelState.attached AND e.event == ChannelEvent.attached +) +ASSERT length(attached_state_events) == 1 # Only the original attach +CLOSE_CLIENT(client) +``` + +--- + +## RTL2i, TH6 - hasBacklog flag in ChannelStateChange + +**Test ID**: `realtime/unit/RTL2i/has-backlog-flag-true-0` + +| Spec | Requirement | +|------|-------------| +| RTL2i | ChannelStateChange may expose hasBacklog property | +| TH6 | hasBacklog indicates whether channel should expect backlog from resume/rewind | + +Tests that hasBacklog is set when ATTACHED message contains HAS_BACKLOG flag. + +### Setup +```pseudo +channel_name = "test-RTL2i-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_BACKLOG + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) + +captured_change = null +channel.on(ChannelEvent.attached).listen((change) => { + captured_change = change +}) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT captured_change IS NOT null +ASSERT captured_change.hasBacklog == true +CLOSE_CLIENT(client) +``` + +--- + +## RTL2i - hasBacklog false when flag not present + +**Test ID**: `realtime/unit/RTL2i/has-backlog-flag-false-1` + +**Spec requirement:** hasBacklog should only be true when ATTACHED message contains HAS_BACKLOG flag. + +Tests that hasBacklog is false when the flag is not present. + +### Setup +```pseudo +channel_name = "test-RTL2i-false-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + # No HAS_BACKLOG flag + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) + +captured_change = null +channel.on(ChannelEvent.attached).listen((change) => { + captured_change = change +}) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT captured_change IS NOT null +ASSERT captured_change.hasBacklog == false OR captured_change.hasBacklog IS null +CLOSE_CLIENT(client) +``` + +--- + +## RTL2d - resumed flag in ChannelStateChange + +**Test ID**: `realtime/unit/RTL2d/resumed-flag-propagated-2` + +**Spec requirement:** ChannelStateChange has a resumed property indicating whether the ATTACHED message had the RESUMED flag set. + +Tests that resumed flag is correctly propagated. + +### Setup +```pseudo +channel_name = "test-RTL2d-resumed-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: RESUMED + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) + +captured_change = null +channel.on(ChannelEvent.attached).listen((change) => { + captured_change = change +}) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT captured_change IS NOT null +ASSERT captured_change.resumed == true +CLOSE_CLIENT(client) +``` + +--- + +## Channel errorReason attribute + +**Test ID**: `realtime/unit/RTL24/error-reason-populated-0` + +**Spec requirement:** Channel should expose error information when in failed state. + +Tests that errorReason is populated when channel enters failed state. + +### Setup +```pseudo +channel_name = "test-errorReason-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo( + code: 40160, + statusCode: 401, + message: "Not authorized" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.failed +ASSERT channel.errorReason IS NOT null +ASSERT channel.errorReason.code == 40160 +ASSERT channel.errorReason.message == "Not authorized" +CLOSE_CLIENT(client) +``` + +--- + +## RTL4c - errorReason cleared on successful attach + +**Test ID**: `realtime/unit/RTL4c/error-reason-cleared-attach-0` + +**Spec requirement (RTL4c):** When the confirmation ATTACHED ProtocolMessage is received, the channel's errorReason is set to null. + +Tests that errorReason is cleared after successful attach following a failure. + +### Setup +```pseudo +channel_name = "test-errorReason-clear-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + # First attach fails + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 40160, message: "Denied") + )) + ELSE: + # Second attach succeeds + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# First attach fails +AWAIT channel.attach() FAILS WITH error +ASSERT channel.state == ChannelState.failed +ASSERT channel.errorReason IS NOT null + +# Second attach succeeds +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT channel.errorReason IS null +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/channels/channel_subscribe.md b/uts/realtime/unit/channels/channel_subscribe.md new file mode 100644 index 000000000..6bfbf41a8 --- /dev/null +++ b/uts/realtime/unit/channels/channel_subscribe.md @@ -0,0 +1,1568 @@ +# RealtimeChannel Subscribe and Unsubscribe Tests + +Spec points: `RTL7`, `RTL7a`, `RTL7b`, `RTL7g`, `RTL7h`, `RTL7f`, `RTL8`, `RTL8a`, `RTL8b`, `RTL8c`, `RTL17`, `RTL22`, `RTL22a`, `RTL22b`, `RTL22c`, `RTL22d`, `MFI1`, `MFI2`, `MFI2a`, `MFI2b`, `MFI2c`, `MFI2d`, `MFI2e` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL7a - Subscribe with no name receives all messages + +**Test ID**: `realtime/unit/RTL7a/subscribe-all-messages-0` + +**Spec requirement:** Subscribe with a single listener argument subscribes a listener to all messages. + +Tests that subscribing without a name filter delivers all incoming messages regardless of name. + +### Setup +```pseudo +channel_name = "test-RTL7a-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +received_messages = [] +channel.subscribe((message) => { + received_messages.append(message) +}) + +# Server sends messages with different names +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "event1", data: "data1") + ] +)) + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "event2", data: "data2") + ] +)) + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: null, data: "data3") + ] +)) +``` + +### Assertions +```pseudo +ASSERT length(received_messages) == 3 +ASSERT received_messages[0].name == "event1" +ASSERT received_messages[0].data == "data1" +ASSERT received_messages[1].name == "event2" +ASSERT received_messages[1].data == "data2" +ASSERT received_messages[2].name IS null +ASSERT received_messages[2].data == "data3" +CLOSE_CLIENT(client) +``` + +--- + +## RTL7a - Subscribe receives multiple messages from a single ProtocolMessage + +**Test ID**: `realtime/unit/RTL7a/multiple-messages-per-protocol-1` + +**Spec requirement:** Subscribe with a single listener argument subscribes a listener to all messages. + +Tests that when a ProtocolMessage contains multiple messages in its `messages` array, each is delivered individually to the subscriber. + +### Setup +```pseudo +channel_name = "test-RTL7a-multi-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +received_messages = [] +channel.subscribe((message) => { + received_messages.append(message) +}) + +# Server sends a single ProtocolMessage with multiple messages +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "batch1", data: "first"), + Message(name: "batch2", data: "second"), + Message(name: "batch3", data: "third") + ] +)) +``` + +### Assertions +```pseudo +ASSERT length(received_messages) == 3 +ASSERT received_messages[0].name == "batch1" +ASSERT received_messages[1].name == "batch2" +ASSERT received_messages[2].name == "batch3" +CLOSE_CLIENT(client) +``` + +--- + +## RTL7b - Subscribe with name only receives matching messages + +**Test ID**: `realtime/unit/RTL7b/name-filtered-subscribe-0` + +**Spec requirement:** Subscribe with a name argument and a listener argument subscribes a listener to only messages whose `name` member matches the string name. + +Tests that subscribing with a name filter delivers only messages with the matching name. + +### Setup +```pseudo +channel_name = "test-RTL7b-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +received_messages = [] +channel.subscribe("target", (message) => { + received_messages.append(message) +}) + +# Server sends messages with different names +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "other", data: "should-not-receive") + ] +)) + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "target", data: "should-receive") + ] +)) + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: null, data: "no-name-should-not-receive") + ] +)) +``` + +### Assertions +```pseudo +ASSERT length(received_messages) == 1 +ASSERT received_messages[0].name == "target" +ASSERT received_messages[0].data == "should-receive" +CLOSE_CLIENT(client) +``` + +--- + +## RTL7b - Multiple name-specific subscriptions are independent + +**Test ID**: `realtime/unit/RTL7b/multiple-name-subscriptions-1` + +**Spec requirement:** Subscribe with a name argument and a listener argument subscribes a listener to only messages whose `name` member matches the string name. + +Tests that multiple name-specific subscriptions each receive only their matching messages. + +### Setup +```pseudo +channel_name = "test-RTL7b-multi-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +alpha_messages = [] +beta_messages = [] + +channel.subscribe("alpha", (message) => { + alpha_messages.append(message) +}) + +channel.subscribe("beta", (message) => { + beta_messages.append(message) +}) + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "alpha", data: "a1"), + Message(name: "beta", data: "b1"), + Message(name: "alpha", data: "a2"), + Message(name: "gamma", data: "g1") + ] +)) +``` + +### Assertions +```pseudo +ASSERT length(alpha_messages) == 2 +ASSERT alpha_messages[0].data == "a1" +ASSERT alpha_messages[1].data == "a2" + +ASSERT length(beta_messages) == 1 +ASSERT beta_messages[0].data == "b1" +CLOSE_CLIENT(client) +``` + +--- + +## RTL7g - Subscribe triggers implicit attach when attachOnSubscribe is true + +**Test ID**: `realtime/unit/RTL7g/implicit-attach-initialized-0` + +**Spec requirement:** If the `attachOnSubscribe` channel option is `true`, implicitly attaches the `RealtimeChannel` if the channel is in the `INITIALIZED`, `DETACHING`, or `DETACHED` states. The listener will always be registered regardless of the implicit attach result. + +Tests that subscribing on a channel with `attachOnSubscribe: true` (the default) triggers an implicit attach from INITIALIZED state. + +### Setup +```pseudo +channel_name = "test-RTL7g-${random_id()}" +attach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_message_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +# Default attachOnSubscribe is true +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT channel.state == ChannelState.initialized + +received_messages = [] +channel.subscribe((message) => { + received_messages.append(message) +}) + +# Wait for implicit attach to complete +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT attach_message_count == 1 + +# Verify the listener was registered by sending a message +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "test", data: "hello") + ] +)) +ASSERT length(received_messages) == 1 +CLOSE_CLIENT(client) +``` + +--- + +## RTL7g - Subscribe triggers implicit attach from DETACHED state + +**Test ID**: `realtime/unit/RTL7g/implicit-attach-detached-1` + +**Spec requirement:** If the `attachOnSubscribe` channel option is `true`, implicitly attaches the `RealtimeChannel` if the channel is in the `INITIALIZED`, `DETACHING`, or `DETACHED` states. + +Tests that subscribing on a DETACHED channel triggers an implicit attach. + +### Setup +```pseudo +channel_name = "test-RTL7g-detached-${random_id()}" +attach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_message_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +AWAIT channel.detach() +ASSERT channel.state == ChannelState.detached +ASSERT attach_message_count == 1 + +# Subscribe should trigger implicit attach from DETACHED +channel.subscribe((message) => {}) + +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT attach_message_count == 2 +CLOSE_CLIENT(client) +``` + +--- + +## RTL7g - Listener registered even if implicit attach fails + +**Test ID**: `realtime/unit/RTL7g/listener-registered-attach-fails-2` + +**Spec requirement:** The listener will always be registered regardless of the implicit attach result. + +Tests that the subscription listener is registered even when the implicit attach fails. + +### Setup +```pseudo +channel_name = "test-RTL7g-fail-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Fail the attach with a channel error + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 40160, statusCode: 401, message: "Not permitted") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +received_messages = [] +channel.subscribe((message) => { + received_messages.append(message) +}) + +# Wait for the channel to enter FAILED from the rejected attach +AWAIT_STATE channel.state == ChannelState.failed + +# Verify the listener was registered despite the failed attach. +# Re-attach the channel so messages can flow. +# First, reset mock to succeed on attach: +mock_ws.onMessageFromClient = (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) +} +AWAIT channel.attach() + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "test", data: "after-reattach") + ] +)) +``` + +### Assertions +```pseudo +ASSERT length(received_messages) == 1 +ASSERT received_messages[0].data == "after-reattach" +CLOSE_CLIENT(client) +``` + +--- + +## RTL7h - Subscribe does not attach when attachOnSubscribe is false + +**Test ID**: `realtime/unit/RTL7h/no-attach-on-subscribe-0` + +**Spec requirement:** If the `attachOnSubscribe` channel option is `false`, then subscribe should not trigger an implicit attach. + +Tests that subscribing with `attachOnSubscribe: false` does not trigger an attach. + +### Setup +```pseudo +channel_name = "test-RTL7h-${random_id()}" +attach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_message_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT channel.state == ChannelState.initialized + +channel.subscribe((message) => {}) +``` + +### Assertions +```pseudo +# Channel should remain INITIALIZED — no attach triggered +ASSERT channel.state == ChannelState.initialized +ASSERT attach_message_count == 0 +CLOSE_CLIENT(client) +``` + +--- + +## RTL7g - Subscribe does not attach when already attached + +**Test ID**: `realtime/unit/RTL7g/no-attach-when-attached-3` + +**Spec requirement:** Implicitly attaches the `RealtimeChannel` if the channel is in the `INITIALIZED`, `DETACHING`, or `DETACHED` states. + +Tests that subscribing on an already-attached channel does not trigger another attach. + +### Setup +```pseudo +channel_name = "test-RTL7g-already-${random_id()}" +attach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_message_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT attach_message_count == 1 + +# Subscribe on already-attached channel — no additional attach +channel.subscribe((message) => {}) +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT attach_message_count == 1 +CLOSE_CLIENT(client) +``` + +--- + +## RTL7g - Subscribe does not attach when already attaching + +**Test ID**: `realtime/unit/RTL7g/no-attach-when-attaching-4` + +**Spec requirement:** Implicitly attaches the `RealtimeChannel` if the channel is in the `INITIALIZED`, `DETACHING`, or `DETACHED` states. + +Tests that subscribing on a channel that is already ATTACHING does not trigger a second attach. + +### Setup +```pseudo +channel_name = "test-RTL7g-attaching-${random_id()}" +attach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_message_count++ + # Don't respond yet — leave channel in ATTACHING + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach but don't complete it +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching +ASSERT attach_message_count == 1 + +# Subscribe while attaching — should not trigger another attach +channel.subscribe((message) => {}) +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attaching +ASSERT attach_message_count == 1 # No additional ATTACH message sent +CLOSE_CLIENT(client) +``` + +--- + +## RTL17 - Messages not delivered when channel is not ATTACHED + +**Test ID**: `realtime/unit/RTL17/no-delivery-when-not-attached-0` + +**Spec requirement:** No messages should be passed to subscribers if the channel is in any state other than `ATTACHED`. + +Tests that incoming MESSAGE protocol messages are not delivered to subscribers when the channel is not in the ATTACHED state (e.g. ATTACHING, SUSPENDED). + +### Setup +```pseudo +channel_name = "test-RTL17-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Don't respond — leave channel in ATTACHING + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +received_messages = [] +channel.subscribe((message) => { + received_messages.append(message) +}) + +# Start attach but don't complete it — channel stays ATTACHING +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Server sends a message while channel is still ATTACHING +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "premature", data: "should-not-deliver") + ] +)) +``` + +### Assertions +```pseudo +# Message should not have been delivered +ASSERT length(received_messages) == 0 +CLOSE_CLIENT(client) +``` + +--- + +## RTL7f - Messages not echoed when echoMessages is false + +**Test ID**: `realtime/unit/RTL7f/no-echo-messages-0` + +**Spec requirement:** A test should exist ensuring published messages are not echoed back to the subscriber when `echoMessages` is set to false in the `RealtimeClient` library constructor. + +> **Implementation note:** Echo suppression may be implemented either by client-side +> filtering (comparing incoming message connectionId against the local connectionId, +> as shown below) or by server-side delegation (passing `echo=false` in the connection +> parameters). SDKs using server-side delegation should adapt this test to verify the +> echo parameter is set on the connection URL, rather than testing client-side filtering. + +Tests that when `echoMessages` is false, messages originating from this connection (identified by matching `connectionId`) are not delivered to subscribers. + +### Setup +```pseudo +channel_name = "test-RTL7f-${random_id()}" +connection_id = "conn-self-123" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: connection_id, + connectionKey: "key-456" + )), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + echoMessages: false +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +received_messages = [] +channel.subscribe((message) => { + received_messages.append(message) +}) + +# Server echoes back a message with this connection's connectionId +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + connectionId: connection_id, + messages: [ + Message(name: "echo", data: "from-self") + ] +)) + +# Server sends a message from a different connection +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + connectionId: "conn-other-789", + messages: [ + Message(name: "remote", data: "from-other") + ] +)) +``` + +### Assertions +```pseudo +# Only the message from the other connection should be delivered +ASSERT length(received_messages) == 1 +ASSERT received_messages[0].name == "remote" +ASSERT received_messages[0].data == "from-other" +CLOSE_CLIENT(client) +``` + +--- + +## RTL8a - Unsubscribe specific listener from all messages + +**Test ID**: `realtime/unit/RTL8a/unsubscribe-specific-listener-0` + +**Spec requirement:** Unsubscribe with a single listener argument unsubscribes the provided listener to all messages if subscribed. + +Tests that unsubscribing a specific listener stops it from receiving messages, while other listeners continue. + +### Setup +```pseudo +channel_name = "test-RTL8a-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +messages_a = [] +messages_b = [] + +listener_a = (message) => { messages_a.append(message) } +listener_b = (message) => { messages_b.append(message) } + +channel.subscribe(listener_a) +channel.subscribe(listener_b) + +# Both listeners receive first message +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "msg1", data: "first") + ] +)) + +ASSERT length(messages_a) == 1 +ASSERT length(messages_b) == 1 + +# Unsubscribe listener_a +channel.unsubscribe(listener_a) + +# Only listener_b should receive second message +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "msg2", data: "second") + ] +)) +``` + +### Assertions +```pseudo +ASSERT length(messages_a) == 1 # Did not receive second message +ASSERT length(messages_b) == 2 # Received both messages +ASSERT messages_b[1].name == "msg2" +CLOSE_CLIENT(client) +``` + +--- + +## RTL8b - Unsubscribe listener from specific name + +**Test ID**: `realtime/unit/RTL8b/unsubscribe-named-listener-0` + +**Spec requirement:** Unsubscribe with a name argument and a listener argument unsubscribes the provided listener if previously subscribed with a name-specific subscription. + +Tests that unsubscribing with a name removes only that name-specific subscription for the listener. + +### Setup +```pseudo +channel_name = "test-RTL8b-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +received_messages = [] +listener = (message) => { received_messages.append(message) } + +# Subscribe to two different names with the same listener +channel.subscribe("alpha", listener) +channel.subscribe("beta", listener) + +# Both subscriptions active +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "alpha", data: "a1"), + Message(name: "beta", data: "b1") + ] +)) +ASSERT length(received_messages) == 2 + +# Unsubscribe only from "alpha" +channel.unsubscribe("alpha", listener) + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "alpha", data: "a2"), + Message(name: "beta", data: "b2") + ] +)) +``` + +### Assertions +```pseudo +# "alpha" unsubscribed but "beta" still active +ASSERT length(received_messages) == 3 +ASSERT received_messages[2].name == "beta" +ASSERT received_messages[2].data == "b2" +CLOSE_CLIENT(client) +``` + +--- + +## RTL8c - Unsubscribe with no arguments removes all listeners + +**Test ID**: `realtime/unit/RTL8c/unsubscribe-all-listeners-0` + +**Spec requirement:** Unsubscribe with no arguments unsubscribes all listeners. + +Tests that calling unsubscribe with no arguments removes all subscriptions from the channel. + +### Setup +```pseudo +channel_name = "test-RTL8c-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +messages_all = [] +messages_named = [] + +channel.subscribe((message) => { messages_all.append(message) }) +channel.subscribe("specific", (message) => { messages_named.append(message) }) + +# Both listeners receive +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "specific", data: "first") + ] +)) +ASSERT length(messages_all) == 1 +ASSERT length(messages_named) == 1 + +# Unsubscribe all +channel.unsubscribe() + +# No listeners should receive +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "specific", data: "second"), + Message(name: "other", data: "third") + ] +)) +``` + +### Assertions +```pseudo +ASSERT length(messages_all) == 1 # No new messages +ASSERT length(messages_named) == 1 # No new messages +CLOSE_CLIENT(client) +``` + +--- + +## RTL8a - Unsubscribe listener not currently subscribed is no-op + +**Test ID**: `realtime/unit/RTL8a/unsubscribe-noop-not-subscribed-1` + +**Spec requirement:** Unsubscribe with a single listener argument unsubscribes the provided listener to all messages if subscribed. + +Tests that unsubscribing a listener that was never subscribed does not cause an error or affect existing subscriptions. + +### Setup +```pseudo +channel_name = "test-RTL8a-noop-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +received_messages = [] +active_listener = (message) => { received_messages.append(message) } +unused_listener = (message) => {} + +channel.subscribe(active_listener) + +# Unsubscribe a listener that was never subscribed — should be no-op +channel.unsubscribe(unused_listener) + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "test", data: "still-works") + ] +)) +``` + +### Assertions +```pseudo +# Existing subscription should be unaffected +ASSERT length(received_messages) == 1 +ASSERT received_messages[0].data == "still-works" +CLOSE_CLIENT(client) +``` + +--- + +## RTL22a - Subscribe with MessageFilter matching name + +**Test ID**: `realtime/unit/RTL22a/filter-matching-name-0` + +| Spec | Requirement | +|------|-------------| +| RTL22 | Methods must be provided for attaching and removing a listener which only executes when the message matches a set of criteria. | +| RTL22a | The method must allow for filters matching one or more of: extras.ref.timeserial, extras.ref.type or name. See MFI1 for an object implementation. | +| RTL22d | The method should use the MessageFilter object if possible and idiomatic for the language. | +| MFI1 | Supplies filter options to subscribe as defined in RTL22. | +| MFI2d | name - A string for checking if a message's name matches the supplied value. | + +Tests that subscribing with a MessageFilter specifying `name` delivers only messages whose name matches the filter. + +### Setup +```pseudo +channel_name = "test-RTL22a-name-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +filtered_messages = [] +filter = MessageFilter(name: "target-event") +channel.subscribe(filter, (message) => { + filtered_messages.append(message) +}) + +# Server sends messages with different names +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "target-event", data: "match-1") + ] +)) + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "other-event", data: "no-match") + ] +)) + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "target-event", data: "match-2") + ] +)) + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: null, data: "no-name") + ] +)) +``` + +### Assertions +```pseudo +ASSERT length(filtered_messages) == 2 +ASSERT filtered_messages[0].name == "target-event" +ASSERT filtered_messages[0].data == "match-1" +ASSERT filtered_messages[1].name == "target-event" +ASSERT filtered_messages[1].data == "match-2" +CLOSE_CLIENT(client) +``` + +--- + +## RTL22a - Subscribe with MessageFilter matching extras.ref.timeserial + +**Test ID**: `realtime/unit/RTL22a/filter-matching-ref-timeserial-1` + +| Spec | Requirement | +|------|-------------| +| RTL22a | The method must allow for filters matching one or more of: extras.ref.timeserial, extras.ref.type or name. | +| MFI2b | refTimeserial - A string for checking if a message's extras.ref.timeserial matches the supplied value. | + +Tests that subscribing with a MessageFilter specifying `refTimeserial` delivers only messages whose `extras.ref.timeserial` matches the filter value. + +### Setup +```pseudo +channel_name = "test-RTL22a-ref-timeserial-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +filtered_messages = [] +filter = MessageFilter(refTimeserial: "abc123@1700000000000-0") +channel.subscribe(filter, (message) => { + filtered_messages.append(message) +}) + +# Message with matching extras.ref.timeserial +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "reply", data: "match", extras: { + "ref": {"timeserial": "abc123@1700000000000-0", "type": "com.ably.reply"} + }) + ] +)) + +# Message with different extras.ref.timeserial +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "reply", data: "no-match", extras: { + "ref": {"timeserial": "xyz789@1700000000000-0", "type": "com.ably.reply"} + }) + ] +)) + +# Message with no extras.ref at all +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "plain", data: "no-ref") + ] +)) + +# Another message with matching extras.ref.timeserial +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "reaction", data: "match-2", extras: { + "ref": {"timeserial": "abc123@1700000000000-0", "type": "com.ably.reaction"} + }) + ] +)) +``` + +### Assertions +```pseudo +ASSERT length(filtered_messages) == 2 +ASSERT filtered_messages[0].data == "match" +ASSERT filtered_messages[1].data == "match-2" +CLOSE_CLIENT(client) +``` + +--- + +## RTL22b - Subscribe with MessageFilter isRef false delivers only messages without extras.ref + +**Test ID**: `realtime/unit/RTL22b/filter-isref-false-0` + +| Spec | Requirement | +|------|-------------| +| RTL22b | The method must allow for matching only messages which do not have extras.ref. | +| MFI2a | isRef - A boolean for checking if a message contains an extras.ref field. | + +Tests that subscribing with a MessageFilter specifying `isRef: false` delivers only messages that do NOT have an `extras.ref` field. + +### Setup +```pseudo +channel_name = "test-RTL22b-isref-false-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +filtered_messages = [] +filter = MessageFilter(isRef: false) +channel.subscribe(filter, (message) => { + filtered_messages.append(message) +}) + +# Message WITHOUT extras.ref (no extras at all) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "plain", data: "no-extras") + ] +)) + +# Message WITH extras.ref — should NOT be delivered +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "reply", data: "has-ref", extras: { + "ref": {"timeserial": "abc123@1700000000000-0", "type": "com.ably.reply"} + }) + ] +)) + +# Message with extras but no ref field — should be delivered +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "annotated", data: "extras-no-ref", extras: { + "headers": {"custom-key": "custom-value"} + }) + ] +)) + +# Another message WITH extras.ref — should NOT be delivered +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "reaction", data: "also-has-ref", extras: { + "ref": {"timeserial": "xyz789@1700000000000-0", "type": "com.ably.reaction"} + }) + ] +)) +``` + +### Assertions +```pseudo +# Only messages without extras.ref should be delivered +ASSERT length(filtered_messages) == 2 +ASSERT filtered_messages[0].name == "plain" +ASSERT filtered_messages[0].data == "no-extras" +ASSERT filtered_messages[1].name == "annotated" +ASSERT filtered_messages[1].data == "extras-no-ref" +CLOSE_CLIENT(client) +``` + +--- + +## RTL22c - Subscribe with MessageFilter matching multiple criteria (name + refType) + +**Test ID**: `realtime/unit/RTL22c/filter-multiple-criteria-0` + +| Spec | Requirement | +|------|-------------| +| RTL22c | The listener must only execute if all provided criteria are met. | +| MFI2c | refType - A string for checking if a message's extras.ref.type matches the supplied value. | +| MFI2d | name - A string for checking if a message's name matches the supplied value. | + +Tests that when a MessageFilter specifies multiple criteria (name AND refType), only messages matching ALL criteria are delivered. + +### Setup +```pseudo +channel_name = "test-RTL22c-multi-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +filtered_messages = [] +filter = MessageFilter(name: "comment", refType: "com.ably.reply") +channel.subscribe(filter, (message) => { + filtered_messages.append(message) +}) + +# Message matching BOTH name AND refType — should be delivered +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "comment", data: "both-match", extras: { + "ref": {"timeserial": "abc@1700000000000-0", "type": "com.ably.reply"} + }) + ] +)) + +# Message matching name but NOT refType — should NOT be delivered +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "comment", data: "name-only", extras: { + "ref": {"timeserial": "def@1700000000000-0", "type": "com.ably.reaction"} + }) + ] +)) + +# Message matching refType but NOT name — should NOT be delivered +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "update", data: "type-only", extras: { + "ref": {"timeserial": "ghi@1700000000000-0", "type": "com.ably.reply"} + }) + ] +)) + +# Message matching NEITHER — should NOT be delivered +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "update", data: "neither") + ] +)) + +# Another message matching BOTH — should be delivered +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "comment", data: "both-match-2", extras: { + "ref": {"timeserial": "jkl@1700000000000-0", "type": "com.ably.reply"} + }) + ] +)) +``` + +### Assertions +```pseudo +# Only messages matching ALL criteria (name == "comment" AND refType == "com.ably.reply") +ASSERT length(filtered_messages) == 2 +ASSERT filtered_messages[0].data == "both-match" +ASSERT filtered_messages[1].data == "both-match-2" +CLOSE_CLIENT(client) +``` + +--- + +## RTL22a, MFI2e - Subscribe with MessageFilter matching clientId + +**Test ID**: `realtime/unit/RTL22a/filter-matching-clientid-2` + +| Spec | Requirement | +|------|-------------| +| RTL22a | The method must allow for filters matching one or more of: extras.ref.timeserial, extras.ref.type or name. | +| MFI2e | clientId - A string for checking if a message's clientId matches the supplied value. | + +Tests that subscribing with a MessageFilter specifying `clientId` delivers only messages whose clientId matches the filter value. + +### Setup +```pseudo +channel_name = "test-RTL22a-clientid-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +filtered_messages = [] +filter = MessageFilter(clientId: "user-42") +channel.subscribe(filter, (message) => { + filtered_messages.append(message) +}) + +# Message with matching clientId +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "chat", data: "hello", clientId: "user-42") + ] +)) + +# Message with different clientId — should NOT be delivered +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "chat", data: "hi", clientId: "user-99") + ] +)) + +# Message with no clientId — should NOT be delivered +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "system", data: "broadcast") + ] +)) + +# Another message with matching clientId +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "chat", data: "world", clientId: "user-42") + ] +)) +``` + +### Assertions +```pseudo +ASSERT length(filtered_messages) == 2 +ASSERT filtered_messages[0].data == "hello" +ASSERT filtered_messages[0].clientId == "user-42" +ASSERT filtered_messages[1].data == "world" +ASSERT filtered_messages[1].clientId == "user-42" +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/channels/channel_update_delete_message.md b/uts/realtime/unit/channels/channel_update_delete_message.md new file mode 100644 index 000000000..38d478a63 --- /dev/null +++ b/uts/realtime/unit/channels/channel_update_delete_message.md @@ -0,0 +1,607 @@ +# RealtimeChannel UpdateMessage/DeleteMessage/AppendMessage Tests + +Spec points: `RTL32`, `RTL32a`, `RTL32b`, `RTL32b1`, `RTL32b2`, `RTL32c`, `RTL32d`, `RTL32e` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL32b, RTL32b1 — updateMessage sends MESSAGE ProtocolMessage with action MESSAGE_UPDATE + +**Test ID**: `realtime/unit/RTL32b/update-message-action-0` + +| Spec | Requirement | +|------|-------------| +| RTL32b | Send a `MESSAGE` `ProtocolMessage` containing a single `Message` | +| RTL32b1 | `action` set to `MESSAGE_UPDATE` for `updateMessage()` | + +Tests that `updateMessage()` sends a MESSAGE ProtocolMessage with the message action set to MESSAGE_UPDATE. + +### Setup +```pseudo +channel_name = "test-RTL32-update-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + captured_messages.append(msg) + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH + )) + ELSE IF msg.action == MESSAGE: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1, + res: { "serials": ["version-serial-1"] } + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +result = AWAIT channel.updateMessage( + Message(serial: "msg-serial-1", name: "updated", data: "new-data"), +) +``` + +### Assertions +```pseudo +# Find the MESSAGE ProtocolMessage (not the ATTACH) +message_pm = null +FOR pm IN captured_messages: + IF pm.action == MESSAGE: + message_pm = pm +ASSERT message_pm IS NOT null + +ASSERT message_pm.channel == channel_name +ASSERT message_pm.messages.length == 1 + +msg = message_pm.messages[0] +ASSERT msg.action == MessageAction.MESSAGE_UPDATE # numeric: 1 +ASSERT msg.serial == "msg-serial-1" +ASSERT msg.name == "updated" +ASSERT msg.data == "new-data" +CLOSE_CLIENT(client) +``` + +--- + +## RTL32b, RTL32b1 — deleteMessage sends MESSAGE ProtocolMessage with action MESSAGE_DELETE + +**Test ID**: `realtime/unit/RTL32b/delete-message-action-1` + +| Spec | Requirement | +|------|-------------| +| RTL32b | Send a `MESSAGE` `ProtocolMessage` containing a single `Message` | +| RTL32b1 | `action` set to `MESSAGE_DELETE` for `deleteMessage()` | + +Tests that `deleteMessage()` sends MESSAGE_DELETE. + +### Setup +```pseudo +channel_name = "test-RTL32-delete-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + captured_messages.append(msg) + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH + )) + ELSE IF msg.action == MESSAGE: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1, + res: { "serials": ["version-serial-1"] } + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +result = AWAIT channel.deleteMessage( + Message(serial: "msg-serial-1"), +) +``` + +### Assertions +```pseudo +message_pm = null +FOR pm IN captured_messages: + IF pm.action == MESSAGE: + message_pm = pm +ASSERT message_pm IS NOT null + +msg = message_pm.messages[0] +ASSERT msg.action == MessageAction.MESSAGE_DELETE # numeric: 2 +ASSERT msg.serial == "msg-serial-1" +CLOSE_CLIENT(client) +``` + +--- + +## RTL32b, RTL32b1 — appendMessage sends MESSAGE ProtocolMessage with action MESSAGE_APPEND + +**Test ID**: `realtime/unit/RTL32b/append-message-action-2` + +| Spec | Requirement | +|------|-------------| +| RTL32b | Send a `MESSAGE` `ProtocolMessage` containing a single `Message` | +| RTL32b1 | `action` set to `MESSAGE_APPEND` for `appendMessage()` | + +Tests that `appendMessage()` sends MESSAGE_APPEND. + +### Setup +```pseudo +channel_name = "test-RTL32-append-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + captured_messages.append(msg) + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH + )) + ELSE IF msg.action == MESSAGE: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1, + res: { "serials": ["version-serial-1"] } + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +result = AWAIT channel.appendMessage( + Message(serial: "msg-serial-1", data: "appended-data"), + operation: MessageOperation(description: "appended content") +) +``` + +### Assertions +```pseudo +message_pm = null +FOR pm IN captured_messages: + IF pm.action == MESSAGE: + message_pm = pm +ASSERT message_pm IS NOT null + +msg = message_pm.messages[0] +ASSERT msg.action == MessageAction.MESSAGE_APPEND # numeric: 5 +ASSERT msg.serial == "msg-serial-1" +ASSERT msg.data == "appended-data" +CLOSE_CLIENT(client) +``` + +--- + +## RTL32b2 — version field set from MessageOperation + +**Test ID**: `realtime/unit/RTL32b2/version-from-operation-0` + +**Spec requirement:** RTL32b2 — `version` set to the `MessageOperation` object if provided. + +Tests that the `version` field on the wire message is set to the MessageOperation when provided, and absent when not provided. + +### Setup +```pseudo +channel_name = "test-RTL32b2-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + captured_messages.append(msg) + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH + )) + ELSE IF msg.action == MESSAGE: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1, + res: { "serials": ["version-serial-1"] } + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +# With operation +AWAIT channel.updateMessage( + Message(serial: "msg-serial-1", data: "v2"), + operation: MessageOperation( + description: "edited content", + metadata: { "reason": "typo" } + ) +) + +# Without operation +AWAIT channel.updateMessage( + Message(serial: "msg-serial-2", data: "v2") +) +``` + +### Assertions +```pseudo +message_pms = [] +FOR pm IN captured_messages: + IF pm.action == MESSAGE: + message_pms.append(pm) +ASSERT message_pms.length == 2 + +# With operation: version field present +msg_with_op = message_pms[0].messages[0] +ASSERT msg_with_op.version IS NOT null +ASSERT msg_with_op.version.description == "edited content" +ASSERT msg_with_op.version.metadata["reason"] == "typo" + +# Without operation: version field absent +msg_without_op = message_pms[1].messages[0] +ASSERT msg_without_op.version IS null +CLOSE_CLIENT(client) +``` + +--- + +## RTL32c — does not mutate user-supplied Message + +**Test ID**: `realtime/unit/RTL32c/no-message-mutation-0` + +**Spec requirement:** RTL32c — The SDK must not mutate the user-supplied `Message` object. + +Tests that the original Message object is unchanged after calling updateMessage. + +### Setup +```pseudo +channel_name = "test-RTL32c-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH + )) + ELSE IF msg.action == MESSAGE: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1, + res: { "serials": ["version-serial-1"] } + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +original_message = Message(serial: "msg-serial-1", name: "original", data: "original-data") +AWAIT channel.updateMessage(original_message) +``` + +### Assertions +```pseudo +# Original message unchanged +ASSERT original_message.name == "original" +ASSERT original_message.data == "original-data" +ASSERT original_message.serial == "msg-serial-1" +ASSERT original_message.action IS null +CLOSE_CLIENT(client) +``` + +--- + +## RTL32d — returns UpdateDeleteResult from ACK + +**Test ID**: `realtime/unit/RTL32d/ack-returns-result-0` + +**Spec requirement:** RTL32d — On success, returns an `UpdateDeleteResult` object containing the version serial of the published update, obtained from the first element of the `serials` array of the `res` field of the `ACK`. + +Tests that the result is parsed from the ACK ProtocolMessage. + +### Setup +```pseudo +channel_name = "test-RTL32d-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH + )) + ELSE IF msg.action == MESSAGE: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1, + res: { "serials": ["01770000000000-000@abcdef:000"] } + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +result = AWAIT channel.updateMessage( + Message(serial: "msg-serial-1", data: "updated") +) +``` + +### Assertions +```pseudo +ASSERT result IS UpdateDeleteResult +ASSERT result.versionSerial == "01770000000000-000@abcdef:000" +CLOSE_CLIENT(client) +``` + +--- + +## RTL32d — NACK returns error + +**Test ID**: `realtime/unit/RTL32d/nack-returns-error-1` + +**Spec requirement:** RTL32d — Indicates an error if the operation was not successful. + +Tests that a NACK results in an error. + +### Setup +```pseudo +channel_name = "test-RTL32d-nack-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH + )) + ELSE IF msg.action == MESSAGE: + mock_ws.active_connection.send_to_client(NACK( + msgSerial: msg.msgSerial, + count: 1, + error: ErrorInfo(code: 40160, message: "Not permitted") + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +AWAIT channel.updateMessage( + Message(serial: "msg-serial-1", data: "updated") +) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40160 +CLOSE_CLIENT(client) +``` + +--- + +## RTL32e — params sent in ProtocolMessage.params + +**Test ID**: `realtime/unit/RTL32e/params-in-protocol-message-0` + +**Spec requirement:** RTL32e — Any params provided in the third argument must be sent in the `TR4q` `ProtocolMessage.params` field. + +Tests that optional params are forwarded in the ProtocolMessage. + +### Setup +```pseudo +channel_name = "test-RTL32e-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + captured_messages.append(msg) + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH + )) + ELSE IF msg.action == MESSAGE: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1, + res: { "serials": ["version-serial-1"] } + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +AWAIT channel.updateMessage( + Message(serial: "msg-serial-1", data: "v2"), + params: { "key1": "value1", "key2": "value2" } +) +``` + +### Assertions +```pseudo +message_pm = null +FOR pm IN captured_messages: + IF pm.action == MESSAGE: + message_pm = pm +ASSERT message_pm IS NOT null + +ASSERT message_pm.params["key1"] == "value1" +ASSERT message_pm.params["key2"] == "value2" +CLOSE_CLIENT(client) +``` + +--- + +## RTL32a — serial validation + +**Test ID**: `realtime/unit/RTL32a/serial-validation-required-0` + +**Spec requirement:** RTL32a — Takes a first argument of a `Message` object (which must contain a populated `serial` field). + +Tests that calling updateMessage/deleteMessage/appendMessage with a missing serial throws an error. Follows the same validation as RSL15a. + +### Setup +```pseudo +channel_name = "test-RTL32a-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps and Assertions +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +# Empty serial +AWAIT channel.updateMessage( + Message(serial: "", data: "v2") +) FAILS WITH error +ASSERT error.code == 40003 + +# Null serial (if applicable in language) +AWAIT channel.deleteMessage( + Message(data: "v2") +) FAILS WITH error +ASSERT error.code == 40003 +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/channels/channel_when_state_test.md b/uts/realtime/unit/channels/channel_when_state_test.md new file mode 100644 index 000000000..0bc0f0e71 --- /dev/null +++ b/uts/realtime/unit/channels/channel_when_state_test.md @@ -0,0 +1,326 @@ +# RealtimeChannel whenState Tests (RTL25) + +Spec points: `RTL25`, `RTL25a`, `RTL25b` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## Purpose + +`RealtimeChannel#whenState` is a convenience function for waiting on channel state: +- If the channel is already in the given state, it resolves immediately + with a `null` value (RTL25a). +- Otherwise, it waits for the given state to be reached, and resolves + with the `ChannelStateChange` when the state is reached (RTL25b). + +This mirrors the `Connection#whenState` function (RTN26). + +--- + +## RTL25a - whenState resolves immediately if already in state + +**Test ID**: `realtime/unit/RTL25a/resolves-immediately-current-0` + +**Spec requirement:** If the channel is already in the given state, resolves +immediately with a `null` value. + +Tests that whenState resolves immediately when the channel is already +in the target state. + +### Setup +```pseudo +channel_name = "test-RTL25a-immediate-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 0 + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Channel is now ATTACHED — call whenState for current state +result = AWAIT channel.whenState(ChannelState.attached) +``` + +### Assertions +```pseudo +# whenState resolved immediately with null (already in target state) +ASSERT result IS null +CLOSE_CLIENT(client) +``` + +--- + +## RTL25b - whenState waits for state if not already in it + +**Test ID**: `realtime/unit/RTL25b/waits-for-state-change-0` + +**Spec requirement:** If the channel is not in the given state, waits for the +state to be reached and resolves with the `ChannelStateChange`. + +Tests that whenState waits for a state transition when the channel is not currently +in the target state. + +### Setup +```pseudo +channel_name = "test-RTL25b-deferred-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 0 + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Channel is in INITIALIZED state — start waiting for ATTACHED +when_state_promise = channel.whenState(ChannelState.attached) + +# Attach the channel (this triggers the state transition) +AWAIT channel.attach() + +# Now await the whenState result +result = AWAIT when_state_promise +``` + +### Assertions +```pseudo +# whenState resolved with a ChannelStateChange object (not null) +ASSERT result IS NOT null +ASSERT result.current == ChannelState.attached +ASSERT result.previous IN [ChannelState.initialized, ChannelState.attaching] +CLOSE_CLIENT(client) +``` + +--- + +## RTL25b - whenState only fires once + +**Test ID**: `realtime/unit/RTL25b/fires-once-only-1` + +**Spec requirement:** whenState resolves only once, even if the state is entered +multiple times. Subsequent entries into the same state do not trigger additional +resolutions. + +Tests that the whenState resolution is one-shot even if the state is entered +multiple times. + +### Setup +```pseudo +channel_name = "test-RTL25b-once-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 0 + )) + IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Register a side-effect counter via a listener wrapping whenState +attach_count = 0 +channel.once(ChannelState.attached, () => { attach_count++ }) + +# Also start a whenState that we'll use to verify one-shot behavior +when_state_promise = channel.whenState(ChannelState.attached) + +# First attach +AWAIT channel.attach() +result = AWAIT when_state_promise +ASSERT result IS NOT null +ASSERT attach_count == 1 + +# Detach +AWAIT channel.detach() + +# Second attach — a new whenState should be needed; the old one is consumed +AWAIT channel.attach() +WAIT(50) +``` + +### Assertions +```pseudo +# The once listener only fired once (confirming one-shot semantics) +ASSERT attach_count == 1 +CLOSE_CLIENT(client) +``` + +--- + +## RTL25a - whenState for past state does not fire + +**Test ID**: `realtime/unit/RTL25a/past-state-does-not-resolve-1` + +**Spec requirement:** whenState checks the current state. If the channel has +already passed through a state but is no longer in it, whenState should NOT +resolve immediately. + +Tests that whenState for a state that was previously visited but is no longer +current does not resolve. + +### Setup +```pseudo +channel_name = "test-RTL25a-past-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 0 + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach — channel passes through ATTACHING to reach ATTACHED +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Now call whenState for ATTACHING — a past state, not the current one +resolved = false + +# Start whenState but do NOT await — check that it does not resolve +when_state_promise = channel.whenState(ChannelState.attaching) +when_state_promise.then(() => { resolved = true }) + +# Wait to see if it resolves +WAIT(200) +``` + +### Assertions +```pseudo +# whenState should NOT have resolved (we're not in ATTACHING state anymore) +ASSERT resolved == false +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/channels/channels_collection.md b/uts/realtime/unit/channels/channels_collection.md new file mode 100644 index 000000000..4a0b7abc1 --- /dev/null +++ b/uts/realtime/unit/channels/channels_collection.md @@ -0,0 +1,356 @@ +# RealtimeChannels Collection Tests + +Spec points: `RTS1`, `RTS2`, `RTS3a`, `RTS4a` + +## Test Type +Unit test - no network calls required + +These tests verify the channels collection management functionality. No mock infrastructure is needed as these tests focus on the in-memory collection behavior. + +--- + +## RTS1 - Channels collection accessible via RealtimeClient + +**Test ID**: `realtime/unit/RTS1/channels-collection-accessible-0` + +**Spec requirement:** `Channels` is a collection of `RealtimeChannel` objects accessible through `RealtimeClient#channels`. + +Tests that the Realtime client exposes a channels collection. + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +channels = client.channels +``` + +### Assertions +```pseudo +ASSERT channels IS RealtimeChannels +ASSERT channels IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RTS2 - Check if channel exists + +**Test ID**: `realtime/unit/RTS2/channel-exists-check-0` + +**Spec requirement:** Methods should exist to check if a channel exists or iterate through the existing channels. + +Tests the `exists()` method returns correct boolean for existing and non-existing channels. + +### Setup +```pseudo +channel_name = "test-RTS2-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Before creating any channel +exists_before = client.channels.exists(channel_name) + +# Create the channel +channel = client.channels.get(channel_name) + +# After creating the channel +exists_after = client.channels.exists(channel_name) + +# Check for non-existent channel +other_channel_name = "test-RTS2-other-${random_id()}" +exists_other = client.channels.exists(other_channel_name) +``` + +### Assertions +```pseudo +ASSERT exists_before == false +ASSERT exists_after == true +ASSERT exists_other == false +CLOSE_CLIENT(client) +``` + +--- + +## RTS2 - Iterate through existing channels + +**Test ID**: `realtime/unit/RTS2/iterate-channels-1` + +**Spec requirement:** Methods should exist to check if a channel exists or iterate through the existing channels. + +Tests that channel names can be iterated. + +### Setup +```pseudo +channel_name_a = "test-RTS2-a-${random_id()}" +channel_name_b = "test-RTS2-b-${random_id()}" +channel_name_c = "test-RTS2-c-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Create several channels +client.channels.get(channel_name_a) +client.channels.get(channel_name_b) +client.channels.get(channel_name_c) + +# Get all channel names +names = client.channels.names +``` + +### Assertions +```pseudo +ASSERT channel_name_a IN names +ASSERT channel_name_b IN names +ASSERT channel_name_c IN names +ASSERT length(names) == 3 +CLOSE_CLIENT(client) +``` + +--- + +## RTS3a - Get creates new channel if none exists + +**Test ID**: `realtime/unit/RTS3a/get-creates-new-channel-0` + +**Spec requirement:** Creates a new `RealtimeChannel` object for the specified channel if none exists, or returns the existing channel. + +Tests that `get()` creates a new channel when called with a new name. + +### Setup +```pseudo +channel_name = "test-RTS3a-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Get a channel that doesn't exist yet +channel = client.channels.get(channel_name) +``` + +### Assertions +```pseudo +ASSERT channel IS RealtimeChannel +ASSERT channel.name == channel_name +ASSERT client.channels.exists(channel_name) == true +CLOSE_CLIENT(client) +``` + +--- + +## RTS3a - Get returns existing channel + +**Test ID**: `realtime/unit/RTS3a/get-returns-existing-channel-1` + +**Spec requirement:** Creates a new `RealtimeChannel` object for the specified channel if none exists, or returns the existing channel. + +Tests that `get()` returns the same channel instance when called multiple times. + +### Setup +```pseudo +channel_name = "test-RTS3a-existing-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Get a channel +channel1 = client.channels.get(channel_name) + +# Get the same channel again +channel2 = client.channels.get(channel_name) +``` + +### Assertions +```pseudo +ASSERT channel1 IS SAME AS channel2 # Same object reference +ASSERT channel1.name == channel_name +ASSERT channel2.name == channel_name +CLOSE_CLIENT(client) +``` + +--- + +## RTS3a - Operator subscript creates or returns channel + +**Test ID**: `realtime/unit/RTS3a/subscript-operator-channel-2` + +**Spec requirement:** Creates a new `RealtimeChannel` object for the specified channel if none exists, or returns the existing channel. + +Tests that the subscript operator `[]` behaves the same as `get()`. + +### Setup +```pseudo +channel_name = "test-RTS3a-subscript-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Use subscript to get channel +channel1 = client.channels[channel_name] + +# Use get() to get same channel +channel2 = client.channels.get(channel_name) + +# Use subscript again +channel3 = client.channels[channel_name] +``` + +### Assertions +```pseudo +ASSERT channel1 IS SAME AS channel2 +ASSERT channel2 IS SAME AS channel3 +ASSERT channel1.name == channel_name +CLOSE_CLIENT(client) +``` + +--- + +## RTS4a - Release detaches and removes channel + +**Test ID**: `realtime/unit/RTS4a/release-removes-channel-0` + +**Spec requirement:** Detaches the channel and then releases the channel resource i.e. it's deleted and can then be garbage collected. + +Tests that `release()` removes the channel from the collection. + +### Setup +```pseudo +channel_name = "test-RTS4a-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Create a channel +channel = client.channels.get(channel_name) +ASSERT client.channels.exists(channel_name) == true + +# Release the channel +AWAIT client.channels.release(channel_name) +``` + +### Assertions +```pseudo +ASSERT client.channels.exists(channel_name) == false +CLOSE_CLIENT(client) +``` + +--- + +## RTS4a - Release on non-existent channel is no-op + +**Test ID**: `realtime/unit/RTS4a/release-nonexistent-noop-1` + +**Spec requirement:** Detaches the channel and then releases the channel resource. + +Tests that releasing a channel that doesn't exist completes without error. + +### Setup +```pseudo +channel_name = "test-RTS4a-nonexistent-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Release a channel that was never created +AWAIT client.channels.release(channel_name) +``` + +### Assertions +```pseudo +# Should complete without throwing +ASSERT client.channels.exists(channel_name) == false +CLOSE_CLIENT(client) +``` + +--- + +## RTS4a - Release calls detach on attached channel + +**Test ID**: `realtime/unit/RTS4a/release-detaches-attached-2` + +**Spec requirement:** Detaches the channel and then releases the channel resource. + +Tests that releasing an attached channel detaches it first. + +### Setup +```pseudo +channel_name = "test-RTS4a-attached-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +``` + +### Test Steps +```pseudo +# Create and attach a channel +channel = client.channels.get(channel_name) +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Capture the state before release +state_before_release = channel.state + +# Release the channel +AWAIT client.channels.release(channel_name) +``` + +### Assertions +```pseudo +ASSERT state_before_release == ChannelState.attached +ASSERT client.channels.exists(channel_name) == false +# Channel should have been detached before removal +CLOSE_CLIENT(client) +``` + +--- + +## RTS3a - Get after release creates new channel + +**Test ID**: `realtime/unit/RTS3a/get-after-release-new-3` + +**Spec requirement:** Creates a new `RealtimeChannel` object for the specified channel if none exists. + +Tests that getting a channel after release creates a fresh instance. + +### Setup +```pseudo +channel_name = "test-RTS3a-release-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Create a channel +channel1 = client.channels.get(channel_name) + +# Release it +AWAIT client.channels.release(channel_name) + +# Get the same channel name again +channel2 = client.channels.get(channel_name) +``` + +### Assertions +```pseudo +ASSERT channel1 IS NOT SAME AS channel2 # Different object instances +ASSERT channel2.name == channel_name +ASSERT client.channels.exists(channel_name) == true +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/channels/message_field_population.md b/uts/realtime/unit/channels/message_field_population.md new file mode 100644 index 000000000..e2f47d1b0 --- /dev/null +++ b/uts/realtime/unit/channels/message_field_population.md @@ -0,0 +1,564 @@ +# Message Field Population from ProtocolMessage + +Spec points: `TM2a`, `TM2c`, `TM2f` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## Purpose + +When a realtime client receives a ProtocolMessage containing messages, certain +fields on individual messages may be absent. The spec requires the SDK to populate +these from the encapsulating ProtocolMessage before delivering to subscribers: + +| Spec | Field | Fallback | +|------|-------|----------| +| TM2a | `id` | `protocolMsgId:index` (0-based index in messages array) | +| TM2c | `connectionId` | ProtocolMessage `connectionId` | +| TM2f | `timestamp` | ProtocolMessage `timestamp` | + +This is critical for correct operation of features that depend on message IDs +(e.g., vcdiff delta decoding RTL20 uses `id` for continuity checks) and for +providing complete message metadata to subscribers. + +These tests verify that the population happens before messages are delivered to +subscribers via `channel.subscribe()`. + +--- + +## TM2a - Message id populated from ProtocolMessage id and index + +**Test ID**: `realtime/unit/TM2a/id-from-protocol-message-0` + +**Spec requirement:** For messages received over Realtime, if the message does not +contain an `id`, it should be set to `protocolMsgId:index`, where `protocolMsgId` +is the id of the `ProtocolMessage` encapsulating it, and `index` is the index of +the message inside the `messages` array of the `ProtocolMessage`. + +Tests that messages without an `id` field receive a computed ID in the format +`protocolMessageId:arrayIndex` before being delivered to subscribers. + +### Setup +```pseudo +channel_name = "test-TM2a-id-${random_id()}" + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Send a ProtocolMessage with 3 messages that have no id field. +# The ProtocolMessage itself has id "connId:serial". +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "abc123:5", + connectionId: "abc123", + timestamp: 1700000000000, + messages: [ + { name: "first", data: "a" }, + { name: "second", data: "b" }, + { name: "third", data: "c" } + ] +)) + +AWAIT length(received_messages) == 3 +``` + +### Assertions +```pseudo +# Each message id is computed as protocolMessageId:index +ASSERT received_messages[0].id == "abc123:5:0" +ASSERT received_messages[1].id == "abc123:5:1" +ASSERT received_messages[2].id == "abc123:5:2" +CLOSE_CLIENT(client) +``` + +--- + +## TM2a - Message with existing id is not overwritten + +**Test ID**: `realtime/unit/TM2a/existing-id-not-overwritten-1` + +**Spec requirement:** The id should only be set if the message does not already +contain one. + +Tests that a message that already has an `id` field retains its original value. + +### Setup +```pseudo +channel_name = "test-TM2a-existing-${random_id()}" + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Message already has its own id — should not be overwritten +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "proto-id:0", + messages: [ + { id: "my-custom-id", name: "msg", data: "hello" } + ] +)) + +AWAIT length(received_messages) == 1 +``` + +### Assertions +```pseudo +ASSERT received_messages[0].id == "my-custom-id" +CLOSE_CLIENT(client) +``` + +--- + +## TM2a - No id when ProtocolMessage has no id + +**Test ID**: `realtime/unit/TM2a/no-id-without-protocol-id-2` + +**Spec requirement:** The id derivation only applies when the ProtocolMessage has +an `id` field. If the ProtocolMessage has no `id`, messages without their own `id` +should remain without one. + +Tests that messages are not assigned a computed id when the ProtocolMessage itself +lacks an `id` field. + +### Setup +```pseudo +channel_name = "test-TM2a-no-proto-id-${random_id()}" + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# ProtocolMessage has no id field — messages should not get computed ids +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + connectionId: "abc123", + messages: [ + { name: "msg", data: "hello" } + ] +)) + +AWAIT length(received_messages) == 1 +``` + +### Assertions +```pseudo +ASSERT received_messages[0].id IS null +CLOSE_CLIENT(client) +``` + +--- + +## TM2c - Message connectionId populated from ProtocolMessage + +**Test ID**: `realtime/unit/TM2c/connectionid-from-protocol-0` + +**Spec requirement:** If a message received from Ably does not contain a +`connectionId`, it should be set to the `connectionId` of the encapsulating +`ProtocolMessage`. + +Tests that messages without a `connectionId` field inherit the value from the +ProtocolMessage before being delivered to subscribers. + +### Setup +```pseudo +channel_name = "test-TM2c-connId-${random_id()}" + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Message has no connectionId — should inherit from ProtocolMessage +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg:0", + connectionId: "server-conn-xyz", + messages: [ + { name: "msg", data: "hello" } + ] +)) + +AWAIT length(received_messages) == 1 +``` + +### Assertions +```pseudo +ASSERT received_messages[0].connectionId == "server-conn-xyz" +CLOSE_CLIENT(client) +``` + +--- + +## TM2c - Message with existing connectionId is not overwritten + +**Test ID**: `realtime/unit/TM2c/existing-connectionid-kept-1` + +**Spec requirement:** The connectionId should only be set if the message does not +already contain one. + +Tests that a message that already has a `connectionId` retains its original value. + +### Setup +```pseudo +channel_name = "test-TM2c-existing-${random_id()}" + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Message already has its own connectionId — should not be overwritten +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg:0", + connectionId: "proto-conn", + messages: [ + { connectionId: "msg-conn", name: "msg", data: "hello" } + ] +)) + +AWAIT length(received_messages) == 1 +``` + +### Assertions +```pseudo +ASSERT received_messages[0].connectionId == "msg-conn" +CLOSE_CLIENT(client) +``` + +--- + +## TM2f - Message timestamp populated from ProtocolMessage + +**Test ID**: `realtime/unit/TM2f/timestamp-from-protocol-0` + +**Spec requirement:** If a message received from Ably over a realtime transport does +not contain a `timestamp`, the SDK must set it to the `timestamp` of the +encapsulating `ProtocolMessage`. + +Tests that messages without a `timestamp` field inherit the value from the +ProtocolMessage before being delivered to subscribers. + +### Setup +```pseudo +channel_name = "test-TM2f-timestamp-${random_id()}" + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Message has no timestamp — should inherit from ProtocolMessage +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg:0", + timestamp: 1700000000000, + messages: [ + { name: "msg", data: "hello" } + ] +)) + +AWAIT length(received_messages) == 1 +``` + +### Assertions +```pseudo +ASSERT received_messages[0].timestamp == 1700000000000 +CLOSE_CLIENT(client) +``` + +--- + +## TM2f - Message with existing timestamp is not overwritten + +**Test ID**: `realtime/unit/TM2f/existing-timestamp-kept-1` + +**Spec requirement:** The timestamp should only be set if the message does not +already contain one. + +Tests that a message that already has a `timestamp` retains its original value. + +### Setup +```pseudo +channel_name = "test-TM2f-existing-${random_id()}" + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Message already has its own timestamp — should not be overwritten +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg:0", + timestamp: 1700000000000, + messages: [ + { timestamp: 1600000000000, name: "msg", data: "hello" } + ] +)) + +AWAIT length(received_messages) == 1 +``` + +### Assertions +```pseudo +ASSERT received_messages[0].timestamp == 1600000000000 +CLOSE_CLIENT(client) +``` + +--- + +## TM2a, TM2c, TM2f - All fields populated together + +**Test ID**: `realtime/unit/TM2a/all-fields-populated-together-3` + +**Spec requirement:** All three fields (id, connectionId, timestamp) should be +populated from the ProtocolMessage when absent from the message. + +Tests that all three fields are populated in a single ProtocolMessage containing +multiple messages, with correct per-message index for the id field. + +### Setup +```pseudo +channel_name = "test-TM2-all-fields-${random_id()}" + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# ProtocolMessage with all parent fields set, messages with none +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "connId:7", + connectionId: "connId", + timestamp: 1700000000000, + messages: [ + { name: "first", data: "a" }, + { name: "second", data: "b" } + ] +)) + +AWAIT length(received_messages) == 2 +``` + +### Assertions +```pseudo +# First message +ASSERT received_messages[0].id == "connId:7:0" +ASSERT received_messages[0].connectionId == "connId" +ASSERT received_messages[0].timestamp == 1700000000000 +ASSERT received_messages[0].name == "first" +ASSERT received_messages[0].data == "a" + +# Second message — same connectionId and timestamp, different id index +ASSERT received_messages[1].id == "connId:7:1" +ASSERT received_messages[1].connectionId == "connId" +ASSERT received_messages[1].timestamp == 1700000000000 +ASSERT received_messages[1].name == "second" +ASSERT received_messages[1].data == "b" +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/client/realtime_client.md b/uts/realtime/unit/client/realtime_client.md new file mode 100644 index 000000000..b297dc651 --- /dev/null +++ b/uts/realtime/unit/client/realtime_client.md @@ -0,0 +1,727 @@ +# Realtime Client Tests + +Spec points: `RTC1`, `RTC1a`, `RTC1b`, `RTC1c`, `RTC1f`, `RTC2`, `RTC3`, `RTC4`, `RTC12`, `RTC13`, `RTC15`, `RTC16`, `RTC17` + +## Test Type +Unit test with mocked WebSocket connection + +## Pseudocode Conventions + +### Type Assertions + +Type assertions in pseudocode (e.g., `ASSERT client.connection IS Connection`) verify that an object has the expected type or interface. Implementation varies by language: + +- **Strongly typed languages** (Dart, Swift, Kotlin, TypeScript): Use native type checks or casting verification +- **Weakly typed languages** (JavaScript, Python, Ruby): Verify the object has the expected methods/properties instead of checking type directly + +**Example:** +```pseudo +# Pseudocode +ASSERT client.connection IS Connection + +# JavaScript implementation +assert(typeof client.connection.connect === 'function'); +assert(typeof client.connection.close === 'function'); +assert(typeof client.connection.state === 'string'); + +# Dart implementation +expect(client.connection, isA()); +``` + +For weakly typed languages, verify the object behaves as the expected interface rather than checking its type name. + +### State Transitions + +State transitions may be synchronous or asynchronous depending on the implementation. Use `AWAIT_STATE` to indicate waiting for a state to reach an expected value: + +```pseudo +# Pseudocode +AWAIT_STATE client.connection.state == ConnectionState.connecting +``` + +This means: if the state is already `connecting`, proceed immediately; otherwise, wait for a state change event until it reaches `connecting`. Implementations should use appropriate timeout values to prevent tests hanging indefinitely. + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTC12 - Constructor String Argument Detection + +**Test ID**: `realtime/unit/RTC12/constructor-string-detection-0` + +**Spec requirement:** The Realtime constructor must accept a string argument and detect whether it's an API key (contains `:`) or token (no `:`), matching REST client behavior. + +The Realtime client has the same constructors as the REST client. + +**See:** `uts/test/realtime/unit/client/client_options.md` - RSC1, RSC1a, RSC1c + +The same test cases apply: +- API key string (`"appId.keyId:keySecret"`) -> Basic auth +- Token string (no `:` delimiter) -> Token auth +- Empty string -> Error + +--- + +## RTC12 - Invalid Arguments Error + +**Test ID**: `realtime/unit/RTC12/invalid-arguments-error-1` + +**Spec requirement:** Error code 40106 must be raised when no valid credentials are provided, matching REST client behavior. + +The Realtime client has the same error handling as the REST client for invalid credentials. + +**See:** `uts/test/realtime/unit/client/client_options.md` - RSC1b + +Error code 40106 should be raised when no valid credentials are provided. + +--- + +## RTC2 - Connection Attribute + +**Test ID**: `realtime/unit/RTC2/connection-attribute-0` + +**Spec requirement:** The Realtime client must expose a `connection` property that provides access to the Connection object. + +Tests that `RealtimeClient#connection` provides access to the underlying Connection object. + +### Setup +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +# Create client with autoConnect: false to avoid immediate connection +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Assertions +```pseudo +ASSERT client.connection IS NOT null +ASSERT client.connection IS Connection +ASSERT client.connection.state == ConnectionState.initialized +CLOSE_CLIENT(client) +``` + +--- + +## RTC3 - Channels Attribute + +**Test ID**: `realtime/unit/RTC3/channels-attribute-0` + +**Spec requirement:** The Realtime client must expose a `channels` property that provides access to the Channels collection. + +Tests that `RealtimeClient#channels` provides access to the Channels collection. + +### Setup +```pseudo +channel_name = "test-RTC3-${random_id()}" + +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Assertions +```pseudo +ASSERT client.channels IS NOT null +ASSERT client.channels IS Channels + +# Should be able to get/create channels +channel = client.channels.get(channel_name) +ASSERT channel IS RealtimeChannel +ASSERT channel.name == channel_name +CLOSE_CLIENT(client) +``` + +--- + +## RTC4 - Auth Attribute + +**Test ID**: `realtime/unit/RTC4/auth-attribute-0` + +**Spec requirement:** The Realtime client must expose an `auth` property that provides access to the Auth object. + +Tests that `RealtimeClient#auth` provides access to the Auth object. + +### Setup +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Assertions +```pseudo +ASSERT client.auth IS NOT null +ASSERT client.auth IS Auth +CLOSE_CLIENT(client) +``` + +--- + +## RTC13 - Push Attribute + +**Test ID**: `realtime/unit/RTC13/push-attribute-0` + +**Spec requirement:** RTC13 — `RealtimeClient#push` attribute provides access to the `Push` object. + +Tests that `RealtimeClient#push` provides access to the Push object. + +### Setup +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Assertions +```pseudo +ASSERT client.push IS NOT null +ASSERT client.push IS Push +ASSERT client.push.admin IS PushAdmin +CLOSE_CLIENT(client) +``` + +--- + +## RTC17 - ClientId Attribute + +**Test ID**: `realtime/unit/RTC17/client-id-attribute-0` + +**Spec requirement:** The Realtime client must expose a `clientId` property that returns the clientId from the auth object. + +Tests that `RealtimeClient#clientId` returns the clientId from the auth object. + +### RTC17a - Returns auth clientId + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + clientId: "explicit-client-id", + autoConnect: false +)) + +ASSERT client.clientId == "explicit-client-id" +ASSERT client.clientId == client.auth.clientId +CLOSE_CLIENT(client) +``` + +--- + +## RTC1a - echoMessages Option + +**Test ID**: `realtime/unit/RTC1a/echo-messages-option-0` + +**Spec requirement:** The `echoMessages` option (default true) controls whether messages published by this client are echoed back on subscriptions. Sent as `echo` query parameter. + +Tests the `echoMessages` option which controls whether messages from this connection are echoed back. + +### RTC1a_1 - echoMessages defaults to true + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +# Wait for connection attempt +pending = AWAIT mock_ws.await_connection_attempt() + +# Check the connection URL query parameters +ASSERT pending.url.query_params["echo"] == "true" +CLOSE_CLIENT(client) +``` + +### RTC1a_2 - echoMessages set to false + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + echoMessages: false +)) + +# Wait for connection attempt +pending = AWAIT mock_ws.await_connection_attempt() + +# Check the connection URL query parameters +ASSERT pending.url.query_params["echo"] == "false" +CLOSE_CLIENT(client) +``` + +--- + +## RTC1b - autoConnect Option + +**Test ID**: `realtime/unit/RTC1b/auto-connect-option-0` + +**Spec requirement:** The `autoConnect` option (default true) controls whether the client automatically connects on instantiation or waits for explicit `connect()` call. + +Tests the `autoConnect` option which controls automatic connection on instantiation. + +### RTC1b_1 - autoConnect defaults to true + +```pseudo +mock_ws = create_mock_websocket() +mock_ws.on_connect(respond_with: CONNECTED_MESSAGE) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +# Should immediately attempt connection (state may be connecting or already connected) +AWAIT_STATE client.connection.state == ConnectionState.connecting OR + client.connection.state == ConnectionState.connected + +# Wait for connection +AWAIT client.connection.once(ConnectionEvent.connected) + +ASSERT mock_ws.connect_attempts.length >= 1 +CLOSE_CLIENT(client) +``` + +### RTC1b_2 - autoConnect set to false + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +# Should NOT attempt connection +ASSERT client.connection.state == ConnectionState.initialized +ASSERT mock_ws.connect_attempts.length == 0 + +# Should remain in initialized state until explicit connect +WAIT 100ms +ASSERT client.connection.state == ConnectionState.initialized +ASSERT mock_ws.connect_attempts.length == 0 +CLOSE_CLIENT(client) +``` + +### RTC1b_3 - Explicit connect after autoConnect false + +```pseudo +mock_ws = create_mock_websocket() +mock_ws.on_connect(respond_with: CONNECTED_MESSAGE) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +ASSERT client.connection.state == ConnectionState.initialized + +# Explicit connect +client.connection.connect() + +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Wait for connection +AWAIT client.connection.once(ConnectionEvent.connected) + +ASSERT mock_ws.events.filter(type: CONNECTION_ATTEMPT).length == 1 +AWAIT_STATE client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) +``` + +--- + +## RTC1c - recover Option + +**Test ID**: `realtime/unit/RTC1c/recover-option-0` + +**Spec requirement:** The `recover` option accepts a recovery key to resume a previous connection's state. The connection key is sent as the `recover` query parameter and is used only for the initial connection attempt. + +Tests the `recover` option for connection state recovery. + +### RTC1c_1 - recover string sent in connection request + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +recovery_key = encode_recovery_key({ + connectionKey: "previous-connection-key", + msgSerial: 5, + channelSerials: { "channel1": "serial1" } +}) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + recover: recovery_key +)) + +# Wait for connection attempt +pending = AWAIT mock_ws.await_connection_attempt() + +# Check the connection URL query parameters +ASSERT pending.url.query_params["recover"] == "previous-connection-key" +CLOSE_CLIENT(client) +``` + +### RTC1c_2 - recover option cleared after connection attempt (RTN16k) + +```pseudo +mock_ws = create_mock_websocket() +mock_ws.on_connect(respond_with: CONNECTED_MESSAGE) +install_mock(mock_ws) + +recovery_key = encode_recovery_key({ + connectionKey: "previous-connection-key", + msgSerial: 5, + channelSerials: {} +}) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + recover: recovery_key +)) + +# Wait for connection +AWAIT client.connection.once(ConnectionEvent.connected) + +# Simulate disconnect and reconnect +mock_ws.simulate_disconnect() +AWAIT client.connection.once(ConnectionEvent.disconnected) + +mock_ws.on_connect(respond_with: CONNECTED_MESSAGE) +AWAIT client.connection.once(ConnectionEvent.connected) + +# Second connection should NOT include recover parameter +# (RTN16k - recover is used only for initial connection) +second_connect_url = mock_ws.connect_attempts[1].url +ASSERT "recover" NOT IN second_connect_url.query_params +CLOSE_CLIENT(client) +``` + +### RTC1c_3 - Invalid recovery key handled gracefully + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + recover: "invalid-not-a-valid-recovery-key" +)) + +# Wait for connection attempt (recovery key decoding failure is logged, not fatal) +pending = AWAIT mock_ws.await_connection_attempt() + +# Connection should proceed without recover parameter +ASSERT "recover" NOT IN pending.url.query_params +CLOSE_CLIENT(client) +``` + +--- + +## RTC1f - transportParams Option + +**Test ID**: `realtime/unit/RTC1f/transport-params-option-0` + +| Spec | Requirement | +|------|-------------| +| RTC1f | Custom query parameters can be added via `transportParams` | +| RTC1f1 | User-specified transportParams override library defaults | + +Tests the `transportParams` option for additional WebSocket query parameters. + +### RTC1f_1 - transportParams included in connection URL + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + transportParams: { + "customParam": "customValue", + "anotherParam": "123" + } +)) + +# Wait for connection attempt +pending = AWAIT mock_ws.await_connection_attempt() + +# Check the connection URL query parameters +ASSERT pending.url.query_params["customParam"] == "customValue" +ASSERT pending.url.query_params["anotherParam"] == "123" +CLOSE_CLIENT(client) +``` + +### RTC1f_2 - transportParams with different value types (Stringifiable) + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + transportParams: { + "stringParam": "hello", + "numberParam": 42, + "boolTrueParam": true, + "boolFalseParam": false + } +)) + +# Wait for connection attempt +pending = AWAIT mock_ws.await_connection_attempt() + +# Check stringification of values (RTC1f) +ASSERT pending.url.query_params["stringParam"] == "hello" +ASSERT pending.url.query_params["numberParam"] == "42" +ASSERT pending.url.query_params["boolTrueParam"] == "true" +ASSERT pending.url.query_params["boolFalseParam"] == "false" +CLOSE_CLIENT(client) +``` + +### RTC1f1 - transportParams override library defaults + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + transportParams: { + "v": "3", # Override protocol version + "heartbeats": "false" # Override heartbeats + } +)) + +# Wait for connection attempt +pending = AWAIT mock_ws.await_connection_attempt() + +# User-specified values should override defaults +ASSERT pending.url.query_params["v"] == "3" +ASSERT pending.url.query_params["heartbeats"] == "false" +CLOSE_CLIENT(client) +``` + +--- + +## RTC15 - connect() Method + +**Test ID**: `realtime/unit/RTC15/connect-method-0` + +**Spec requirement:** The Realtime client must provide a `connect()` method that calls `Connection#connect()`. + +Tests the `RealtimeClient#connect` method. + +### RTC15a - connect() calls Connection#connect + +```pseudo +mock_ws = create_mock_websocket() +mock_ws.on_connect(respond_with: CONNECTED_MESSAGE) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +ASSERT client.connection.state == ConnectionState.initialized + +# Call connect on client (should proxy to connection) +client.connect() + +AWAIT_STATE client.connection.state == ConnectionState.connecting + +AWAIT client.connection.once(ConnectionEvent.connected) +AWAIT_STATE client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) +``` + +--- + +## RTC16 - close() Method + +**Test ID**: `realtime/unit/RTC16/close-method-0` + +**Spec requirement:** The Realtime client must provide a `close()` method that calls `Connection#close()`. + +Tests the `RealtimeClient#close` method. + +### RTC16a - close() calls Connection#close + +```pseudo +mock_ws = create_mock_websocket() +mock_ws.on_connect(respond_with: CONNECTED_MESSAGE) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +# Wait for connection +AWAIT client.connection.once(ConnectionEvent.connected) +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Configure mock to respond to CLOSE with CLOSED +mock_ws.on_message(action: CLOSE, respond_with: CLOSED_MESSAGE) + +# Call close on client (should proxy to connection) +client.close() + +AWAIT_STATE client.connection.state == ConnectionState.closing OR + client.connection.state == ConnectionState.closed + +AWAIT client.connection.once(ConnectionEvent.closed) +AWAIT_STATE client.connection.state == ConnectionState.closed +CLOSE_CLIENT(client) +``` + +--- + +## Shared Options (Reference to REST Client Tests) + +The following options are shared with the REST client and should behave identically: + +| Option | REST Spec | Test File | +|--------|-----------|-----------| +| `key` | RSC1, RSC1a | `uts/test/realtime/unit/client/client_options.md` | +| `token` / `tokenDetails` | RSC1c | `uts/test/realtime/unit/client/client_options.md` | +| `authCallback` / `authUrl` | RSA8 | `unit/auth/auth_callback.md` | +| `clientId` | RSA7, RSC17 | `unit/auth/client_id.md` | +| `tls` | RSC18 | `uts/test/rest/unit/rest_client.md` | +| `environment` / `endpoint` | RSC15e, REC1 | `unit/client/fallback.md` | +| `restHost` / `realtimeHost` | RSC12, TO3k2, TO3k3 | `unit/client/fallback.md` | +| `fallbackHosts` | RSC15 | `unit/client/fallback.md` | +| `useBinaryProtocol` | RSC8, TO3f | `uts/test/rest/unit/rest_client.md` | +| `logLevel` / `logHandler` | TO3b, TO3c | (not yet specified) | + +### Realtime-Specific Verification for Shared Options + +For shared options that affect the WebSocket connection, verify the behavior in the Realtime context: + +#### TLS Setting (RSC18) in Realtime + +```pseudo +mock_ws = create_mock_websocket() +mock_ws.on_connect(respond_with: CONNECTED_MESSAGE) +install_mock(mock_ws) + +FOR EACH tls_setting IN [true, false]: + mock_ws.reset() + + # Note: Basic auth requires TLS, so use token auth for tls: false + IF tls_setting: + client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + tls: true + )) + ELSE: + client = Realtime(options: ClientOptions( + token: "test-token", + tls: false + )) + + AWAIT client.connection.once(ConnectionEvent.connected) + + connect_url = mock_ws.last_connect_url + IF tls_setting: + ASSERT connect_url.scheme == "wss" + ELSE: + ASSERT connect_url.scheme == "ws" + + client.close() +``` + +#### useBinaryProtocol in Realtime + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +FOR EACH use_binary IN [true, false]: + mock_ws.reset() + + client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: use_binary + )) + + pending = AWAIT mock_ws.await_connection_attempt() + + IF use_binary: + ASSERT pending.url.query_params["format"] == "msgpack" + ELSE: + ASSERT pending.url.query_params["format"] == "json" + + client.close() +``` + +--- + +## Connection URL Query Parameters + +Tests that the connection URL includes all required query parameters. + +### Standard Query Parameters + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +pending = AWAIT mock_ws.await_connection_attempt() + +# Required parameters +ASSERT "v" IN pending.url.query_params # Protocol version +ASSERT "format" IN pending.url.query_params # msgpack or json +ASSERT "heartbeats" IN pending.url.query_params # RTN23b +ASSERT "echo" IN pending.url.query_params + +# Auth parameters (one of these depending on auth method) +ASSERT ("key" IN pending.url.query_params) OR + ("accessToken" IN pending.url.query_params) +CLOSE_CLIENT(client) +``` + +--- + +## Test Infrastructure Notes + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for mock installation, test isolation, and timer mocking guidance. + +### Channel Naming + +Tests that use channels should use uniquely-named channels to avoid: +- Collisions between concurrent tests +- Server-side side-effects from previous test runs +- State leakage between test cases + +Use generated unique identifiers (UUIDs, timestamps, or test-framework-provided unique names) for channel names rather than fixed strings like "test-channel". diff --git a/uts/realtime/unit/client/realtime_request.md b/uts/realtime/unit/client/realtime_request.md new file mode 100644 index 000000000..e61292286 --- /dev/null +++ b/uts/realtime/unit/client/realtime_request.md @@ -0,0 +1,16 @@ +# RealtimeClient Request Tests + +Spec points: `RTC9` + +## Test Type +Unit test with mocked HTTP client + +--- + +## RTC9 - RealtimeClient#request proxies to RestClient#request + +**Test ID**: `realtime/unit/RTC9/request-proxies-rest-0` + +**Spec requirement:** `RealtimeClient#request` is a wrapper around `RestClient#request` (see RSC19) delivered in an idiomatic way for the realtime library. + +`RealtimeClient#request` is a direct proxy to `RestClient#request`. The tests in `uts/test/rest/unit/request.md` (covering RSC19) should be used to test a `RealtimeClient` instance in place of a `RestClient` instance. All the same behaviour, parameters, and return types apply. diff --git a/uts/realtime/unit/client/realtime_stats.md b/uts/realtime/unit/client/realtime_stats.md new file mode 100644 index 000000000..ec7185aec --- /dev/null +++ b/uts/realtime/unit/client/realtime_stats.md @@ -0,0 +1,19 @@ +# RealtimeClient Stats Tests + +Spec points: `RTC5`, `RTC5a`, `RTC5b` + +## Test Type +Unit test with mocked HTTP client + +--- + +## RTC5 - RealtimeClient#stats proxies to RestClient#stats + +**Test ID**: `realtime/unit/RTC5/stats-proxies-rest-0` + +| Spec | Requirement | +|------|-------------| +| RTC5a | Proxy to `RestClient#stats` presented with an async or threaded interface as appropriate | +| RTC5b | Accepts all the same params as `RestClient#stats` and provides all the same functionality | + +`RealtimeClient#stats` is a direct proxy to `RestClient#stats`. The tests in `uts/test/rest/unit/stats.md` (covering RSC6) should be used to test a `RealtimeClient` instance in place of a `RestClient` instance. All the same behaviour, parameters, and return types apply. diff --git a/uts/realtime/unit/client/realtime_time.md b/uts/realtime/unit/client/realtime_time.md new file mode 100644 index 000000000..a13dfb6b6 --- /dev/null +++ b/uts/realtime/unit/client/realtime_time.md @@ -0,0 +1,18 @@ +# RealtimeClient Time Tests + +Spec points: `RTC6`, `RTC6a` + +## Test Type +Unit test with mocked HTTP client + +--- + +## RTC6 - RealtimeClient#time proxies to RestClient#time + +**Test ID**: `realtime/unit/RTC6/time-proxies-rest-0` + +| Spec | Requirement | +|------|-------------| +| RTC6a | Proxy to `RestClient#time` presented with an async or threaded interface as appropriate | + +`RealtimeClient#time` is a direct proxy to `RestClient#time`. The tests in `uts/test/rest/unit/time.md` (covering RSC16) should be used to test a `RealtimeClient` instance in place of a `RestClient` instance. All the same behaviour, parameters, and return types apply. diff --git a/uts/realtime/unit/client/realtime_timeouts.md b/uts/realtime/unit/client/realtime_timeouts.md new file mode 100644 index 000000000..ac9fa6b52 --- /dev/null +++ b/uts/realtime/unit/client/realtime_timeouts.md @@ -0,0 +1,300 @@ +# Realtime Client Configured Timeouts + +Spec points: `RTC7` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## Purpose + +The realtime client must use the configured timeouts specified in `ClientOptions`, +falling back to client library defaults. This file tests that custom timeout values +are correctly applied to realtime operations. + +Default timeout values (from spec): +- `realtimeRequestTimeout`: 10,000 ms (TO3l11) — used for CONNECT, ATTACH, DETACH, HEARTBEAT +- `disconnectedRetryTimeout`: 15,000 ms (TO3l1) — delay before reconnecting from DISCONNECTED +- `suspendedRetryTimeout`: 30,000 ms (TO3l2) — delay before reconnecting from SUSPENDED + +--- + +## RTC7 - realtimeRequestTimeout applied to channel attach + +**Test ID**: `realtime/unit/RTC7/attach-request-timeout-0` + +**Spec requirement:** The client library must use the configured timeouts specified +in the ClientOptions. + +Tests that a custom `realtimeRequestTimeout` is applied to channel attach operations. +When the server does not respond to ATTACH within the timeout, the operation should fail. + +### Setup +```pseudo +channel_name = "test-RTC7-attach-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Do NOT respond — simulate timeout + PASS + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 500 +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach — will not get a response +attach_future = channel.attach() + +# Advance past the custom timeout +ADVANCE_TIME(600) + +# Attach should fail +AWAIT attach_future FAILS WITH error +``` + +### Assertions +```pseudo +# The timeout used the custom value (500ms), not the default (10000ms) +ASSERT error IS NOT null +# Channel should be in SUSPENDED state (RTL4f: attach timeout → SUSPENDED) +ASSERT channel.state == ChannelState.suspended +CLOSE_CLIENT(client) +``` + +--- + +## RTC7 - realtimeRequestTimeout applied to channel detach + +**Test ID**: `realtime/unit/RTC7/detach-request-timeout-1` + +**Spec requirement:** The client library must use the configured timeouts specified +in the ClientOptions. + +Tests that a custom `realtimeRequestTimeout` is applied to channel detach operations. + +### Setup +```pseudo +channel_name = "test-RTC7-detach-${random_id()}" +ignore_detach = false + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 0 + )) + IF msg.action == DETACH AND ignore_detach: + # Do NOT respond — simulate timeout + PASS + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 500 +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Now ignore DETACH messages +ignore_detach = true + +# Start detach — will not get a response +detach_future = channel.detach() + +# Advance past the custom timeout +ADVANCE_TIME(600) + +# Detach should fail +AWAIT detach_future FAILS WITH error +``` + +### Assertions +```pseudo +# The timeout used the custom value (500ms), not the default (10000ms) +ASSERT error IS NOT null +# Channel should still be in ATTACHED state (RTL5f: detach timeout → back to ATTACHED) +ASSERT channel.state == ChannelState.attached +CLOSE_CLIENT(client) +``` + +--- + +## RTC7 - disconnectedRetryTimeout controls reconnection delay + +**Test ID**: `realtime/unit/RTC7/disconnected-retry-timeout-2` + +**Spec requirement:** The client library must use the configured timeouts specified +in the ClientOptions. + +Tests that a custom `disconnectedRetryTimeout` controls the delay before reconnection +after the connection is lost. + +Note: Per RTN15a, when a previously-CONNECTED client disconnects, the first +reconnection attempt is immediate (no delay). This immediate retry must be +accounted for. We make all retries after the initial connection fail, and +disable fallback hosts so SocketException errors don't trigger fallback host +iteration. A mock HTTP client is used to avoid real network requests from +the connectivity checker (RTN17j). + +### Setup +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + IF connection_attempt_count == 1: + # Initial connection succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 0, + connectionStateTtl: 120000 + ) + )) + ELSE: + # All subsequent attempts fail + conn.respond_with_refused() + } +) +install_mock(mock_ws) + +mock_http = MockHttpClient( + onRequest: (req) => req.respond_with(200, "yes") +) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + disconnectedRetryTimeout: 2000, + fallbackHosts: [] +), httpClient: mock_http) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +ASSERT connection_attempt_count == 1 + +# Force disconnection — triggers RTN15a immediate retry (which fails), +# then schedules timer-based retry using disconnectedRetryTimeout +mock_ws.active_connection.close() + +# Wait for the immediate retry to fail and state to return to DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Record attempts after the immediate retry cycle +count_after_immediate = connection_attempt_count + +# Advance time by less than the custom timeout — no new retry yet +ADVANCE_TIME(1500) +ASSERT connection_attempt_count == count_after_immediate + +# Advance past the custom timeout (2000ms + jitter margin) +ADVANCE_TIME(1500) +``` + +### Assertions +```pseudo +# A new reconnection attempt was made after the custom delay +ASSERT connection_attempt_count > count_after_immediate +CLOSE_CLIENT(client) +``` + +--- + +## RTC7 - default timeouts applied when not configured + +**Test ID**: `realtime/unit/RTC7/default-timeouts-applied-3` + +**Spec requirement:** The client library must use the configured timeouts specified +in the ClientOptions, falling back to the client library defaults. + +Tests that default timeout values are used when no custom values are specified. + +### Setup +```pseudo +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Assertions +```pseudo +# Default values per spec (TO3l*) +ASSERT client.options.realtimeRequestTimeout == 10000 +ASSERT client.options.disconnectedRetryTimeout == 15000 +ASSERT client.options.suspendedRetryTimeout == 30000 +ASSERT client.options.httpOpenTimeout == 4000 +ASSERT client.options.httpRequestTimeout == 10000 +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/connection/auto_connect_test.md b/uts/realtime/unit/connection/auto_connect_test.md new file mode 100644 index 000000000..6dee11512 --- /dev/null +++ b/uts/realtime/unit/connection/auto_connect_test.md @@ -0,0 +1,190 @@ +# Connection Auto Connect Tests (RTN3) + +Spec points: `RTN3` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## Purpose + +When the `autoConnect` option is true (the default), a connection should be +initiated immediately when the Realtime client is created. When false, no +connection should be made until `connect()` is explicitly called. + +--- + +## RTN3 - autoConnect true initiates connection immediately + +**Test ID**: `realtime/unit/RTN3/auto-connect-true-0` + +**Spec requirement:** If connection option `autoConnect` is true, a connection is +initiated immediately. + +Tests that creating a Realtime client with `autoConnect: true` (or default) +initiates a WebSocket connection without requiring an explicit `connect()` call. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) +``` + +### Test Steps +```pseudo +# Create client with default autoConnect (true) — do NOT call connect() +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +# Wait for connection to complete +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions +```pseudo +# Connection was established automatically +ASSERT client.connection.state == ConnectionState.connected +ASSERT client.connection.id == "connection-id" +CLOSE_CLIENT(client) +``` + +--- + +## RTN3 - autoConnect false does not initiate connection + +**Test ID**: `realtime/unit/RTN3/auto-connect-false-1` + +**Spec requirement:** Otherwise a connection is only initiated following an explicit +call to `connect()`. + +Tests that creating a Realtime client with `autoConnect: false` does not initiate +a WebSocket connection. + +### Setup +```pseudo +connection_attempted = false + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempted = true + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) +``` + +### Test Steps +```pseudo +# Create client with autoConnect: false +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +# Wait briefly to confirm no connection attempt is made +WAIT(500) +``` + +### Assertions +```pseudo +# No connection was attempted +ASSERT connection_attempted == false + +# State remains INITIALIZED +ASSERT client.connection.state == ConnectionState.initialized +CLOSE_CLIENT(client) +``` + +--- + +## RTN3 - explicit connect after autoConnect false + +**Test ID**: `realtime/unit/RTN3/explicit-connect-after-false-2` + +**Spec requirement:** A connection is only initiated following an explicit call to +`connect()`. + +Tests that after creating a client with `autoConnect: false`, calling `connect()` +initiates the connection. + +### Setup +```pseudo +connection_attempted = false + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempted = true + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) +``` + +### Test Steps +```pseudo +# Create client with autoConnect: false +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +# Verify no connection yet +ASSERT client.connection.state == ConnectionState.initialized +ASSERT connection_attempted == false + +# Explicitly connect +client.connect() + +# Wait for connection to complete +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions +```pseudo +# Connection was established after explicit connect() +ASSERT connection_attempted == true +ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/connection/backoff_jitter_test.md b/uts/realtime/unit/connection/backoff_jitter_test.md new file mode 100644 index 000000000..5bb34a8f7 --- /dev/null +++ b/uts/realtime/unit/connection/backoff_jitter_test.md @@ -0,0 +1,384 @@ +# Backoff and Jitter Tests (RTB1) + +Spec points: `RTB1`, `RTB1a`, `RTB1b` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Overview + +RTB1 defines how retry delays are calculated for connections in the DISCONNECTED state and channels in the SUSPENDED state. The delay is the product of three factors: + +1. **Initial retry timeout** (`disconnectedRetryTimeout` for connections, `channelRetryTimeout` for channels) +2. **Backoff coefficient** (RTB1a): `min((n + 2) / 3, 2)` for the nth retry +3. **Jitter coefficient** (RTB1b): a random number uniformly distributed between 0.8 and 1.0 + +--- + +## RTB1a - Backoff coefficient follows min((n+2)/3, 2) for successive retries + +**Test ID**: `realtime/unit/RTB1a/backoff-coefficient-sequence-0` + +**Spec requirement:** The backoff coefficient for the nth retry is calculated as the minimum of `(n + 2) / 3` and `2` (resulting in the sequence `[1, 4/3, 5/3, 2, 2, ...]`). + +Tests that the backoff coefficient calculation produces the correct sequence of values for successive retries. + +### Setup + +```pseudo +# This test verifies the backoff coefficient calculation function directly. +# The function under test takes a retry count (1-indexed) and returns the +# backoff coefficient. + +# Expected values: +# n=1: min((1+2)/3, 2) = min(1, 2) = 1.0 +# n=2: min((2+2)/3, 2) = min(4/3, 2) = 1.333... +# n=3: min((3+2)/3, 2) = min(5/3, 2) = 1.666... +# n=4: min((4+2)/3, 2) = min(2, 2) = 2.0 +# n=5: min((5+2)/3, 2) = min(7/3, 2) = 2.0 +# n=10: min((10+2)/3, 2) = min(4, 2) = 2.0 +``` + +### Test Steps + +```pseudo +# Calculate backoff coefficients for retries 1 through 10 +coefficients = [] +FOR n IN 1..10: + coefficient = get_backoff_coefficient(n) + coefficients.append(coefficient) +``` + +### Assertions + +```pseudo +# Verify exact values for the first few retries +ASSERT coefficients[0] == 1.0 # n=1: (1+2)/3 = 1 +ASSERT coefficients[1] == 4.0 / 3.0 # n=2: (2+2)/3 = 4/3 +ASSERT coefficients[2] == 5.0 / 3.0 # n=3: (3+2)/3 = 5/3 +ASSERT coefficients[3] == 2.0 # n=4: (4+2)/3 = 2, capped at 2 + +# Verify all subsequent retries are capped at 2.0 +FOR i IN 3..9: + ASSERT coefficients[i] == 2.0 +``` + +--- + +## RTB1b - Jitter coefficient is between 0.8 and 1.0 + +**Test ID**: `realtime/unit/RTB1b/jitter-coefficient-range-0` + +**Spec requirement:** The jitter coefficient is a random number between 0.8 and 1. The randomness of this number doesn't need to be cryptographically secure but should be approximately uniformly distributed. + +Tests that the jitter coefficient is always within the valid range and shows reasonable distribution. + +### Setup + +```pseudo +# This test verifies the jitter coefficient generator. +# We sample it many times and verify range and approximate uniformity. +``` + +### Test Steps + +```pseudo +sample_count = 1000 +jitter_values = [] + +FOR i IN 1..sample_count: + jitter = get_jitter_coefficient() + jitter_values.append(jitter) +``` + +### Assertions + +```pseudo +# All values must be within [0.8, 1.0] +FOR jitter IN jitter_values: + ASSERT jitter >= 0.8 + ASSERT jitter <= 1.0 + +# Verify approximate uniformity: the mean should be close to 0.9 +# (the midpoint of 0.8 and 1.0). Allow some tolerance for randomness. +mean = sum(jitter_values) / sample_count +ASSERT mean >= 0.85 +ASSERT mean <= 0.95 + +# Verify spread: not all values are the same (degenerate case) +min_value = min(jitter_values) +max_value = max(jitter_values) +ASSERT max_value - min_value > 0.05 +``` + +--- + +## RTB1 - Combined retry delay for DISCONNECTED connections + +**Test ID**: `realtime/unit/RTB1/disconnected-retry-delay-0` + +| Spec | Requirement | +|------|-------------| +| RTB1 | Retry delay = disconnectedRetryTimeout * backoff coefficient * jitter coefficient | +| RTB1a | Backoff coefficient = min((n+2)/3, 2) | +| RTB1b | Jitter coefficient is between 0.8 and 1.0 | + +Tests that the retry delay reported in ConnectionStateChange.retryIn falls within the expected range for successive DISCONNECTED retries, computed as `disconnectedRetryTimeout * backoff * jitter`. + +### Setup + +```pseudo +connection_attempt_count = 0 +retry_delays = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + IF connection_attempt_count == 1: + # Initial connection succeeds + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 60000 + ) + )) + ELSE: + # All reconnection attempts fail + conn.respond_with_refused() + } +) +install_mock(mock_ws) + +disconnected_retry_timeout = 2000 # 2 seconds + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: disconnected_retry_timeout, + autoConnect: false, + useBinaryProtocol: false +)) + +# Capture retryIn from DISCONNECTED state changes +client.connection.on((change) => { + IF change.current == ConnectionState.disconnected: + retry_delays.append(change.retryIn) +}) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Simulate unexpected disconnect to trigger reconnection cycle +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection +ws_connection.simulate_disconnect() + +# Advance time in increments to allow multiple retry cycles. +# Each retry fails (respond_with_refused), producing another DISCONNECTED +# state change with a retryIn value. +# We want at least 5 DISCONNECTED events to verify the backoff sequence. +LOOP up to 30 times: + ADVANCE_TIME(5000) + IF retry_delays.length >= 5: + BREAK +``` + +### Assertions + +```pseudo +ASSERT retry_delays.length >= 5 + +# For each retry, verify retryIn is within the expected range: +# retryIn = disconnectedRetryTimeout * backoff(n) * jitter +# where jitter is in [0.8, 1.0] + +# Retry 1: backoff = 1.0, range = [2000*0.8, 2000*1.0] = [1600, 2000] +ASSERT retry_delays[0] >= disconnected_retry_timeout * 1.0 * 0.8 +ASSERT retry_delays[0] <= disconnected_retry_timeout * 1.0 * 1.0 + +# Retry 2: backoff = 4/3, range = [2000*4/3*0.8, 2000*4/3*1.0] = [2133, 2667] +ASSERT retry_delays[1] >= disconnected_retry_timeout * (4.0/3.0) * 0.8 +ASSERT retry_delays[1] <= disconnected_retry_timeout * (4.0/3.0) * 1.0 + +# Retry 3: backoff = 5/3, range = [2000*5/3*0.8, 2000*5/3*1.0] = [2667, 3333] +ASSERT retry_delays[2] >= disconnected_retry_timeout * (5.0/3.0) * 0.8 +ASSERT retry_delays[2] <= disconnected_retry_timeout * (5.0/3.0) * 1.0 + +# Retry 4+: backoff = 2.0 (capped), range = [2000*2*0.8, 2000*2*1.0] = [3200, 4000] +ASSERT retry_delays[3] >= disconnected_retry_timeout * 2.0 * 0.8 +ASSERT retry_delays[3] <= disconnected_retry_timeout * 2.0 * 1.0 + +ASSERT retry_delays[4] >= disconnected_retry_timeout * 2.0 * 0.8 +ASSERT retry_delays[4] <= disconnected_retry_timeout * 2.0 * 1.0 + +# Verify the delays are monotonically non-decreasing (on average), +# accounting for jitter. The max of retry n should be <= max of retry n+1 +# when backoff is increasing. +CLOSE_CLIENT(client) +``` + +--- + +## RTB1 - Combined retry delay for SUSPENDED channels + +**Test ID**: `realtime/unit/RTB1/suspended-channel-retry-delay-1` + +| Spec | Requirement | +|------|-------------| +| RTB1 | Retry delay = channelRetryTimeout * backoff coefficient * jitter coefficient | +| RTB1a | Backoff coefficient = min((n+2)/3, 2) | +| RTB1b | Jitter coefficient is between 0.8 and 1.0 | + +Tests that the retry delay reported in ChannelStateChange.retryIn falls within the expected range for successive SUSPENDED channel re-attach attempts, computed as `channelRetryTimeout * backoff * jitter`. + +### Setup + +```pseudo +channel_name = "test-RTB1-channel-${random_id()}" +connection_attempt_count = 0 +retry_delays = [] +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessage: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + # First attach succeeds + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + flags: 0 + )) + ELSE: + # All subsequent re-attach attempts fail with DETACHED + # (per RTL13b, this triggers SUSPENDED state) + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: msg.channel, + error: ErrorInfo( + code: 90001, + statusCode: 500, + message: "Channel re-attach failed" + ) + )) + } +) +install_mock(mock_ws) + +channel_retry_timeout = 3000 # 3 seconds + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + channelRetryTimeout: channel_retry_timeout, + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +channel = client.channels.get(channel_name) + +# Capture retryIn from SUSPENDED state changes +channel.on((change) => { + IF change.current == ChannelState.suspended: + retry_delays.append(change.retryIn) +}) + +# Initial attach succeeds +channel.attach() +AWAIT_STATE channel.state == ChannelState.attached + +# Server sends ERROR on the channel, triggering re-attach (RTL13b). +# The re-attach will fail (DETACHED response), causing SUSPENDED state. +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo( + code: 90001, + statusCode: 500, + message: "Channel error" + ) +)) + +# Advance time in increments to allow multiple SUSPENDED -> ATTACHING cycles. +# Each re-attach fails, producing another SUSPENDED with retryIn. +LOOP up to 30 times: + ADVANCE_TIME(7000) + IF retry_delays.length >= 4: + BREAK +``` + +### Assertions + +```pseudo +ASSERT retry_delays.length >= 4 + +# Retry 1: backoff = 1.0, range = [3000*0.8, 3000*1.0] = [2400, 3000] +ASSERT retry_delays[0] >= channel_retry_timeout * 1.0 * 0.8 +ASSERT retry_delays[0] <= channel_retry_timeout * 1.0 * 1.0 + +# Retry 2: backoff = 4/3, range = [3000*4/3*0.8, 3000*4/3*1.0] = [3200, 4000] +ASSERT retry_delays[1] >= channel_retry_timeout * (4.0/3.0) * 0.8 +ASSERT retry_delays[1] <= channel_retry_timeout * (4.0/3.0) * 1.0 + +# Retry 3: backoff = 5/3, range = [3000*5/3*0.8, 3000*5/3*1.0] = [4000, 5000] +ASSERT retry_delays[2] >= channel_retry_timeout * (5.0/3.0) * 0.8 +ASSERT retry_delays[2] <= channel_retry_timeout * (5.0/3.0) * 1.0 + +# Retry 4: backoff = 2.0 (capped), range = [3000*2*0.8, 3000*2*1.0] = [4800, 6000] +ASSERT retry_delays[3] >= channel_retry_timeout * 2.0 * 0.8 +ASSERT retry_delays[3] <= channel_retry_timeout * 2.0 * 1.0 + +CLOSE_CLIENT(client) +``` + +--- + +## Implementation Notes + +### Testing the Backoff/Jitter Functions + +The first two tests (RTB1a and RTB1b) verify the underlying calculation functions in isolation. Implementations should expose or have testable access to: + +- A backoff coefficient function that takes the retry count and returns the coefficient +- A jitter coefficient function/generator + +If these functions are private, implementations may test them indirectly through the full retry delay tests (the RTB1 tests), or use language-specific mechanisms to access internal functions (e.g., `@visibleForTesting` in Dart). + +### Jitter Seeding + +For deterministic tests of the full retry delay (RTB1), implementations may optionally seed or mock the random number generator used for jitter. However, the range-based assertions (`>= min, <= max`) should work without mocking since they account for the full jitter range. diff --git a/uts/realtime/unit/connection/connection_failures_test.md b/uts/realtime/unit/connection/connection_failures_test.md new file mode 100644 index 000000000..ace9c3981 --- /dev/null +++ b/uts/realtime/unit/connection/connection_failures_test.md @@ -0,0 +1,1148 @@ +# Connection Failures When Connected Tests (RTN15) + +Spec points: `RTN15`, `RTN15a`, `RTN15b`, `RTN15c`, `RTN15d`, `RTN15e`, `RTN15g`, `RTN15h`, `RTN15j` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTN15h1 - DISCONNECTED with token error, no means to renew + +**Test ID**: `realtime/unit/RTN15h1/token-error-no-renew-0` + +**Spec requirement:** If a DISCONNECTED message contains a token error and the library cannot renew the token, transition to FAILED state. + +Tests that non-renewable token errors cause permanent failure. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +# Use token directly (no way to renew) +client = Realtime(options: ClientOptions( + token: "some_token_string", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect successfully +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Get reference to the WebSocket connection +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection + +# Server sends DISCONNECTED with token error and closes connection +ws_connection.send_to_client_and_close(ProtocolMessage( + action: DISCONNECTED, + error: ErrorInfo( + code: 40142, + statusCode: 401, + message: "Token expired" + ) +)) + +# Should transition to FAILED (no means to renew) +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 2 seconds +``` + +### Assertions + +```pseudo +# Connection transitioned to FAILED +ASSERT client.connection.state == ConnectionState.failed + +# Error reason is set +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 40142 +ASSERT client.connection.errorReason.statusCode == 401 +CLOSE_CLIENT(client) +``` + +--- + +## RTN15h2 - DISCONNECTED with token error, renewable token + +**Test ID**: `realtime/unit/RTN15h2/token-error-renew-success-0` + +**Spec requirement:** If a DISCONNECTED message contains a token error and the library can renew the token, transition to CONNECTING, obtain new token, and attempt resume. + +Tests that renewable token errors trigger token renewal and reconnection. + +### Setup + +```pseudo +token_request_count = 0 +connection_attempt_count = 0 + +# Mock HTTP for token requests +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + IF req.url.path CONTAINS "/keys/": + token_request_count++ + req.respond_with(200, { + "token": "renewed_token_" + token_request_count, + "keyName": "appId.keyId", + "issued": time_now(), + "expires": time_now() + 3600000, + "capability": "{\"*\":[\"*\"]}" + }) + } +) +install_mock(mock_http) + +# Mock WebSocket +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success() + + IF connection_attempt_count == 1: + # First connection succeeds + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + ELSE: + # Resume after token renewal succeeds + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", # Same ID = successful resume + connectionKey: "key-1-renewed", + connectionDetails: ConnectionDetails( + connectionKey: "key-1-renewed", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect successfully +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +first_connection_id = client.connection.id +first_connection_key = client.connection.key + +# Get WebSocket connection +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection + +# Server sends DISCONNECTED with token error and closes connection +ws_connection.send_to_client_and_close(ProtocolMessage( + action: DISCONNECTED, + error: ErrorInfo( + code: 40142, + statusCode: 401, + message: "Token expired" + ) +)) + +# Should transition to CONNECTING (to renew and resume) +AWAIT_STATE client.connection.state == ConnectionState.connecting + WITH timeout: 2 seconds + +# Should reconnect with renewed token +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Successfully reconnected +ASSERT client.connection.state == ConnectionState.connected + +# Token was renewed +ASSERT token_request_count == 2 # Initial + renewal + +# Connection was resumed (same ID) +ASSERT client.connection.id == first_connection_id + +# Connection key was updated +ASSERT client.connection.key != first_connection_key +ASSERT client.connection.key == "key-1-renewed" +CLOSE_CLIENT(client) +``` + +--- + +## RTN15h2 - DISCONNECTED with token error, renewal fails + +**Test ID**: `realtime/unit/RTN15h2/token-error-renew-fails-1` + +**Spec requirement:** If token renewal or reconnection fails after DISCONNECTED with token error, transition to DISCONNECTED with errorReason set. + +Tests that failed token renewal leads to DISCONNECTED state. + +### Setup + +```pseudo +# Mock HTTP for token requests (returns error) +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + IF req.url.path CONTAINS "/keys/": + req.respond_with(401, { + "error": { + "code": 40101, + "statusCode": 401, + "message": "Invalid credentials" + } + }) + } +) +install_mock(mock_http) + +# Mock WebSocket +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect successfully +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Get WebSocket connection +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection + +# Server sends DISCONNECTED with token error and closes connection +ws_connection.send_to_client_and_close(ProtocolMessage( + action: DISCONNECTED, + error: ErrorInfo( + code: 40142, + statusCode: 401, + message: "Token expired" + ) +)) + +# Should transition to CONNECTING (to attempt renewal) +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Renewal fails, should transition to DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Connection is DISCONNECTED (will retry later) +ASSERT client.connection.state == ConnectionState.disconnected + +# Error reason is set (from token renewal failure) +ASSERT client.connection.errorReason IS NOT null +CLOSE_CLIENT(client) +``` + +> **Implementation note:** The `key` + mock HTTP approach shown above is one way to +> test token renewal failure. A more portable alternative is to use `authCallback`: +> ```pseudo +> call_count = 0 +> auth_callback = (params) => +> call_count += 1 +> IF call_count == 1: +> RETURN TokenDetails(token: "valid-token-1", expires: now + 3600000) +> ELSE: +> THROW ErrorInfo(code: 40171, statusCode: 401, message: "Token renewal failed") +> +> client = create_realtime_client(ClientOptions( +> authCallback: auth_callback, +> autoConnect: false +> )) +> ``` +> This pattern is clearer about the number of token requests and doesn't require a +> mock HTTP client for internal token request endpoints. +> +> **State transition note:** RTN15h2i specifies a transient DISCONNECTED state between +> CONNECTED and CONNECTING. When tracking state changes, implementations should +> distinguish between the transient DISCONNECTED (before CONNECTING retry) and the +> final DISCONNECTED (after failed renewal). A naive `AWAIT_STATE disconnected` may +> match the wrong transition. + +--- + +## RTN15h3 - DISCONNECTED with non-token error + +**Test ID**: `realtime/unit/RTN15h3/non-token-error-resume-0` + +**Spec requirement:** If a DISCONNECTED message contains an error other than a token error, initiate immediate reconnect with resume attempt. + +Tests that non-token disconnection triggers immediate resume. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + IF connection_attempt_count == 1: + # First connection succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + ELSE: + # Resume succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", # Same ID = resumed + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect successfully +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +original_connection_id = client.connection.id + +# Get WebSocket connection +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection + +# Server sends DISCONNECTED with non-token error and closes connection +ws_connection.send_to_client_and_close(ProtocolMessage( + action: DISCONNECTED, + error: ErrorInfo( + code: 80003, + statusCode: 503, + message: "Service unavailable" + ) +)) + +# Should transition to CONNECTING immediately (no token renewal) +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Should reconnect and resume +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Successfully reconnected +ASSERT client.connection.state == ConnectionState.connected + +# Connection was resumed (same ID) +ASSERT client.connection.id == original_connection_id + +# Two connection attempts total +ASSERT connection_attempt_count == 2 + +# Second connection attempt included resume parameter +ASSERT mock_ws.events[1].url.query_params["resume"] == "key-1" +CLOSE_CLIENT(client) +``` + +--- + +## RTN15j - ERROR protocol message with empty channel + +**Test ID**: `realtime/unit/RTN15j/error-empty-channel-failed-0` + +**Spec requirement:** If an ERROR ProtocolMessage with empty channel is received when CONNECTED, transition to FAILED state and set errorReason. + +Tests that fatal connection errors cause FAILED state. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect successfully +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Get WebSocket connection +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection + +# Server sends ERROR with empty channel (connection-level error) and closes connection +ws_connection.send_to_client_and_close(ProtocolMessage( + action: ERROR, + channel: null, # Empty = connection-level error + error: ErrorInfo( + code: 50000, + statusCode: 500, + message: "Internal error" + ) +)) + +# Should transition to FAILED +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 2 seconds +``` + +### Assertions + +```pseudo +# Connection transitioned to FAILED +ASSERT client.connection.state == ConnectionState.failed + +# Error reason is set +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 50000 +ASSERT client.connection.errorReason.statusCode == 500 +CLOSE_CLIENT(client) +``` + +--- + +## RTN15a - Unexpected transport disconnect + +**Test ID**: `realtime/unit/RTN15a/unexpected-transport-disconnect-0` + +**Spec requirement:** If transport is disconnected unexpectedly (without DISCONNECTED or ERROR), respond as if receiving non-token DISCONNECTED message. + +Tests that transport failures trigger resume attempts. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + IF connection_attempt_count == 1: + # First connection succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + ELSE: + # Resume succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", # Same ID = resumed + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect successfully +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +original_connection_id = client.connection.id + +# Get WebSocket connection +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection + +# Simulate unexpected disconnect (no protocol message) +ws_connection.simulate_disconnect() + +# Should transition to DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 1 second + +# Should automatically attempt reconnect +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Should resume successfully +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Successfully reconnected +ASSERT client.connection.state == ConnectionState.connected + +# Connection was resumed (same ID) +ASSERT client.connection.id == original_connection_id + +# Two connection attempts made +ASSERT connection_attempt_count == 2 +CLOSE_CLIENT(client) +``` + +--- + +## RTN15b, RTN15c6 - Successful resume + +**Test ID**: `realtime/unit/RTN15b/successful-resume-0` + +| Spec | Requirement | +|------|-------------| +| RTN15b | Resume is attempted with connectionKey in query parameter | +| RTN15c6 | Successful resume indicated by same connectionId in CONNECTED | + +Tests that connection resume works correctly. + +### Setup + +```pseudo +connection_attempt_count = 0 +captured_connection_attempts = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + captured_connection_attempts.append(conn) + + conn.respond_with_success() + + IF connection_attempt_count == 1: + # Initial connection + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + ELSE: + # Resume succeeds (same connectionId) + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", # Same ID indicates successful resume + connectionKey: "key-1-updated", + connectionDetails: ConnectionDetails( + connectionKey: "key-1-updated", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Initial connection +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +original_connection_id = client.connection.id +ASSERT original_connection_id == "connection-1" + +# Force disconnect +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection +ws_connection.simulate_disconnect() + +# Wait for reconnection +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Connection resumed (same ID) +ASSERT client.connection.id == "connection-1" + +# Connection key was updated (RTN15e) +ASSERT client.connection.key == "key-1-updated" + +# Second connection attempt included resume parameter (RTN15b1) +ASSERT captured_connection_attempts[1].url.query_params["resume"] == "key-1" + +# Two connection attempts total +ASSERT connection_attempt_count == 2 +CLOSE_CLIENT(client) +``` + +--- + +## RTN15c7 - Failed resume (new connectionId) + +**Test ID**: `realtime/unit/RTN15c7/failed-resume-new-id-0` + +**Spec requirement:** If resume fails, server sends CONNECTED with new connectionId and error. Client should reset msgSerial to 0. + +Tests that failed resume is handled correctly. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + conn.respond_with_success() + + IF connection_attempt_count == 1: + # Initial connection + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + ELSE: + # Resume failed (new connectionId + error) + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-2", # Different ID = failed resume + connectionKey: "key-2", + error: ErrorInfo( + code: 80008, + statusCode: 400, + message: "Unable to recover connection" + ), + connectionDetails: ConnectionDetails( + connectionKey: "key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Initial connection +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +original_connection_id = client.connection.id + +# Force disconnect +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection +ws_connection.simulate_disconnect() + +# Wait for reconnection +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# New connection (different ID) +ASSERT client.connection.id == "connection-2" +ASSERT client.connection.id != original_connection_id + +# Connection key updated +ASSERT client.connection.key == "key-2" + +# Error reason set (indicates why resume failed) +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80008 + +# Connection is still CONNECTED (despite error) +ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) +``` + +--- + +## RTN15e - Connection key updated on resume + +**Test ID**: `realtime/unit/RTN15e/connection-key-updated-0` + +**Spec requirement:** When connection is resumed, Connection.key may change and is provided in CONNECTED message connectionDetails. + +Tests that connection key is updated after resume. + +This is covered by the RTN15b, RTN15c6 test above. The key assertion is: + +```pseudo +ASSERT client.connection.key == "key-1-updated" +``` + +--- + +## RTN15g - Connection state cleared after connectionStateTtl + +**Test ID**: `realtime/unit/RTN15g/state-cleared-after-ttl-0` + +**Spec requirement:** If disconnected longer than connectionStateTtl, don't attempt resume. Clear local state and make fresh connection. + +Tests that stale connections don't attempt resume. After disconnecting, reconnection +attempts fail repeatedly, causing the client to eventually transition to SUSPENDED +(once connectionStateTtl expires). When the client eventually reconnects from +SUSPENDED state, it makes a fresh connection without resume parameters. + +> **Note on verifying transient states:** Rather than trying to observe intermediate +> states (e.g. DISCONNECTED, SUSPENDED) mid-test with `AWAIT_STATE`, we record all +> state changes and verify the full sequence at the end. This avoids flaky tests +> caused by the SDK (correctly) attempting immediate reconnection per RTN15a, which +> makes transient states difficult to observe reliably. + +### Setup + +```pseudo +connection_attempt_count = 0 +captured_connection_attempts = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + captured_connection_attempts.append(conn) + + IF connection_attempt_count == 1: + # Initial connection succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 5000 # 5 seconds TTL + ) + )) + ELSE IF connection_attempt_count < 6: + # Reconnection attempts 2-5 fail (connection refused) + # This keeps the client retrying while TTL expires + conn.respond_with_refused() + ELSE: + # After TTL expires, fresh connection succeeds (no resume) + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-2", # New ID + connectionKey: "key-2", + connectionDetails: ConnectionDetails( + connectionKey: "key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +mock_http = MockHttpClient( + onRequest: (req) => req.respond_with(200, "yes") # Connectivity check +) +install_mock(mock_http) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: 1000, + suspendedRetryTimeout: 2000, + autoConnect: false, + fallbackHosts: [] +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Record all state changes +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +# Initial connection +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +original_connection_id = client.connection.id +original_connection_key = client.connection.key + +# Force disconnect - triggers immediate reconnect per RTN15a +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection +ws_connection.simulate_disconnect() + +# Reconnection attempts keep failing (connection refused). +# Advance time in increments to allow retries, TTL expiry, +# transition to SUSPENDED, and eventual successful reconnection. +# TTL is 5000ms, disconnectedRetryTimeout is 1000ms, +# suspendedRetryTimeout is 2000ms. +LOOP up to 15 times: + ADVANCE_TIME(2500) + IF client.connection.state == ConnectionState.connected: + BREAK + +# Wait for final successful reconnection +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Verify the full state change sequence includes SUSPENDED +# (TTL expired while reconnection attempts were failing) +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.suspended, + ConnectionState.connecting, + ConnectionState.connected +] + +# RTN15g: New connection (different ID, not resumed - TTL expired) +ASSERT client.connection.id == "connection-2" +ASSERT client.connection.id != original_connection_id + +# Fresh connection key +ASSERT client.connection.key == "key-2" +ASSERT client.connection.key != original_connection_key + +# Final reconnection URL did NOT include resume parameter +# (because TTL expired and connection state was cleared) +ASSERT "resume" NOT IN captured_connection_attempts.last.url.query_params +CLOSE_CLIENT(client) +``` + +--- + +## RTN15c5 - ERROR with token error during resume + +**Test ID**: `realtime/unit/RTN15c5/token-error-during-resume-0` + +**Spec requirement:** If resume attempt receives ERROR with token error, follow RTN15h spec for token error handling. + +Tests that token errors during resume trigger renewal. + +### Setup + +```pseudo +token_request_count = 0 +connection_attempt_count = 0 + +# Mock HTTP for token requests +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + IF req.url.path CONTAINS "/keys/": + token_request_count++ + req.respond_with(200, { + "token": "renewed_token", + "keyName": "appId.keyId", + "issued": time_now(), + "expires": time_now() + 3600000, + "capability": "{\"*\":[\"*\"]}" + }) + } +) +install_mock(mock_http) + +# Mock WebSocket +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + conn.respond_with_success() + + IF connection_attempt_count == 1: + # Initial connection + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + ELSE IF connection_attempt_count == 2: + # Resume attempt fails with token error + conn.send_to_client(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40142, + statusCode: 401, + message: "Token expired" + ) + )) + ELSE: + # Retry with renewed token succeeds + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-2", + connectionKey: "key-2", + connectionDetails: ConnectionDetails( + connectionKey: "key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Initial connection +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Force disconnect (will trigger resume attempt) +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection +ws_connection.simulate_disconnect() + +# Wait for final CONNECTED (after token renewal) +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +``` + +### Assertions + +```pseudo +# Successfully reconnected after token renewal +ASSERT client.connection.state == ConnectionState.connected + +# Token was renewed +ASSERT token_request_count == 2 # Initial + renewal + +# Three connection attempts (initial, failed resume, retry with new token) +ASSERT connection_attempt_count == 3 +CLOSE_CLIENT(client) +``` + +--- + +## RTN15c4 - ERROR with fatal error during resume + +**Test ID**: `realtime/unit/RTN15c4/fatal-error-during-resume-0` + +**Spec requirement:** If resume attempt receives ERROR with fatal error, transition to FAILED state. + +Tests that fatal errors during resume cause permanent failure. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + conn.respond_with_success() + + IF connection_attempt_count == 1: + # Initial connection + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + ELSE: + # Resume attempt fails with fatal error + conn.send_to_client(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 50000, + statusCode: 500, + message: "Internal server error" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Initial connection +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Force disconnect (will trigger resume attempt) +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection +ws_connection.simulate_disconnect() + +# Wait for FAILED state +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Connection transitioned to FAILED +ASSERT client.connection.state == ConnectionState.failed + +# Error reason is set +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 50000 + +# Only two connection attempts (no retry after fatal error) +ASSERT connection_attempt_count == 2 +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/connection/connection_id_key_test.md b/uts/realtime/unit/connection/connection_id_key_test.md new file mode 100644 index 000000000..81239ea42 --- /dev/null +++ b/uts/realtime/unit/connection/connection_id_key_test.md @@ -0,0 +1,386 @@ +# Connection ID and Key Tests + +Spec points: `RTN8`, `RTN8a`, `RTN8b`, `RTN8c`, `RTN9`, `RTN9a`, `RTN9b`, `RTN9c` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTN8a - Connection ID is unset until connected + +**Test ID**: `realtime/unit/RTN8a/id-unset-until-connected-0` + +| Spec | Requirement | +|------|-------------| +| RTN8 | `Connection#id` attribute | +| RTN8a | Is unset until connected | + +Tests that `connection.id` is null before the connection is established and is set after CONNECTED. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "unique-conn-id-1", connectionKey: "conn-key-1") + ) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +# Before connecting, id should be null +ASSERT client.connection.id IS null + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions +```pseudo +ASSERT client.connection.id == "unique-conn-id-1" +CLOSE_CLIENT(client) +``` + +--- + +## RTN9a - Connection key is unset until connected + +**Test ID**: `realtime/unit/RTN9a/key-unset-until-connected-0` + +| Spec | Requirement | +|------|-------------| +| RTN9 | `Connection#key` attribute | +| RTN9a | Is unset until connected | + +Tests that `connection.key` is null before the connection is established and is set after CONNECTED. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "unique-conn-id-1", connectionKey: "conn-key-1") + ) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +# Before connecting, key should be null +ASSERT client.connection.key IS null + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions +```pseudo +ASSERT client.connection.key == "conn-key-1" +CLOSE_CLIENT(client) +``` + +--- + +## RTN8b - Connection ID is unique per connection + +**Test ID**: `realtime/unit/RTN8b/id-unique-per-connection-0` + +| Spec | Requirement | +|------|-------------| +| RTN8b | Is a unique string provided by Ably. Multiple connected clients have unique connection IDs | + +Tests that two separate clients receive different connection IDs from the server. + +### Setup +```pseudo +connection_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success( + CONNECTED_MESSAGE( + connectionId: "conn-id-${connection_count}", + connectionKey: "conn-key-${connection_count}" + ) + ) + } +) +install_mock(mock_ws) + +client1 = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +client2 = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client1.connect() +AWAIT_STATE client1.connection.state == ConnectionState.connected + +client2.connect() +AWAIT_STATE client2.connection.state == ConnectionState.connected +``` + +### Assertions +```pseudo +ASSERT client1.connection.id != client2.connection.id +ASSERT client1.connection.id == "conn-id-1" +ASSERT client2.connection.id == "conn-id-2" +CLOSE_CLIENT(client1) +CLOSE_CLIENT(client2) +``` + +--- + +## RTN9b - Connection key is unique per connection + +**Test ID**: `realtime/unit/RTN9b/key-unique-per-connection-0` + +| Spec | Requirement | +|------|-------------| +| RTN9b | Is a unique private connection key. Multiple connected clients have unique connection keys | + +Tests that two separate clients receive different connection keys from the server. + +### Setup +```pseudo +connection_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success( + CONNECTED_MESSAGE( + connectionId: "conn-id-${connection_count}", + connectionKey: "conn-key-${connection_count}" + ) + ) + } +) +install_mock(mock_ws) + +client1 = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +client2 = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client1.connect() +AWAIT_STATE client1.connection.state == ConnectionState.connected + +client2.connect() +AWAIT_STATE client2.connection.state == ConnectionState.connected +``` + +### Assertions +```pseudo +ASSERT client1.connection.key != client2.connection.key +ASSERT client1.connection.key == "conn-key-1" +ASSERT client2.connection.key == "conn-key-2" +CLOSE_CLIENT(client1) +CLOSE_CLIENT(client2) +``` + +--- + +## RTN8c - Connection ID is null in terminal/non-connected states + +**Test ID**: `realtime/unit/RTN8c/id-null-after-closed-0` + +| Spec | Requirement | +|------|-------------| +| RTN8c | Is null when the SDK is in CLOSED, CLOSING, FAILED, or SUSPENDED states | + +Tests that `connection.id` is cleared when the connection enters CLOSED or FAILED states. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +ASSERT client.connection.id == "conn-id-1" + +# Close the connection +AWAIT client.close() +AWAIT_STATE client.connection.state == ConnectionState.closed +``` + +### Assertions +```pseudo +ASSERT client.connection.id IS null +CLOSE_CLIENT(client) +``` + +--- + +## RTN9c - Connection key is null in terminal/non-connected states + +**Test ID**: `realtime/unit/RTN9c/key-null-after-closed-0` + +| Spec | Requirement | +|------|-------------| +| RTN9c | Is null when the SDK is in CLOSED, CLOSING, FAILED, or SUSPENDED states | + +Tests that `connection.key` is cleared when the connection enters CLOSED or FAILED states. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +ASSERT client.connection.key == "conn-key-1" + +# Close the connection +AWAIT client.close() +AWAIT_STATE client.connection.state == ConnectionState.closed +``` + +### Assertions +```pseudo +ASSERT client.connection.key IS null +CLOSE_CLIENT(client) +``` + +--- + +## RTN8c, RTN9c - ID and key null after FAILED + +**Test ID**: `realtime/unit/RTN8c/id-key-null-after-failed-1` + +**Spec requirement:** Connection ID and key are null in FAILED state. + +Tests that both `connection.id` and `connection.key` are cleared when the connection transitions to FAILED (e.g. due to a fatal error). + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_error( + ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: 80000, statusCode: 400, message: "Fatal error") + ) + ) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.failed +``` + +### Assertions +```pseudo +ASSERT client.connection.id IS null +ASSERT client.connection.key IS null +CLOSE_CLIENT(client) +``` + +--- + +## RTN8c, RTN9c - ID and key null after SUSPENDED + +**Test ID**: `realtime/unit/RTN8c/id-key-null-after-suspended-2` + +**Spec requirement:** Connection ID and key are null in SUSPENDED state. + +Tests that both `connection.id` and `connection.key` are null when the connection transitions to SUSPENDED. + +### Setup +```pseudo +enable_fake_timers() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_refused() +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + disconnectedRetryTimeout: 1000, + suspendedRetryTimeout: 100, + fallbackHosts: [] +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Advance past connectionStateTtl to reach SUSPENDED +ADVANCE_TIME(121s) +AWAIT_STATE client.connection.state == ConnectionState.suspended +``` + +### Assertions +```pseudo +ASSERT client.connection.id IS null +ASSERT client.connection.key IS null +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/connection/connection_open_failures_test.md b/uts/realtime/unit/connection/connection_open_failures_test.md new file mode 100644 index 000000000..38e93fc4b --- /dev/null +++ b/uts/realtime/unit/connection/connection_open_failures_test.md @@ -0,0 +1,654 @@ +# Connection Opening Failures Tests (RTN14) + +Spec points: `RTN14`, `RTN14a`, `RTN14b`, `RTN14c`, `RTN14d`, `RTN14e`, `RTN14f`, `RTN14g` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTN14a - Invalid API key causes FAILED state + +**Test ID**: `realtime/unit/RTN14a/invalid-key-failed-0` + +**Spec requirement:** If an API key is invalid, the connection transitions to FAILED state and Connection.errorReason is set. + +Tests that connecting with an invalid API key results in immediate failure. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # WebSocket connects successfully + conn.respond_with_success() + + # But server immediately sends ERROR for invalid key and closes connection + conn.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40005, + statusCode: 400, + message: "Invalid key" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "invalid.key:secret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for CONNECTING state +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Wait for FAILED state +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Connection transitioned to FAILED +ASSERT client.connection.state == ConnectionState.failed + +# Error reason is set +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 40005 +ASSERT client.connection.errorReason.statusCode == 400 + +# Connection ID/key not set +ASSERT client.connection.id IS null +ASSERT client.connection.key IS null +CLOSE_CLIENT(client) +``` + +--- + +## RTN14b - Token error during connection with renewal + +**Test ID**: `realtime/unit/RTN14b/token-error-with-renewal-0` + +**Spec requirement:** If a token error occurs during connection and the token is renewable, attempt to obtain a new token and retry the connection. + +Tests that token errors trigger renewal and retry when possible. + +### Setup + +```pseudo +token_request_count = 0 +connection_attempt_count = 0 + +# Mock HTTP for token requests +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + IF req.url.path CONTAINS "/keys/": + token_request_count++ + req.respond_with(200, { + "token": "renewed_token_" + token_request_count, + "keyName": "appId.keyId", + "issued": time_now(), + "expires": time_now() + 3600000, + "capability": "{\"*\":[\"*\"]}" + }) + } +) +install_mock(mock_http) + +# Mock WebSocket +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success() + + IF connection_attempt_count == 1: + # First attempt: token error, close connection + conn.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40142, + statusCode: 401, + message: "Token expired" + ) + )) + ELSE: + # Second attempt: success + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for CONNECTED (should retry after token renewal) +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +``` + +### Assertions + +```pseudo +# Successfully connected after retry +ASSERT client.connection.state == ConnectionState.connected + +# Token was renewed +ASSERT token_request_count == 2 # Initial + renewal + +# Connection was attempted twice +ASSERT connection_attempt_count == 2 +CLOSE_CLIENT(client) +``` + +--- + +## RTN14b - Token error during initial connection, renewal fails + +**Test ID**: `realtime/unit/RTN14b/token-renewal-fails-1` + +**Spec requirement:** When a token error occurs during the initial connection attempt and the subsequent +token renewal also fails, the connection should transition to DISCONNECTED (per RTN14b: +"If the attempt to create a new token fails... the connection will transition to the +DISCONNECTED state"). + +### Setup + +```pseudo +call_count = 0 +auth_callback = (params) => + call_count += 1 + IF call_count == 1: + RETURN TokenDetails(token: "initial-token", expires: now + 3600000) + ELSE: + THROW ErrorInfo(code: 40171, statusCode: 401, message: "Unable to renew token") + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => + # Always reject with token error + conn.send_to_client(ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: 40142, statusCode: 401, message: "Token expired") + )) +) +install_mock(mock_ws) + +client = create_realtime_client(ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +state_changes = [] +client.connection.on((change) => state_changes.push(change)) + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected +``` + +### Assertions + +```pseudo +# Connection should be DISCONNECTED (not FAILED) per RTN14b +ASSERT client.connection.state == ConnectionState.disconnected + +# authCallback was called twice: once for initial token, once for renewal +ASSERT call_count == 2 + +# errorReason should reflect the renewal failure +ASSERT client.connection.errorReason IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RSA4a - Token error during connection without renewal + +**Test ID**: `realtime/unit/RSA4a/token-error-no-renewal-0` + +**Spec requirement (RSA4a2):** If the server responds with a token error and there is no means to renew the token, the connection transitions to FAILED with error code 40171. + +Tests that non-renewable token errors cause FAILED state. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40142, + statusCode: 401, + message: "Token expired" + ) + )) + } +) +install_mock(mock_ws) + +# Use token directly (no way to renew) +client = Realtime(options: ClientOptions( + token: "expired_token_string", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for FAILED state (RSA4a2: no means to renew → FAILED) +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Connection transitioned to FAILED (RSA4a2: not DISCONNECTED) +ASSERT client.connection.state == ConnectionState.failed + +# Error reason is set with 40171 (RSA4a2) +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 40171 +CLOSE_CLIENT(client) +``` + +--- + +## RTN14c - Connection timeout + +**Test ID**: `realtime/unit/RTN14c/connection-timeout-0` + +**Spec requirement:** A connection attempt fails if not connected within realtimeRequestTimeout. + +Tests that connections time out if no CONNECTED message is received. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + # WebSocket connects but server never sends CONNECTED + # (simulates unresponsive server) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 1000, # 1 second timeout + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Start connection +client.connect() + +# Wait for CONNECTING state +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Advance time past timeout +ADVANCE_TIME(1100) + +# Should transition to DISCONNECTED (will retry) +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 1 second +``` + +### Assertions + +```pseudo +# Connection timed out +ASSERT client.connection.state == ConnectionState.disconnected + +# Error indicates timeout +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.message CONTAINS "timeout" + OR client.connection.errorReason.code IN [50003, 80003] +CLOSE_CLIENT(client) +``` + +--- + +## RTN14d - Retry after recoverable failure + +**Test ID**: `realtime/unit/RTN14d/retry-recoverable-failure-0` + +**Spec requirement:** After a recoverable connection failure, the client transitions to DISCONNECTED and automatically retries after disconnectedRetryTimeout. + +Tests that recoverable failures trigger automatic retry. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + IF connection_attempt_count == 1: + # First attempt fails (network error) + conn.respond_with_refused() + ELSE: + # Second attempt succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: 1000, # 1 second + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Start connection +client.connect() + +# Should transition to DISCONNECTED after first failure +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 2 seconds + +# Advance time to trigger retry +ADVANCE_TIME(1100) + +# Should reconnect automatically +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Successfully connected on retry +ASSERT client.connection.state == ConnectionState.connected + +# Two connection attempts were made +ASSERT connection_attempt_count == 2 +CLOSE_CLIENT(client) +``` + +--- + +## RTN14e - DISCONNECTED to SUSPENDED after connectionStateTtl + +**Test ID**: `realtime/unit/RTN14e/disconnected-to-suspended-0` + +**Spec requirement:** Once the connection has been DISCONNECTED for longer than connectionStateTtl, transition to SUSPENDED state. + +Tests that prolonged disconnection leads to suspension. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # All connection attempts fail + conn.respond_with_refused() + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: 1000, # Retry every 1 second + autoConnect: false +)) + +# Simulate short connectionStateTtl +# In real implementation, this comes from server in CONNECTED message +# For this test, we'll use a short default value +DEFAULT_CONNECTION_STATE_TTL = 5000 # 5 seconds +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Start connection (will fail) +client.connect() + +# Should transition to DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Advance time past connectionStateTtl +ADVANCE_TIME(DEFAULT_CONNECTION_STATE_TTL + 100) + +# Should transition to SUSPENDED +AWAIT_STATE client.connection.state == ConnectionState.suspended + WITH timeout: 1 second +``` + +### Assertions + +```pseudo +# Connection is SUSPENDED +ASSERT client.connection.state == ConnectionState.suspended + +# Error reason is set (indicates why suspended) +ASSERT client.connection.errorReason IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RTN14f - SUSPENDED state retries indefinitely + +**Test ID**: `realtime/unit/RTN14f/suspended-retries-indefinitely-0` + +**Spec requirement:** The connection remains in SUSPENDED state indefinitely, periodically attempting to reestablish connection. + +Tests that SUSPENDED state continues retry attempts. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + IF connection_attempt_count < 3: + # First 2 attempts fail + conn.respond_with_refused() + ELSE: + # Third attempt succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: 500, + suspendedRetryTimeout: 1000, # 1 second + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Start connection (will fail repeatedly) +client.connect() + +# Wait for SUSPENDED state +# (after initial failure + connectionStateTtl expiry) +AWAIT_STATE client.connection.state == ConnectionState.suspended + +recorded_suspended_time = current_fake_time() + +# Advance time to trigger first SUSPENDED retry +ADVANCE_TIME(1100) + +# Should attempt reconnection (but still fail) +WAIT_FOR connection_attempt_count >= 2 + +# Advance time to trigger second SUSPENDED retry +ADVANCE_TIME(1100) + +# Should reconnect successfully on third attempt +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Successfully connected after multiple SUSPENDED retries +ASSERT client.connection.state == ConnectionState.connected + +# Multiple connection attempts were made from SUSPENDED state +ASSERT connection_attempt_count >= 3 +CLOSE_CLIENT(client) +``` + +--- + +## RTN14g - ERROR protocol message with empty channel + +**Test ID**: `realtime/unit/RTN14g/error-empty-channel-failed-0` + +**Spec requirement:** If an ERROR ProtocolMessage with empty channel attribute is received, transition to FAILED state and set errorReason. + +Tests that fatal protocol errors cause FAILED state. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client_and_close(ProtocolMessage( + action: ERROR, + channel: null, # Empty channel = connection-level error + error: ErrorInfo( + code: 50000, + statusCode: 500, + message: "Internal server error" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for FAILED state +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Connection transitioned to FAILED +ASSERT client.connection.state == ConnectionState.failed + +# Error reason is set from protocol message +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 50000 +ASSERT client.connection.errorReason.statusCode == 500 +ASSERT client.connection.errorReason.message == "Internal server error" +CLOSE_CLIENT(client) +``` + +--- + +## Timer Mocking Note + +These tests use `enable_fake_timers()` and `ADVANCE_TIME()` to avoid slow tests. Implementations should: + +1. **Prefer fake timers** (JavaScript Jest, Python freezegun, Go testing.Clock) +2. **Or use dependency injection** for timer/clock interfaces +3. **Or use very short timeout values** (e.g., 50ms instead of 15s) +4. **Last resort:** Use actual delays with generous test timeouts + +See the "Timer Mocking" section in `write-test-spec.md` for detailed guidance. diff --git a/uts/realtime/unit/connection/connection_ping_test.md b/uts/realtime/unit/connection/connection_ping_test.md new file mode 100644 index 000000000..ea1e03ede --- /dev/null +++ b/uts/realtime/unit/connection/connection_ping_test.md @@ -0,0 +1,802 @@ +# Connection Ping Tests (RTN13) + +Spec points: `RTN13`, `RTN13a`, `RTN13b`, `RTN13c`, `RTN13d`, `RTN13e` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Overview + +RTN13 defines the `Connection#ping()` function: + +- **RTN13a**: Sends a `ProtocolMessage` with action `HEARTBEAT` and expects a `HEARTBEAT` response. Returns the round-trip duration. +- **RTN13b**: Returns an error if in, or transitions to, `INITIALIZED`, `SUSPENDED`, `CLOSING`, `CLOSED`, or `FAILED`. +- **RTN13c**: Fails with a timeout error if no `HEARTBEAT` response is received within `realtimeRequestTimeout`. +- **RTN13d**: If connection state is `CONNECTING` or `DISCONNECTED`, the operation is deferred and executed once the state becomes `CONNECTED`. +- **RTN13e**: The sent `HEARTBEAT` includes an `id` property with a random string. Only a response `HEARTBEAT` with a matching `id` is considered a valid response — this disambiguates from normal heartbeats and other pings. + +--- + +## RTN13a - Ping sends HEARTBEAT and returns round-trip duration + +**Test ID**: `realtime/unit/RTN13a/ping-heartbeat-roundtrip-0` + +| Spec | Requirement | +|------|-------------| +| RTN13a | Sends HEARTBEAT when connected and expects HEARTBEAT response with round-trip time | + +Tests that `connection.ping()` sends a HEARTBEAT protocol message and resolves with the elapsed duration when a matching HEARTBEAT response is received. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == HEARTBEAT: + # Echo back a HEARTBEAT with matching id + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: HEARTBEAT, + id: msg.id + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +duration = AWAIT client.connection.ping() +``` + +### Assertions +```pseudo +# Ping should resolve successfully +ASSERT duration IS NOT null +ASSERT duration >= Duration.zero + +# Verify a HEARTBEAT was sent by the client +heartbeats_sent = mock_ws.events.filter( + e => e.type == MESSAGE_FROM_CLIENT AND e.message.action == HEARTBEAT +) +ASSERT heartbeats_sent.length == 1 +CLOSE_CLIENT(client) +``` + +--- + +## RTN13e - HEARTBEAT includes random id for disambiguation + +**Test ID**: `realtime/unit/RTN13e/heartbeat-random-id-0` + +| Spec | Requirement | +|------|-------------| +| RTN13e | Sent HEARTBEAT includes random id; only matching response counts | + +Tests that the sent HEARTBEAT includes a random `id` and that only a response with the same `id` is accepted. + +### Setup +```pseudo +captured_heartbeat_id = null + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == HEARTBEAT: + captured_heartbeat_id = msg.id + # First send a HEARTBEAT with a DIFFERENT id (should be ignored) + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: HEARTBEAT, + id: "wrong-id" + )) + # Then send a HEARTBEAT with the matching id (should resolve ping) + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: HEARTBEAT, + id: msg.id + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +duration = AWAIT client.connection.ping() +``` + +### Assertions +```pseudo +# Ping should resolve (matched the correct id) +ASSERT duration IS NOT null +ASSERT duration >= Duration.zero + +# The sent HEARTBEAT should have had a non-empty id +ASSERT captured_heartbeat_id IS NOT null +ASSERT captured_heartbeat_id.length > 0 +CLOSE_CLIENT(client) +``` + +--- + +## RTN13e - HEARTBEAT with no id is ignored as ping response + +**Test ID**: `realtime/unit/RTN13e/no-id-heartbeat-ignored-1` + +| Spec | Requirement | +|------|-------------| +| RTN13e | Only a HEARTBEAT with matching id counts as a ping response | + +Tests that a server-initiated HEARTBEAT (no `id` field) does not resolve a pending ping. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == HEARTBEAT: + # Send a HEARTBEAT without an id (like a server-initiated heartbeat) + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: HEARTBEAT + )) + # Then send the correct response + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: HEARTBEAT, + id: msg.id + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +duration = AWAIT client.connection.ping() +``` + +### Assertions +```pseudo +# Ping should resolve (ignored the no-id heartbeat, matched the correct one) +ASSERT duration IS NOT null +ASSERT duration >= Duration.zero +CLOSE_CLIENT(client) +``` + +--- + +## RTN13e - Multiple concurrent pings each get their own response + +**Test ID**: `realtime/unit/RTN13e/concurrent-pings-unique-ids-2` + +| Spec | Requirement | +|------|-------------| +| RTN13e | Each ping has a unique random id for disambiguation | + +Tests that two concurrent pings each resolve independently via their unique ids. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == HEARTBEAT: + # Echo back with matching id + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: HEARTBEAT, + id: msg.id + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start two pings concurrently +ping1_future = client.connection.ping() +ping2_future = client.connection.ping() + +duration1 = AWAIT ping1_future +duration2 = AWAIT ping2_future +``` + +### Assertions +```pseudo +# Both pings should resolve +ASSERT duration1 IS NOT null +ASSERT duration2 IS NOT null + +# Verify two separate HEARTBEAT messages were sent +heartbeats_sent = mock_ws.events.filter( + e => e.type == MESSAGE_FROM_CLIENT AND e.message.action == HEARTBEAT +) +ASSERT heartbeats_sent.length == 2 + +# The two HEARTBEATs should have different ids +ASSERT heartbeats_sent[0].message.id != heartbeats_sent[1].message.id +CLOSE_CLIENT(client) +``` + +--- + +## RTN13c - Ping times out if no HEARTBEAT response + +**Test ID**: `realtime/unit/RTN13c/ping-timeout-0` + +| Spec | Requirement | +|------|-------------| +| RTN13c | Fails if HEARTBEAT not received within realtimeRequestTimeout | + +Tests that `ping()` fails with a timeout error if the server does not respond within `realtimeRequestTimeout`. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ) + # No onMessageFromClient handler — server never responds to HEARTBEAT +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 2000, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ping_future = client.connection.ping() + +# Advance time past realtimeRequestTimeout +ADVANCE_TIME(2100) + +error = AWAIT_ERROR ping_future +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +# The error should indicate a timeout +ASSERT error.message CONTAINS "timeout" (case insensitive) +CLOSE_CLIENT(client) +``` + +--- + +## RTN13b - Ping errors in INITIALIZED state + +**Test ID**: `realtime/unit/RTN13b/ping-error-initialized-0` + +| Spec | Requirement | +|------|-------------| +| RTN13b | Error if in INITIALIZED, SUSPENDED, CLOSING, CLOSED, or FAILED state | + +Tests that `ping()` returns an error immediately when the connection is in INITIALIZED state. + +### Setup +```pseudo +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +ASSERT client.connection.state == ConnectionState.initialized + +error = AWAIT_ERROR client.connection.ping() +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RTN13b - Ping errors in SUSPENDED state + +**Test ID**: `realtime/unit/RTN13b/ping-error-suspended-1` + +| Spec | Requirement | +|------|-------------| +| RTN13b | Error if in INITIALIZED, SUSPENDED, CLOSING, CLOSED, or FAILED state | + +Tests that `ping()` returns an error when the connection is in SUSPENDED state. + +### Setup +```pseudo +enable_fake_timers() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_refused() +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + disconnectedRetryTimeout: 1000, + suspendedRetryTimeout: 100, + fallbackHosts: [] +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Advance past connectionStateTtl to reach SUSPENDED +ADVANCE_TIME(121s) +AWAIT_STATE client.connection.state == ConnectionState.suspended + +error = AWAIT_ERROR client.connection.ping() +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RTN13b - Ping errors in CLOSED state + +**Test ID**: `realtime/unit/RTN13b/ping-error-closed-2` + +| Spec | Requirement | +|------|-------------| +| RTN13b | Error if in INITIALIZED, SUSPENDED, CLOSING, CLOSED, or FAILED state | + +Tests that `ping()` returns an error when the connection is in CLOSED state. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT client.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + +error = AWAIT_ERROR client.connection.ping() +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RTN13b - Ping errors in FAILED state + +**Test ID**: `realtime/unit/RTN13b/ping-error-failed-3` + +| Spec | Requirement | +|------|-------------| +| RTN13b | Error if in INITIALIZED, SUSPENDED, CLOSING, CLOSED, or FAILED state | + +Tests that `ping()` returns an error when the connection is in FAILED state. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_error( + ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: 80000, statusCode: 400, message: "Fatal error") + ) + ) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.failed + +error = AWAIT_ERROR client.connection.ping() +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RTN13d - Ping deferred from CONNECTING state until CONNECTED + +**Test ID**: `realtime/unit/RTN13d/ping-deferred-connecting-0` + +| Spec | Requirement | +|------|-------------| +| RTN13d | If CONNECTING or DISCONNECTED, execute ping once CONNECTED | + +Tests that calling `ping()` while CONNECTING defers the operation until the connection becomes CONNECTED, then sends the HEARTBEAT and resolves. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Delay the CONNECTED response so we can call ping() while CONNECTING + SCHEDULE_AFTER(100ms): + conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ) + }, + onMessageFromClient: (msg) => { + IF msg.action == HEARTBEAT: + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: HEARTBEAT, + id: msg.id + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +ASSERT client.connection.state == ConnectionState.connecting + +# Call ping() while still CONNECTING +ping_future = client.connection.ping() + +# Advance time so the connection completes +ADVANCE_TIME(200ms) +AWAIT_STATE client.connection.state == ConnectionState.connected + +duration = AWAIT ping_future +``` + +### Assertions +```pseudo +# Ping should resolve after connection was established +ASSERT duration IS NOT null +ASSERT duration >= Duration.zero + +# Verify HEARTBEAT was sent (only after CONNECTED) +heartbeats_sent = mock_ws.events.filter( + e => e.type == MESSAGE_FROM_CLIENT AND e.message.action == HEARTBEAT +) +ASSERT heartbeats_sent.length == 1 +CLOSE_CLIENT(client) +``` + +--- + +## RTN13d - Ping deferred from DISCONNECTED state until CONNECTED + +**Test ID**: `realtime/unit/RTN13d/ping-deferred-disconnected-1` + +| Spec | Requirement | +|------|-------------| +| RTN13d | If CONNECTING or DISCONNECTED, execute ping once CONNECTED | + +Tests that calling `ping()` while DISCONNECTED defers the operation until the connection reconnects, then sends the HEARTBEAT and resolves. + +### Setup +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + IF connection_attempt_count == 1: + # First attempt: connect successfully + conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ) + ELSE: + # Subsequent attempts: also connect successfully + conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-2", connectionKey: "conn-key-2") + ) + }, + onMessageFromClient: (msg) => { + IF msg.action == HEARTBEAT: + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: HEARTBEAT, + id: msg.id + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: 500, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Force disconnect by closing the transport +mock_ws.active_connection.close_from_server() +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Call ping() while DISCONNECTED +ping_future = client.connection.ping() + +# Advance time past disconnectedRetryTimeout so reconnection happens +ADVANCE_TIME(600ms) +AWAIT_STATE client.connection.state == ConnectionState.connected + +duration = AWAIT ping_future +``` + +### Assertions +```pseudo +# Ping should resolve after reconnection +ASSERT duration IS NOT null +ASSERT duration >= Duration.zero + +# Verify HEARTBEAT was sent +heartbeats_sent = mock_ws.events.filter( + e => e.type == MESSAGE_FROM_CLIENT AND e.message.action == HEARTBEAT +) +ASSERT heartbeats_sent.length == 1 +CLOSE_CLIENT(client) +``` + +--- + +## RTN13b - Deferred ping errors if connection transitions to FAILED + +**Test ID**: `realtime/unit/RTN13b/deferred-ping-error-failed-4` + +| Spec | Requirement | +|------|-------------| +| RTN13b | Error if has transitioned to INITIALIZED, SUSPENDED, CLOSING, CLOSED, or FAILED | +| RTN13d | Deferred ping from CONNECTING state | + +Tests that a ping deferred from CONNECTING state fails with an error if the connection transitions to FAILED instead of CONNECTED. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Respond with fatal error instead of CONNECTED + SCHEDULE_AFTER(100ms): + conn.respond_with_error( + ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: 80000, statusCode: 400, message: "Fatal error") + ) + ) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +ASSERT client.connection.state == ConnectionState.connecting + +# Call ping() while CONNECTING +ping_future = client.connection.ping() + +# Advance time so the error response arrives +ADVANCE_TIME(200ms) +AWAIT_STATE client.connection.state == ConnectionState.failed + +error = AWAIT_ERROR ping_future +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RTN13b - Deferred ping errors if connection transitions to SUSPENDED + +**Test ID**: `realtime/unit/RTN13b/deferred-ping-error-suspended-5` + +| Spec | Requirement | +|------|-------------| +| RTN13b | Error if has transitioned to INITIALIZED, SUSPENDED, CLOSING, CLOSED, or FAILED | +| RTN13d | Deferred ping from CONNECTING/DISCONNECTED state | + +Tests that a ping deferred from DISCONNECTED state fails with an error if the connection transitions to SUSPENDED instead of reconnecting. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_refused() +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + disconnectedRetryTimeout: 1000, + suspendedRetryTimeout: 100, + fallbackHosts: [] +)) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Call ping() while DISCONNECTED +ping_future = client.connection.ping() + +# Advance past connectionStateTtl to reach SUSPENDED +ADVANCE_TIME(121s) +AWAIT_STATE client.connection.state == ConnectionState.suspended + +error = AWAIT_ERROR ping_future +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +CLOSE_CLIENT(client) +``` + +--- + +## RTN13c - Deferred ping times out after realtimeRequestTimeout from CONNECTED + +**Test ID**: `realtime/unit/RTN13c/deferred-ping-timeout-1` + +| Spec | Requirement | +|------|-------------| +| RTN13c | Fails if HEARTBEAT not received within realtimeRequestTimeout | +| RTN13d | Deferred ping from CONNECTING state | + +Tests that a ping deferred from CONNECTING state still times out based on `realtimeRequestTimeout` after the connection becomes CONNECTED (the timeout starts when the HEARTBEAT is actually sent, not when `ping()` is called). + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + SCHEDULE_AFTER(100ms): + conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ) + } + # No onMessageFromClient — server never responds to HEARTBEAT +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 2000, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +ASSERT client.connection.state == ConnectionState.connecting + +# Call ping() while CONNECTING +ping_future = client.connection.ping() + +# Advance time so connection completes +ADVANCE_TIME(200ms) +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Advance time past realtimeRequestTimeout +ADVANCE_TIME(2100) + +error = AWAIT_ERROR ping_future +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.message CONTAINS "timeout" (case insensitive) +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/connection/connection_recovery_test.md b/uts/realtime/unit/connection/connection_recovery_test.md new file mode 100644 index 000000000..7b1e8aafc --- /dev/null +++ b/uts/realtime/unit/connection/connection_recovery_test.md @@ -0,0 +1,643 @@ +# Connection Recovery Tests (RTN16) + +Spec points: `RTN16d`, `RTN16f`, `RTN16f1`, `RTN16g`, `RTN16g1`, `RTN16g2`, `RTN16i`, `RTN16j`, `RTN16k`, `RTN16l` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTN16g, RTN16g1 - createRecoveryKey returns string with connectionKey, msgSerial, and channel/channelSerial pairs + +**Test ID**: `realtime/unit/RTN16g/recovery-key-structure-0` + +| Spec | Requirement | +|------|-------------| +| RTN16g | `Connection#createRecoveryKey` returns a string incorporating the connectionKey, current msgSerial, and channel name/channelSerial pairs for every attached channel | +| RTN16g1 | The recovery key must be serialized in a way that can encode any unicode channel name | + +Tests that `createRecoveryKey()` returns a correctly structured recovery key containing the connection key, message serial, and channel serials for attached channels, including channels with unicode names. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-abc-123", + connectionDetails: ConnectionDetails( + connectionKey: "key-abc-123", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Get the WebSocket connection for sending mock responses +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection + +# Get two channels and simulate attaching them (including one with unicode name) +channel_a = client.channels.get("channel-alpha") +channel_b = client.channels.get("channel-éàü-世界") + +# Attach channel_a +channel_a.attach() +ws_connection.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: "channel-alpha", + channelSerial: "serial-a-001" +)) +AWAIT_STATE channel_a.state == ChannelState.attached + +# Attach channel_b (unicode name) +channel_b.attach() +ws_connection.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: "channel-éàü-世界", + channelSerial: "serial-b-002" +)) +AWAIT_STATE channel_b.state == ChannelState.attached + +# Create recovery key +recovery_key_string = client.connection.createRecoveryKey() +``` + +### Assertions + +```pseudo +# Recovery key is not null +ASSERT recovery_key_string IS NOT null + +# Deserialize the recovery key (JSON format per ably-js reference) +recovery_key = fromJson(recovery_key_string) + +# Contains connectionKey +ASSERT recovery_key["connectionKey"] == "key-abc-123" + +# Contains msgSerial (starts at 0 since no messages were sent) +ASSERT recovery_key["msgSerial"] == 0 + +# Contains channelSerials map with both channels +ASSERT recovery_key["channelSerials"] IS NOT null +ASSERT recovery_key["channelSerials"]["channel-alpha"] == "serial-a-001" + +# RTN16g1: Unicode channel name is correctly encoded in the serialized key +ASSERT recovery_key["channelSerials"]["channel-éàü-世界"] == "serial-b-002" + +# Verify round-trip: re-serializing and deserializing preserves the unicode name +re_serialized = toJson(recovery_key) +re_parsed = fromJson(re_serialized) +ASSERT re_parsed["channelSerials"]["channel-éàü-世界"] == "serial-b-002" + +CLOSE_CLIENT(client) +``` + +--- + +## RTN16g2 - createRecoveryKey returns null in inactive states and before first connect + +**Test ID**: `realtime/unit/RTN16g2/recovery-key-null-inactive-0` + +**Spec requirement:** `createRecoveryKey()` should return null when the SDK is in the CLOSED, CLOSING, FAILED, or SUSPENDED states, or when it does not have a connectionKey (e.g. before first connect). + +Tests that `createRecoveryKey()` returns null in all the specified states. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Before connecting (INITIALIZED state, no connectionKey) +ASSERT client.connection.createRecoveryKey() IS null + +# Connect and verify recovery key is available when CONNECTED +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +ASSERT client.connection.createRecoveryKey() IS NOT null + +# Transition to CLOSING then CLOSED +client.connection.close() +AWAIT_STATE client.connection.state == ConnectionState.closing +ASSERT client.connection.createRecoveryKey() IS null + +AWAIT_STATE client.connection.state == ConnectionState.closed +ASSERT client.connection.createRecoveryKey() IS null +``` + +### Assertions + +```pseudo +# All null cases verified inline above. +# For FAILED and SUSPENDED states, create separate clients to test: + +# --- Test FAILED state --- +mock_ws_failed = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "conn-f", + connectionKey: "key-f", + connectionDetails: ConnectionDetails( + connectionKey: "key-f", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws_failed) + +client_failed = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +client_failed.connect() +AWAIT_STATE client_failed.connection.state == ConnectionState.connected + +# Trigger FAILED via fatal ERROR +ws_conn = mock_ws_failed.events.find(e => e.type == CONNECTION_SUCCESS).connection +ws_conn.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: 50000, statusCode: 500, message: "Fatal error") +)) +AWAIT_STATE client_failed.connection.state == ConnectionState.failed +ASSERT client_failed.connection.createRecoveryKey() IS null + +# --- Test SUSPENDED state --- +mock_ws_suspended = MockWebSocket( + onConnectionAttempt: (conn) => { + # All connections fail after initial, to force SUSPENDED + IF connection_attempt_count == 1: + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "conn-s", + connectionKey: "key-s", + connectionDetails: ConnectionDetails( + connectionKey: "key-s", + maxIdleInterval: 15000, + connectionStateTtl: 2000 + ) + )) + ELSE: + conn.respond_with_refused() + } +) +install_mock(mock_ws_suspended) + +client_suspended = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: 500, + autoConnect: false, + fallbackHosts: [] +)) + +enable_fake_timers() + +client_suspended.connect() +AWAIT_STATE client_suspended.connection.state == ConnectionState.connected + +ws_conn_s = mock_ws_suspended.events.find(e => e.type == CONNECTION_SUCCESS).connection +ws_conn_s.simulate_disconnect() + +# Advance time until SUSPENDED (connectionStateTtl expires) +LOOP up to 10 times: + ADVANCE_TIME(1500) + IF client_suspended.connection.state == ConnectionState.suspended: + BREAK + +AWAIT_STATE client_suspended.connection.state == ConnectionState.suspended +ASSERT client_suspended.connection.createRecoveryKey() IS null + +CLOSE_CLIENT(client_suspended) +``` + +--- + +## RTN16k - recover option adds recover query param to WebSocket URL + +**Test ID**: `realtime/unit/RTN16k/recover-query-param-0` + +**Spec requirement:** When instantiated with the `recover` client option, the library should add a `recover` querystring param (set from the connectionKey component of the recoveryKey) to the first WebSocket request. After successful connection, it should never again supply a `recover` param. + +Tests that the `recover` query parameter is sent on the first connection and not on subsequent reconnections. + +### Setup + +```pseudo +connection_attempt_count = 0 +captured_connection_attempts = [] + +# Construct a valid recoveryKey +recovery_key = toJson({ + "connectionKey": "recovered-key-xyz", + "msgSerial": 5, + "channelSerials": {} +}) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + captured_connection_attempts.append(conn) + conn.respond_with_success() + + IF connection_attempt_count == 1: + # First connection: successful recovery (same connectionId as implied by recoveryKey) + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "recovered-conn-id", + connectionKey: "new-key-after-recovery", + connectionDetails: ConnectionDetails( + connectionKey: "new-key-after-recovery", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + ELSE: + # Subsequent connection: resume after disconnect + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "recovered-conn-id", + connectionKey: "resumed-key", + connectionDetails: ConnectionDetails( + connectionKey: "resumed-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + recover: recovery_key, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect - should use recover param +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Simulate disconnect and reconnection +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection +ws_connection.simulate_disconnect() + +# Wait for resume reconnection +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# First connection attempt includes recover param with connectionKey from recoveryKey +ASSERT captured_connection_attempts[0].url.query_params["recover"] == "recovered-key-xyz" + +# First connection attempt does NOT include resume param +ASSERT "resume" NOT IN captured_connection_attempts[0].url.query_params + +# Second connection attempt uses resume (not recover) since client is now connected +ASSERT captured_connection_attempts[1].url.query_params["resume"] == "new-key-after-recovery" +ASSERT "recover" NOT IN captured_connection_attempts[1].url.query_params + +CLOSE_CLIENT(client) +``` + +--- + +## RTN16f - recover option initializes msgSerial from recoveryKey + +**Test ID**: `realtime/unit/RTN16f/recover-initializes-msgserial-0` + +**Spec requirement:** When instantiated with the `recover` client option, the library should initialize its internal msgSerial counter to the msgSerial component of the recoveryKey. If recover fails, the counter should be reset to 0 per RTN15c7. + +Tests that the msgSerial is initialized from the recoveryKey and reset on recovery failure. + +### Setup + +```pseudo +connection_attempt_count = 0 +captured_messages = [] + +# Construct a recoveryKey with msgSerial of 42 +recovery_key = toJson({ + "connectionKey": "old-key", + "msgSerial": 42, + "channelSerials": { + "test-channel": "ch-serial-1" + } +}) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success() + + IF connection_attempt_count == 1: + # Successful recovery + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "recovered-conn", + connectionKey: "new-key", + connectionDetails: ConnectionDetails( + connectionKey: "new-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + recover: recovery_key, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect with recovery +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Get WebSocket connection reference +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection + +# Attach the recovered channel +channel = client.channels.get("test-channel") +channel.attach() +ws_connection.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: "test-channel", + channelSerial: "ch-serial-updated" +)) +AWAIT_STATE channel.state == ChannelState.attached + +# Publish a message - the msgSerial should start from the recovered value (42) +channel.publish("event", "data") + +# Capture the MESSAGE frame sent by the client +sent_frames = mock_ws.events.filter(e => e.type == "ws_frame" AND e.direction == "client_to_server") +message_frame = sent_frames.find(f => f.message.action == MESSAGE) + +# ACK the message +ws_connection.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: 42, + count: 1 +)) +``` + +### Assertions + +```pseudo +# The first message published uses msgSerial from the recoveryKey +ASSERT message_frame IS NOT null +ASSERT message_frame.message.msgSerial == 42 + +CLOSE_CLIENT(client) +``` + +--- + +## RTN16f1 - Malformed recoveryKey logs error and connects normally + +**Test ID**: `realtime/unit/RTN16f1/malformed-recovery-key-0` + +**Spec requirement:** If the recovery key provided in the `recover` client option cannot be deserialized due to malformed data, then an error should be logged and the connection should be made like no `recover` option was provided. + +Tests that a malformed recoveryKey is handled gracefully: the connection proceeds normally without the `recover` query parameter. + +### Setup + +```pseudo +connection_attempt_count = 0 +captured_connection_attempts = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + captured_connection_attempts.append(conn) + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "fresh-conn", + connectionKey: "fresh-key", + connectionDetails: ConnectionDetails( + connectionKey: "fresh-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +# Use a malformed (non-JSON) recover string +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + recover: "this-is-not-valid-json!!!", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect - should proceed as a normal connection (no recover param) +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# Connection succeeded normally +ASSERT client.connection.state == ConnectionState.connected +ASSERT client.connection.id == "fresh-conn" +ASSERT client.connection.key == "fresh-key" + +# No recover param was sent (malformed key was rejected) +ASSERT "recover" NOT IN captured_connection_attempts[0].url.query_params + +# Also no resume param (this is a fresh connection) +ASSERT "resume" NOT IN captured_connection_attempts[0].url.query_params + +# Only one connection attempt (normal connection, no retries) +ASSERT connection_attempt_count == 1 + +CLOSE_CLIENT(client) +``` + +> **Implementation note:** The spec requires that an error be logged when the recovery +> key is malformed. Implementations should verify this by capturing log output (e.g., +> via a log handler) and asserting that an error-level log message was emitted mentioning +> the malformed recovery key. The exact mechanism for capturing logs is implementation-specific. + +--- + +## RTN16j - recover option instantiates channels from recoveryKey with correct channelSerials + +**Test ID**: `realtime/unit/RTN16j/recover-channel-serials-0` + +| Spec | Requirement | +|------|-------------| +| RTN16j | When instantiated with the `recover` client option, for every channel/channelSerial pair in the recoveryKey, the library should instantiate a corresponding channel and set its channelSerial (RTL15b) | + +Tests that channels listed in the recoveryKey are pre-instantiated with their channel serials before the connection is established. + +### Setup + +```pseudo +connection_attempt_count = 0 + +# Construct a recoveryKey with multiple channels +recovery_key = toJson({ + "connectionKey": "old-key-abc", + "msgSerial": 10, + "channelSerials": { + "channel-one": "serial-1-abc", + "channel-two": "serial-2-def", + "channel-üñîçöðé": "serial-3-unicode" + } +}) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "recovered-conn", + connectionKey: "new-key", + connectionDetails: ConnectionDetails( + connectionKey: "new-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + recover: recovery_key, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect with recovery +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# RTN16j: Channels from the recoveryKey are instantiated +channel_one = client.channels.get("channel-one") +channel_two = client.channels.get("channel-two") +channel_unicode = client.channels.get("channel-üñîçöðé") + +# Each channel has its channelSerial set from the recoveryKey +ASSERT channel_one.properties.channelSerial == "serial-1-abc" +ASSERT channel_two.properties.channelSerial == "serial-2-def" +ASSERT channel_unicode.properties.channelSerial == "serial-3-unicode" + +# RTN16i: Channels are NOT automatically attached — the user must explicitly attach them. +# They should be in INITIALIZED state (the library instantiated them but didn't attach). +ASSERT channel_one.state == ChannelState.initialized +ASSERT channel_two.state == ChannelState.initialized +ASSERT channel_unicode.state == ChannelState.initialized + +# When the user attaches, the ATTACH message should include the channelSerial +# (this enables the server to resume the channel from the correct point) +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection + +channel_one.attach() + +# Capture the ATTACH frame sent by the client +sent_frames = mock_ws.events.filter(e => + e.type == "ws_frame" AND + e.direction == "client_to_server" AND + e.message.action == ATTACH AND + e.message.channel == "channel-one" +) +ASSERT sent_frames.length == 1 +ASSERT sent_frames[0].message.channelSerial == "serial-1-abc" + +# Complete the attachment +ws_connection.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: "channel-one", + channelSerial: "serial-1-abc-updated" +)) +AWAIT_STATE channel_one.state == ChannelState.attached + +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/connection/error_reason_test.md b/uts/realtime/unit/connection/error_reason_test.md new file mode 100644 index 000000000..81b855dd9 --- /dev/null +++ b/uts/realtime/unit/connection/error_reason_test.md @@ -0,0 +1,467 @@ +# Connection errorReason Tests (RTN25) + +Spec point: `RTN25` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTN25 - errorReason set on connection errors + +**Test ID**: `realtime/unit/RTN25/error-reason-on-failed-0` + +**Spec requirement:** Connection#errorReason attribute is an optional ErrorInfo object which is set by the library when an error occurs on the connection, as described by RSA4c1, RSA4d, RTN11d, RTN14a, RTN14b, RTN14e, RTN14g, RTN15c7, RTN15c4, RTN15d, RTN15h, RTN15i, RTN16e. + +Tests that errorReason is populated correctly across various error scenarios. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40005, + statusCode: 400, + message: "Invalid API key" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "invalid.key:secret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Initially errorReason should be null +ASSERT client.connection.errorReason IS null + +# Start connection +client.connect() + +# Wait for FAILED state +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# errorReason is set with error details +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 40005 +ASSERT client.connection.errorReason.statusCode == 400 +ASSERT client.connection.errorReason.message == "Invalid API key" +CLOSE_CLIENT(client) +``` + +--- + +## RTN25 - errorReason on DISCONNECTED state (RTN14e) + +**Test ID**: `realtime/unit/RTN25/error-reason-disconnected-1` + +**Spec requirement:** errorReason is set when connection enters DISCONNECTED state due to connection failure. + +Tests that errorReason is populated when transitioning to DISCONNECTED. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Connection attempt fails + conn.respond_with_refused() + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for DISCONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# errorReason is set +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.message IS NOT null + +# Error indicates connection failure +# (Exact error code/message depends on implementation) +CLOSE_CLIENT(client) +``` + +--- + +## RTN25 - errorReason on SUSPENDED state (RTN14e) + +**Test ID**: `realtime/unit/RTN25/error-reason-suspended-2` + +**Spec requirement:** errorReason is updated when connection enters SUSPENDED state after connectionStateTtl expires. + +Tests that errorReason reflects suspension reason. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # All connection attempts fail + conn.respond_with_refused() + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: 500, + autoConnect: false +)) + +DEFAULT_CONNECTION_STATE_TTL = 5000 # 5 seconds +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Start connection (will fail) +client.connect() + +# Wait for DISCONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Advance time past connectionStateTtl +ADVANCE_TIME(DEFAULT_CONNECTION_STATE_TTL + 100) + +# Wait for SUSPENDED state +AWAIT_STATE client.connection.state == ConnectionState.suspended + WITH timeout: 1 second +``` + +### Assertions + +```pseudo +# errorReason is set and indicates suspension +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.message IS NOT null + +# Error should indicate timeout or suspension reason +# (Exact error code/message depends on implementation) +CLOSE_CLIENT(client) +``` + +--- + +## RTN25 - errorReason on token errors (RTN14b, RSA4a) + +**Test ID**: `realtime/unit/RTN25/error-reason-token-error-3` + +**Spec requirement:** When an ERROR ProtocolMessage with a token error is received during connection and there is no means to renew the token, RSA4a applies: the connection transitions to FAILED with error code 40171. + +Tests that errorReason is set with the 40171 wrapper error when a non-renewable token fails. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40142, + statusCode: 401, + message: "Token expired" + ) + )) + } +) +install_mock(mock_ws) + +# Use token directly (no way to renew) — RSA4a applies +client = Realtime(options: ClientOptions( + token: "expired_token", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Per RSA4a2: no means to renew → FAILED state +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# errorReason should indicate no means to renew (RSA4a2: error code 40171) +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 40171 +CLOSE_CLIENT(client) +``` + +--- + +## RTN25 - errorReason cleared on successful connection + +**Test ID**: `realtime/unit/RTN25/error-reason-cleared-on-connect-4` + +**Spec requirement:** errorReason should be cleared when connection successfully recovers. + +Tests that errorReason is reset after successful connection following a failure. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + IF connection_attempt_count == 1: + # First attempt fails + conn.respond_with_refused() + ELSE: + # Second attempt succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: 100, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Start connection (will fail initially) +client.connect() + +# Wait for DISCONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds + +# errorReason should be set after failure +ASSERT client.connection.errorReason IS NOT null +failure_error = client.connection.errorReason + +# Advance time to trigger retry +ADVANCE_TIME(150) + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# errorReason should be cleared after successful connection +# Note: Specification doesn't explicitly require this, but it's common practice +# Some implementations may keep the last error for debugging purposes +# Verify implementation behavior: + +# Either: +# A) errorReason is cleared on successful connection +ASSERT client.connection.errorReason IS null + +# Or: +# B) errorReason is kept but clearly not relevant to current state +# (Implementation-specific behavior) +CLOSE_CLIENT(client) +``` + +--- + +## RTN25 - errorReason on protocol-level ERROR message (RTN14g) + +**Test ID**: `realtime/unit/RTN25/error-reason-protocol-error-5` + +**Spec requirement:** errorReason is set when ERROR ProtocolMessage with empty channel is received. + +Tests that connection-level protocol errors populate errorReason. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client_and_close(ProtocolMessage( + action: ERROR, + channel: null, # Empty channel = connection-level error + error: ErrorInfo( + code: 50000, + statusCode: 500, + message: "Internal server error" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for FAILED state +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# errorReason is set from ERROR protocol message +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 50000 +ASSERT client.connection.errorReason.statusCode == 500 +ASSERT client.connection.errorReason.message == "Internal server error" +CLOSE_CLIENT(client) +``` + +--- + +## RTN25 - errorReason propagated to ConnectionStateChange events + +**Test ID**: `realtime/unit/RTN25/error-reason-in-state-change-6` + +**Spec requirement:** errorReason should be accessible through ConnectionStateChange events emitted during state transitions. + +Tests that state change events include error information. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40003, + statusCode: 400, + message: "Access token invalid" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Track state changes +state_changes = [] + +client.connection.on(ConnectionState.failed, (change) => { + state_changes.push(change) +}) + +# Start connection +client.connect() + +# Wait for FAILED state +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# State change event was emitted +ASSERT state_changes.length == 1 + +change = state_changes[0] + +# State change has reason populated +ASSERT change.reason IS NOT null +ASSERT change.reason.code == 40003 +ASSERT change.reason.statusCode == 400 +ASSERT change.reason.message == "Access token invalid" + +# Connection errorReason matches state change reason +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == change.reason.code +ASSERT client.connection.errorReason.message == change.reason.message +CLOSE_CLIENT(client) +``` + +--- + +## Note on errorReason Lifecycle + +The errorReason attribute behavior across different implementations: + +1. **Set on error**: Always populated when an error causes a state transition +2. **Cleared on success**: May or may not be cleared on successful connection (implementation-specific) +3. **Accessible via**: Both `Connection#errorReason` attribute and `ConnectionStateChange#reason` +4. **Persistence**: Some implementations keep the last error for debugging, others clear it +5. **NULL vs defined**: Initially null before any errors occur + +Test implementations should verify their SDK's specific behavior regarding errorReason lifecycle. diff --git a/uts/realtime/unit/connection/fallback_hosts_test.md b/uts/realtime/unit/connection/fallback_hosts_test.md new file mode 100644 index 000000000..28e438af4 --- /dev/null +++ b/uts/realtime/unit/connection/fallback_hosts_test.md @@ -0,0 +1,694 @@ +# Fallback Hosts Tests (RTN17) + +Spec points: `RTN17`, `RTN17e`, `RTN17f`, `RTN17f1`, `RTN17g`, `RTN17h`, `RTN17i`, `RTN17j` + +## Test Type +Unit test with mocked WebSocket client and HTTP client + +## Mock Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. +See `uts/test/rest/unit/helpers/mock_http.md` for Mock HTTP Client specification. + +--- + +## RTN17i - Always prefer primary domain first + +**Test ID**: `realtime/unit/RTN17i/prefer-primary-domain-0` + +**Spec requirement:** By default, every connection attempt is first attempted to the primary domain. The client library must always prefer the primary domain, even if a previous connection attempt to that endpoint has failed. + +Tests that the client always tries the primary domain first, even after failures. + +### Setup + +```pseudo +channel_name = "test-RTN17i-${random_id()}" +connection_attempts = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Record which host was attempted + connection_attempts.push({ + host: conn.url.host, + attempt_number: connection_attempts.length + 1 + }) + + IF connection_attempts.length == 1: + # First attempt (to primary): fail + conn.respond_with_refused() + ELSE IF connection_attempts.length == 2: + # Second attempt (to fallback): succeed + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# First connection attempt +client.connect() + +# Wait for successful connection (after trying primary then fallback) +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +# Now force a disconnection +mock_ws.active_connection.close() + +# Wait for DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds + +# Clear previous attempts +connection_attempts.clear() + +# Allow next connection to primary to succeed +mock_ws.onConnectionAttempt = (conn) => { + connection_attempts.push({ + host: conn.url.host, + attempt_number: connection_attempts.length + 1 + }) + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-2", + connectionKey: "connection-key-2", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) +} + +# Wait for automatic reconnection +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +``` + +### Assertions + +```pseudo +# The reconnection attempt should have tried primary domain first +ASSERT connection_attempts.length >= 1 +ASSERT connection_attempts[0].host == "realtime.ably.io" + OR connection_attempts[0].host CONTAINS "realtime.ably" # Primary domain +CLOSE_CLIENT(client) +``` + +--- + +## RTN17f - Errors that necessitate fallback host usage + +**Test ID**: `realtime/unit/RTN17f/fallback-on-error-0` + +**Spec requirement:** Errors that necessitate use of an alternative host include conditions specified in RSC15l and also DISCONNECTED responses with error.statusCode in range 500-504. + +Tests that specific error conditions trigger fallback host usage. + +### Setup + +```pseudo +connection_attempts = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempts.push(conn.url.host) + + IF connection_attempts.length == 1: + # Primary domain: unresolvable (simulated) + conn.respond_with_error("Host unresolvable") + ELSE: + # Fallback domain: succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for successful connection via fallback +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +``` + +### Assertions + +```pseudo +# Should have tried at least 2 hosts (primary + fallback) +ASSERT connection_attempts.length >= 2 + +# First attempt was to primary domain +ASSERT connection_attempts[0] CONTAINS "realtime.ably" + +# Second attempt was to a fallback domain +ASSERT connection_attempts[1] CONTAINS "fallback" +CLOSE_CLIENT(client) +``` + +--- + +## RTN17f1 - DISCONNECTED with 5xx status triggers fallback + +**Test ID**: `realtime/unit/RTN17f1/disconnected-5xx-fallback-0` + +**Spec requirement:** A DISCONNECTED response with an error.statusCode in the range 500 <= code <= 504 necessitates use of an alternative host. + +Tests that 5xx errors in DISCONNECTED messages trigger fallback. + +### Setup + +```pseudo +connection_attempts = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempts.push(conn.url.host) + + IF connection_attempts.length == 1: + # Primary domain: connect then send DISCONNECTED with 503 and close + conn.respond_with_success() + conn.send_to_client_and_close(ProtocolMessage( + action: DISCONNECTED, + error: ErrorInfo( + code: 50003, + statusCode: 503, + message: "Service temporarily unavailable" + ) + )) + ELSE: + # Fallback domain: succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for successful connection via fallback +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +``` + +### Assertions + +```pseudo +# Should have tried at least 2 hosts +ASSERT connection_attempts.length >= 2 + +# First was primary, second was fallback +ASSERT connection_attempts[0] CONTAINS "realtime.ably" +ASSERT connection_attempts[1] CONTAINS "fallback" +CLOSE_CLIENT(client) +``` + +--- + +## RTN17j - Connectivity check before fallback + +**Test ID**: `realtime/unit/RTN17j/connectivity-check-before-fallback-0` + +**Spec requirement:** In case of an error necessitating fallback, check connectivity by issuing GET to connectivityCheckUrl. If response includes "yes", proceed with fallback hosts in random order. + +Tests that connectivity check is performed before trying fallback hosts. + +### Setup + +```pseudo +channel_name = "test-RTN17j-${random_id()}" +http_requests = [] +connection_attempts = [] + +# Mock HTTP client for connectivity check +mock_http = MockHttpClient( + onRequest: (req) => { + http_requests.push({ + url: req.url.toString(), + method: req.method + }) + + IF req.url.toString() CONTAINS "internet-up": + # Connectivity check succeeds + req.respond_with(200, "yes", contentType: "text/plain") + ELSE: + # Token requests etc + req.respond_with(200, { + "token": "test_token", + "keyName": "appId.keyId", + "issued": time_now(), + "expires": time_now() + 3600000, + "capability": "{\"*\":[\"*\"]}" + }) + } +) +install_mock(mock_http) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempts.push(conn.url.host) + + IF connection_attempts.length == 1: + # Primary domain fails + conn.respond_with_timeout() + ELSE: + # Fallback succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for successful connection +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15 seconds +``` + +### Assertions + +```pseudo +# Connectivity check should have been performed +connectivity_checks = FILTER http_requests WHERE req.url CONTAINS "internet-up" +ASSERT connectivity_checks.length >= 1 + +# Connectivity check was a GET request +ASSERT connectivity_checks[0].method == "GET" + +# Connection attempts proceeded to fallback after check +ASSERT connection_attempts.length >= 2 +CLOSE_CLIENT(client) +``` + +--- + +## RTN17g - Empty fallback set results in immediate error + +**Test ID**: `realtime/unit/RTN17g/empty-fallback-set-error-0` + +**Spec requirement:** When the set of fallback domains is empty, failing requests that would have qualified for retry should result in an error immediately. + +Tests that no fallback is attempted when fallback set is empty. + +### Setup + +```pseudo +connection_attempts = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempts.push(conn.url.host) + # Connection fails + conn.respond_with_refused() + } +) +install_mock(mock_ws) + +# Use custom endpoint which results in empty fallback set (REC2c2) +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeHost: "custom.example.com", # Custom host = no fallbacks + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for DISCONNECTED (should not try fallbacks) +AWAIT_STATE client.connection.state IN [ConnectionState.disconnected, ConnectionState.failed] + WITH timeout: 5 seconds + +# Give it time to potentially try fallbacks (it shouldn't) +WAIT(2000) +``` + +### Assertions + +```pseudo +# Should have only tried the custom host once, no fallbacks +ASSERT connection_attempts.length == 1 +ASSERT connection_attempts[0] == "custom.example.com" +CLOSE_CLIENT(client) +``` + +--- + +## RTN17h - Fallback domains determined by REC2 + +**Test ID**: `realtime/unit/RTN17h/fallback-domains-from-rec2-0` + +**Spec requirement:** When fallbacks apply, the set of fallback domains is determined by REC2. + +Tests that correct fallback hosts are used based on configuration. + +### Setup + +```pseudo +connection_attempts = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempts.push(conn.url.host) + + IF connection_attempts.length == 1: + # Primary fails + conn.respond_with_refused() + ELSE: + # Fallback succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +# Use default configuration (should use default fallback hosts) +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for successful connection +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +``` + +### Assertions + +```pseudo +# Should have tried primary then fallback +ASSERT connection_attempts.length >= 2 + +# Second attempt should be a default fallback host +# Default fallback pattern: *.a|b|c|d|e.fallback.ably-realtime.com +fallback_host = connection_attempts[1] +ASSERT fallback_host CONTAINS "fallback.ably-realtime.com" +ASSERT fallback_host MATCHES /\.[abcde]\.fallback\.ably-realtime\.com$/ +CLOSE_CLIENT(client) +``` + +--- + +## RTN17j - Fallback hosts tried in random order + +**Test ID**: `realtime/unit/RTN17j/fallback-random-order-1` + +**Spec requirement:** Retry connection against fallback domains in random order to find an alternative healthy datacenter. + +Tests that fallback hosts are not always tried in the same order. + +### Setup + +```pseudo +# Run multiple test iterations to check randomness +fallback_orders = [] + +FOR iteration IN [1, 2, 3, 4, 5]: + connection_attempts = [] + + mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempts.push(conn.url.host) + + IF connection_attempts.length <= 3: + # Primary and first 2 fallbacks fail + conn.respond_with_refused() + ELSE: + # Third fallback succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } + ) + install_mock(mock_ws) + + client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false + )) + + client.connect() + + AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15 seconds + + # Record the order of fallback hosts (skip primary at index 0) + fallback_order = connection_attempts[1:] + fallback_orders.push(fallback_order) + + await client.close() +``` + +### Test Steps + +```pseudo +# Analyze the collected fallback orders +``` + +### Assertions + +```pseudo +# At least one iteration should have different order than another +# (This is probabilistic - with 5 iterations and 5 fallback hosts, +# we should see some variation) + +unique_orders = COUNT_UNIQUE(fallback_orders) +ASSERT unique_orders >= 2 + +# Note: This test may occasionally fail due to randomness +# In production, this should use a larger sample size +``` + +--- + +## RTN17e - HTTP requests use same fallback host as realtime connection + +**Test ID**: `realtime/unit/RTN17e/http-uses-same-fallback-0` + +**Spec requirement:** If the realtime client is connected to a fallback host, HTTP requests should first be attempted to the same datacenter. If the HTTP request fails, follow normal fallback behavior. + +Tests that HTTP requests prefer the same host as the active realtime connection. + +### Setup + +```pseudo +channel_name = "test-RTN17e-${random_id()}" +connection_attempts = [] +http_requests = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempts.push(conn.url.host) + + IF connection_attempts.length == 1: + # Primary fails + conn.respond_with_refused() + ELSE: + # Fallback succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +mock_http = MockHttpClient( + onRequest: (req) => { + http_requests.push({ + url: req.url.toString(), + host: req.url.host + }) + + # Respond successfully to HTTP requests + IF req.url.path CONTAINS "/history": + req.respond_with(200, { + "items": [], + "start": 0, + "end": 0 + }) + ELSE: + req.respond_with(200, {}) + } +) +install_mock(mock_http) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for successful connection to fallback +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +# Determine which fallback host we're connected to +connected_fallback_host = connection_attempts[1] + +# Make an HTTP request (e.g., channel history) +channel = client.channels.get(channel_name) +await channel.history() + +# Wait for HTTP request to complete +WAIT(500) +``` + +### Assertions + +```pseudo +# At least one HTTP request should have been made +history_requests = FILTER http_requests WHERE req.url CONTAINS "/history" +ASSERT history_requests.length >= 1 + +# HTTP request should have used the same fallback host +# Note: The exact host matching logic may vary by implementation +# Some SDKs may convert WebSocket host to REST host pattern +history_host = history_requests[0].host + +# Either: +# A) Exact match +ASSERT history_host == connected_fallback_host + +# Or: +# B) Same fallback datacenter (e.g., *.b.fallback.* matches) +# EXTRACT_FALLBACK_ID extracts the datacenter identifier from a fallback hostname. +# Realtime hosts: main..fallback.ably-realtime.com +# REST hosts: rest..fallback.ably-realtime.com +# The function returns the portion (e.g., "a", "b", "c", "d", "e"). +ASSERT EXTRACT_FALLBACK_ID(history_host) == EXTRACT_FALLBACK_ID(connected_fallback_host) +CLOSE_CLIENT(client) +``` + +--- + +## Implementation Notes + +Fallback host behavior involves several complex interactions: + +1. **Primary preference (RTN17i)**: Always try primary first, even after previous failures +2. **Error conditions (RTN17f)**: Only specific errors trigger fallback (host unreachable, timeout, 5xx) +3. **Connectivity check (RTN17j)**: Check internet connectivity before blaming Ably +4. **Randomization (RTN17j)**: Use fallbacks in random order to distribute load +5. **Empty fallback set (RTN17g)**: Custom hosts typically have no fallbacks +6. **HTTP coordination (RTN17e)**: REST and realtime should use same datacenter +7. **Configuration (RTN17h)**: Fallback set determined by REC2 rules + +Test implementations should verify their SDK correctly implements these behaviors. diff --git a/uts/realtime/unit/connection/forwards_compatibility_test.md b/uts/realtime/unit/connection/forwards_compatibility_test.md new file mode 100644 index 000000000..89d22c0e2 --- /dev/null +++ b/uts/realtime/unit/connection/forwards_compatibility_test.md @@ -0,0 +1,348 @@ +# Forwards Compatibility Tests (RTF1, RSF1) + +Spec points: `RTF1`, `RSF1` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Overview + +The Ably client library must apply the robustness principle to deserialization: + +- **RTF1**: ProtocolMessages and related types must tolerate unrecognised attributes (ignored) and unknown enum values (handled gracefully). +- **RSF1**: Messages and related types must tolerate unrecognised attributes (ignored) and unknown enum values (ignored). + +These tests verify that the library does not throw errors or crash when encountering unknown fields or enum values from the server, enabling forwards compatibility when the server adds new features. + +--- + +## RTF1 - ProtocolMessage with unrecognised attributes is deserialized without error + +**Test ID**: `realtime/unit/RTF1/unrecognised-attributes-ignored-0` + +**Spec requirement:** Deserialization of ProtocolMessages and related types must be tolerant to unrecognised attributes, which must be ignored. + +Tests that the client correctly processes a ProtocolMessage containing extra unknown fields that are not part of the current spec, without throwing errors. + +### Setup + +```pseudo +channel_name = "test-RTF1-extra-attrs-${random_id()}" +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +channel = client.channels.get(channel_name) +channel.subscribe((msg) => { + received_messages.append(msg) +}) +channel.attach() + +# Respond to ATTACH request +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 0 +)) +AWAIT_STATE channel.state == ChannelState.attached + +# Send a MESSAGE ProtocolMessage with extra unknown attributes. +# The raw JSON includes fields that don't exist in the current spec. +# The client must ignore these and process the message normally. +mock_ws.active_connection.send_to_client_raw({ + "action": 15, # MESSAGE + "channel": channel_name, + "messages": [ + { + "name": "test-event", + "data": "hello", + "serial": "msg-serial-1" + } + ], + "unknownField1": "some-future-value", + "unknownField2": 42, + "unknownNestedObject": { + "nestedKey": "nestedValue" + }, + "unknownArray": [1, 2, 3] +}) + +# Wait for the message to be delivered to the subscriber +poll_until( + () => received_messages.length >= 1, + interval: 100ms, + timeout: 5s +) +``` + +### Assertions + +```pseudo +# Message was delivered successfully despite unknown fields +ASSERT received_messages.length == 1 +ASSERT received_messages[0].name == "test-event" +ASSERT received_messages[0].data == "hello" + +# Connection remains healthy +ASSERT client.connection.state == ConnectionState.connected +ASSERT channel.state == ChannelState.attached +CLOSE_CLIENT(client) +``` + +> **Implementation note:** The `send_to_client_raw` method sends a raw JSON object +> directly to the client's WebSocket, bypassing the ProtocolMessage constructor. This +> is necessary because the standard `send_to_client(ProtocolMessage(...))` would strip +> unknown fields during construction. If `send_to_client_raw` is not available in the +> mock infrastructure, implementations can serialize a ProtocolMessage and inject +> additional fields into the JSON before sending, or modify the mock to support +> arbitrary extra fields. + +--- + +## RTF1 - ProtocolMessage with unknown action enum value is handled gracefully + +**Test ID**: `realtime/unit/RTF1/unknown-action-handled-1` + +**Spec requirement:** Deserialization of ProtocolMessages and associated enums must be tolerant to unknown enum values, which must be handled in some sensible, language-idiomatic way. + +Tests that the client does not crash or disconnect when receiving a ProtocolMessage with an action value that is not defined in the current spec. + +### Setup + +```pseudo +channel_name = "test-RTF1-unknown-action-${random_id()}" +state_changes = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + useBinaryProtocol: false +)) + +# Record connection state changes to detect unexpected disconnections +client.connection.on((change) => { + state_changes.append(change.current) +}) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Send a ProtocolMessage with an unknown action value. +# Action 254 is not defined in the current spec. +mock_ws.active_connection.send_to_client_raw({ + "action": 254, + "channel": channel_name, + "unknownPayload": "future-feature-data" +}) + +# Send a normal HEARTBEAT to verify the connection is still processing messages +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: HEARTBEAT +)) + +# Give the client time to process both messages +poll_until( + () => true, + interval: 100ms, + timeout: 1s +) +``` + +### Assertions + +```pseudo +# Connection should still be CONNECTED - the unknown action was silently ignored +ASSERT client.connection.state == ConnectionState.connected + +# No unexpected state transitions occurred (only the initial connecting -> connected) +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected +] +# Verify no disconnected or failed states appeared +ASSERT ConnectionState.disconnected NOT IN state_changes +ASSERT ConnectionState.failed NOT IN state_changes + +CLOSE_CLIENT(client) +``` + +--- + +## RSF1 - Message with unrecognised attributes is deserialized without error + +**Test ID**: `realtime/unit/RSF1/message-unrecognised-attrs-0` + +**Spec requirement:** Deserialization of Messages and related types, and associated enums, must be tolerant to unrecognised attributes or enum values. Such unrecognised values must be ignored. + +Tests that a Message containing extra unknown fields is delivered to subscribers without error, and the known fields are correctly parsed. + +### Setup + +```pseudo +channel_name = "test-RSF1-extra-attrs-${random_id()}" +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +channel = client.channels.get(channel_name) +channel.subscribe((msg) => { + received_messages.append(msg) +}) +channel.attach() + +# Respond to ATTACH request +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 0 +)) +AWAIT_STATE channel.state == ChannelState.attached + +# Send a MESSAGE ProtocolMessage where the individual messages within +# the messages array contain unknown fields. The ProtocolMessage itself +# is well-formed, but the Message objects have extra attributes. +mock_ws.active_connection.send_to_client_raw({ + "action": 15, # MESSAGE + "channel": channel_name, + "messages": [ + { + "name": "event-1", + "data": "payload-1", + "serial": "serial-1", + "futureField": "future-value", + "futureNumber": 99, + "futureObject": {"nested": true} + }, + { + "name": "event-2", + "data": "payload-2", + "serial": "serial-2", + "anotherUnknownField": [1, 2, 3] + } + ] +}) + +# Wait for both messages to be delivered +poll_until( + () => received_messages.length >= 2, + interval: 100ms, + timeout: 5s +) +``` + +### Assertions + +```pseudo +# Both messages were delivered successfully despite unknown fields +ASSERT received_messages.length == 2 + +# Known fields were correctly parsed +ASSERT received_messages[0].name == "event-1" +ASSERT received_messages[0].data == "payload-1" + +ASSERT received_messages[1].name == "event-2" +ASSERT received_messages[1].data == "payload-2" + +# Connection and channel remain healthy +ASSERT client.connection.state == ConnectionState.connected +ASSERT channel.state == ChannelState.attached +CLOSE_CLIENT(client) +``` + +--- + +## Implementation Notes + +### send_to_client_raw + +These tests require the ability to send raw JSON to the client's WebSocket connection, including fields that are not part of the ProtocolMessage or Message type definitions. The `send_to_client_raw` method on the mock connection accepts a raw JSON object (map/dictionary) and serializes it directly to the WebSocket, bypassing any type-safe constructors that would strip unknown fields. + +If the mock infrastructure does not support `send_to_client_raw`, alternatives include: +1. Constructing a JSON string manually and writing it to the mock WebSocket transport +2. Modifying the mock `send_to_client` to accept extra fields as an additional parameter +3. Using the language's serialization to add fields post-construction (e.g., adding to a Map after `toJson()`) + +### Enum Handling + +The RTF1 test for unknown action values verifies that the client does not crash. The exact handling of unknown enum values is language-idiomatic: +- **Dart/Swift/Kotlin**: May deserialize to a sentinel/unknown enum variant or null +- **JavaScript/Python**: May store the raw numeric value +- **All languages**: Must not throw an exception or disconnect diff --git a/uts/realtime/unit/connection/heartbeat_test.md b/uts/realtime/unit/connection/heartbeat_test.md new file mode 100644 index 000000000..82a434b49 --- /dev/null +++ b/uts/realtime/unit/connection/heartbeat_test.md @@ -0,0 +1,1210 @@ +# Heartbeat Tests (RTN23) + +Spec points: `RTN23`, `RTN23a`, `RTN23b` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Overview + +RTN23 defines how the client detects connection liveness: + +- **RTN23a**: The client must disconnect if no activity is received for `maxIdleInterval + realtimeRequestTimeout`. Any received message (or ping frame, per RTN23b) resets this timer. + +- **RTN23b**: The client may use either: + 1. **HEARTBEAT protocol messages** (`heartbeats=true` in connection URL) - for platforms where the WebSocket client does NOT surface ping events + 2. **WebSocket ping frames** (`heartbeats=false` or omitted) - for platforms where the WebSocket client CAN surface ping events + +A concrete implementation should implement either RTN23a with HEARTBEAT messages OR RTN23b with ping frames, depending on platform capabilities. The test cases below cover both approaches. + +### Verifying Transient States + +When testing heartbeat timeout behavior, the connection will pass through the DISCONNECTED state very quickly due to immediate reconnection (RTN15a). Attempting to `AWAIT_STATE disconnected` as an intermediate step in the middle of a test is unreliable. Instead, all tests that involve disconnection should: + +1. Record the full sequence of state changes from the start of the test +2. Let the complete connect → disconnect → reconnect cycle play out +3. `AWAIT_STATE connected` after the final reconnection +4. Assert the recorded state change sequence and other invariants at the end + +This pattern is used consistently throughout these tests. + +--- + +# RTN23a Tests (HEARTBEAT Protocol Messages) + +These tests apply to platforms where the WebSocket client does NOT surface ping frame events. The client must send `heartbeats=true` in the connection URL. + +--- + +## RTN23a - Client sends heartbeats=true when ping frames not observable + +**Test ID**: `realtime/unit/RTN23a/heartbeats-true-query-param-0` + +**Spec requirement:** If the client cannot observe WebSocket ping frames, it should send `heartbeats=true` in the connection query parameters. + +Tests that the client requests HEARTBEAT protocol messages. + +### Setup + +```pseudo +captured_url = null + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + captured_url = conn.url + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# Client should request heartbeats if it cannot observe ping frames +ASSERT captured_url.query_params["heartbeats"] == "true" +CLOSE_CLIENT(client) +``` + +--- + +## RTN23a - Disconnect and reconnect after maxIdleInterval + realtimeRequestTimeout + +**Test ID**: `realtime/unit/RTN23a/idle-timeout-reconnect-1` + +**Spec requirement:** If no message is received from the server for `maxIdleInterval + realtimeRequestTimeout` milliseconds, the connection is considered lost and the client transitions to DISCONNECTED state, then immediately reconnects (RTN15a). + +Tests the full disconnect/reconnect cycle when no server activity is detected. + +### Setup + +```pseudo +connection_attempt_count = 0 +state_changes = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-" + connection_attempt_count, + connectionKey: "connection-key-" + connection_attempt_count, + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-" + connection_attempt_count, + maxIdleInterval: 5000, # 5 seconds + connectionStateTtl: 120000 + ) + )) + # Server sends CONNECTED but then no further messages + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 2000, # 2 seconds + disconnectedRetryTimeout: 500, + autoConnect: false +)) + +# Record all state changes +client.connection.on().listen((change) => { + state_changes.append(change.current) +}) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT connection_attempt_count == 1 + +# Advance time past maxIdleInterval + realtimeRequestTimeout +# = 5000 + 2000 = 7000ms +ADVANCE_TIME(7100) + +# After idle timeout fires, the client enters DISCONNECTED and waits +# disconnectedRetryTimeout before reconnecting. If using fake timers, +# ensure time is advanced past both the idle timeout AND the retry delay. + +# Wait for the reconnection to complete +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# Verify the full state change sequence +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + +# Verify two connection attempts were made (initial + reconnect) +ASSERT connection_attempt_count == 2 + +# Verify we're connected with new connection details +ASSERT client.connection.id == "connection-id-2" + +# Verify the client closed the first WebSocket connection +client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) +ASSERT client_close_events.length == 1 +CLOSE_CLIENT(client) +``` + +--- + +## RTN23a - HEARTBEAT message resets idle timer + +**Test ID**: `realtime/unit/RTN23a/heartbeat-resets-timer-2` + +**Spec requirement:** Any message from the server, including HEARTBEAT messages, resets the idle timer. + +Tests that receiving HEARTBEAT messages keeps the connection alive, and that when the timer eventually expires the client disconnects and reconnects. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-" + connection_attempt_count, + connectionKey: "connection-key-" + connection_attempt_count, + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-" + connection_attempt_count, + maxIdleInterval: 3000, # 3 seconds + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 1000, # 1 second + disconnectedRetryTimeout: 500, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT connection_attempt_count == 1 + +# Advance time (not enough to trigger timeout: 3000 + 1000 = 4000ms) +ADVANCE_TIME(2000) +# Send HEARTBEAT from server - resets timer +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: HEARTBEAT +)) +# Advance time again (2000ms since HEARTBEAT, still within threshold) +ADVANCE_TIME(2000) +# Connection should still be alive - no reconnection triggered +ASSERT client.connection.state == ConnectionState.connected +ASSERT connection_attempt_count == 1 + +# Advance time past the timeout window (4100ms since last HEARTBEAT) +ADVANCE_TIME(2100) + +# After idle timeout fires, the client enters DISCONNECTED and waits +# disconnectedRetryTimeout before reconnecting. If using fake timers, +# ensure time is advanced past both the idle timeout AND the retry delay. + +# Wait for reconnection to complete +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# Verify reconnection happened +ASSERT connection_attempt_count == 2 + +# Verify the client closed the first WebSocket connection +client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) +ASSERT client_close_events.length == 1 +CLOSE_CLIENT(client) +``` + +--- + +## RTN23a - Any protocol message resets idle timer + +**Test ID**: `realtime/unit/RTN23a/any-message-resets-timer-3` + +**Spec requirement:** Any message from the server resets the idle timer, not just HEARTBEAT messages. + +Tests that receiving any protocol message (e.g., ACK, MESSAGE) keeps the connection alive, and that when the timer eventually expires the client disconnects and reconnects. + +### Setup + +```pseudo +channel_name = "test-RTN23a-message-${random_id()}" +connection_attempt_count = 0 +state_changes = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-" + connection_attempt_count, + connectionKey: "connection-key-" + connection_attempt_count, + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-" + connection_attempt_count, + maxIdleInterval: 2000, # 2 seconds + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 1000, # 1 second + disconnectedRetryTimeout: 500, + autoConnect: false +)) + +# Record all state changes +client.connection.on().listen((change) => { + state_changes.append(change.current) +}) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Advance time (timeout is 2000+1000=3000ms) +ADVANCE_TIME(1500) +# Send ACK message from server - resets timer +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: 0 +)) +# Advance time again (1500ms since ACK, still within threshold) +ADVANCE_TIME(1500) +# Connection should still be alive (timer was reset) +ASSERT client.connection.state == ConnectionState.connected + +# Send MESSAGE from server - resets timer again +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "event", data: "data") + ] +)) +# Advance time again (1500ms since MESSAGE) +ADVANCE_TIME(1500) +# Still only one connection attempt - no timeout yet +ASSERT connection_attempt_count == 1 + +# Advance time past timeout without any message (3100ms since last activity) +ADVANCE_TIME(3100) + +# After idle timeout fires, the client enters DISCONNECTED and waits +# disconnectedRetryTimeout before reconnecting. If using fake timers, +# ensure time is advanced past both the idle timeout AND the retry delay. + +# Wait for reconnection to complete +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# Verify the state change sequence includes disconnected +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + +# Verify two connection attempts were made +ASSERT connection_attempt_count == 2 + +# Verify the client closed the first WebSocket connection +client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) +ASSERT client_close_events.length == 1 +CLOSE_CLIENT(client) +``` + +--- + +## RTN23a - Heartbeat timeout triggers immediate reconnection + +**Test ID**: `realtime/unit/RTN23a/timeout-triggers-reconnect-4` + +**Spec requirement:** When a heartbeat timeout causes disconnection, the client should immediately attempt to reconnect (per RTN15a - DISCONNECTED state triggers reconnection). + +Tests that the client attempts to reconnect after a heartbeat timeout, verifying the complete state change sequence. + +### Setup + +```pseudo +connection_attempt_count = 0 +state_changes = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-" + connection_attempt_count, + connectionKey: "connection-key-" + connection_attempt_count, + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-" + connection_attempt_count, + maxIdleInterval: 2000, # 2 seconds + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 1000, # 1 second + disconnectedRetryTimeout: 500, + autoConnect: false +)) + +# Record all state changes +client.connection.on().listen((change) => { + state_changes.append(change.current) +}) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT connection_attempt_count == 1 + +# Advance time past maxIdleInterval + realtimeRequestTimeout +# = 2000 + 1000 = 3000ms +ADVANCE_TIME(3100) + +# After idle timeout fires, the client enters DISCONNECTED and waits +# disconnectedRetryTimeout before reconnecting. If using fake timers, +# ensure time is advanced past both the idle timeout AND the retry delay. + +# Wait for reconnection to complete (immediate per RTN15a) +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# Verify the state change sequence shows disconnect then reconnect +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + +# Verify two connection attempts were made (initial + reconnect) +ASSERT connection_attempt_count == 2 + +# Verify the client is now connected with new connection details +ASSERT client.connection.state == ConnectionState.connected +ASSERT client.connection.id == "connection-id-2" + +# Verify the first connection was closed by the client +client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) +ASSERT client_close_events.length == 1 +CLOSE_CLIENT(client) +``` + +--- + +## RTN23a - Reconnection after heartbeat timeout uses resume + +**Test ID**: `realtime/unit/RTN23a/reconnect-uses-resume-5` + +**Spec requirement:** When reconnecting after a heartbeat timeout, the client should attempt to resume the connection using the previous connectionKey (per RTN15c). + +Tests that the reconnection attempt includes the resume parameters. + +### Setup + +```pseudo +connection_attempts = [] +state_changes = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempts.append({ + url: conn.url, + attempt_number: connection_attempts.length + 1 + }) + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-" + connection_attempts.length, + connectionKey: "connection-key-" + connection_attempts.length, + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-" + connection_attempts.length, + maxIdleInterval: 2000, # 2 seconds + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 1000, # 1 second + disconnectedRetryTimeout: 500, + autoConnect: false +)) + +# Record all state changes +client.connection.on().listen((change) => { + state_changes.append(change.current) +}) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Advance time past timeout to trigger disconnection and reconnection +ADVANCE_TIME(3100) + +# After idle timeout fires, the client enters DISCONNECTED and waits +# disconnectedRetryTimeout before reconnecting. If using fake timers, +# ensure time is advanced past both the idle timeout AND the retry delay. + +# Wait for reconnection to complete +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# Verify the state change sequence +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + +ASSERT connection_attempts.length == 2 + +# First connection should not have resume parameter +first_url = connection_attempts[0].url +ASSERT "resume" NOT IN first_url.query_params + +# Second connection should include resume parameter with first connectionKey +second_url = connection_attempts[1].url +ASSERT second_url.query_params["resume"] == "connection-key-1" +CLOSE_CLIENT(client) +``` + +--- + +# RTN23b Tests (WebSocket Ping Frames) + +These tests apply to platforms where the WebSocket client CAN surface ping frame events. The client should send `heartbeats=false` (or omit the parameter) in the connection URL. + +--- + +## RTN23b - Client sends heartbeats=false when ping frames observable + +**Test ID**: `realtime/unit/RTN23b/heartbeats-false-query-param-0` + +**Spec requirement:** If the client can observe WebSocket ping frames, it should send `heartbeats=false` (or omit the parameter) in the connection query parameters. + +Tests that the client does not request HEARTBEAT protocol messages when it can observe ping frames. + +### Setup + +```pseudo +captured_url = null + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + captured_url = conn.url + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# Client should NOT request heartbeats if it can observe ping frames +ASSERT captured_url.query_params["heartbeats"] == "false" + OR "heartbeats" NOT IN captured_url.query_params +CLOSE_CLIENT(client) +``` + +--- + +## RTN23b - Disconnect and reconnect after maxIdleInterval + realtimeRequestTimeout (no ping frames) + +**Test ID**: `realtime/unit/RTN23b/idle-timeout-reconnect-1` + +**Spec requirement:** If no activity (including ping frames) is received for `maxIdleInterval + realtimeRequestTimeout`, disconnect and reconnect. + +Tests the full disconnect/reconnect cycle when no ping frames or messages are received. + +### Setup + +```pseudo +connection_attempt_count = 0 +state_changes = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-" + connection_attempt_count, + connectionKey: "connection-key-" + connection_attempt_count, + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-" + connection_attempt_count, + maxIdleInterval: 5000, # 5 seconds + connectionStateTtl: 120000 + ) + )) + # Server sends CONNECTED but then no further messages or ping frames + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 2000, # 2 seconds + disconnectedRetryTimeout: 500, + autoConnect: false +)) + +# Record all state changes +client.connection.on().listen((change) => { + state_changes.append(change.current) +}) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT connection_attempt_count == 1 + +# Advance time past maxIdleInterval + realtimeRequestTimeout +# = 5000 + 2000 = 7000ms +ADVANCE_TIME(7100) + +# After idle timeout fires, the client enters DISCONNECTED and waits +# disconnectedRetryTimeout before reconnecting. If using fake timers, +# ensure time is advanced past both the idle timeout AND the retry delay. + +# Wait for the reconnection to complete +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# Verify the full state change sequence +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + +# Verify two connection attempts were made (initial + reconnect) +ASSERT connection_attempt_count == 2 + +# Verify we're connected with new connection details +ASSERT client.connection.id == "connection-id-2" + +# Verify the client closed the first WebSocket connection +client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) +ASSERT client_close_events.length == 1 +CLOSE_CLIENT(client) +``` + +--- + +## RTN23b - Ping frame resets idle timer + +**Test ID**: `realtime/unit/RTN23b/ping-frame-resets-timer-2` + +**Spec requirement:** WebSocket ping frames count as activity indication and reset the idle timer. + +Tests that receiving ping frames keeps the connection alive, and that when the timer eventually expires the client disconnects and reconnects. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-" + connection_attempt_count, + connectionKey: "connection-key-" + connection_attempt_count, + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-" + connection_attempt_count, + maxIdleInterval: 3000, # 3 seconds + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 1000, # 1 second + disconnectedRetryTimeout: 500, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT connection_attempt_count == 1 + +# Advance time (not enough to trigger timeout: 3000 + 1000 = 4000ms) +ADVANCE_TIME(2000) +# Server sends ping frame - resets timer +mock_ws.active_connection.send_ping_frame() +# Advance time again (2000ms since ping, still within threshold) +ADVANCE_TIME(2000) +# Connection should still be alive - no reconnection triggered +ASSERT client.connection.state == ConnectionState.connected +ASSERT connection_attempt_count == 1 + +# Advance time past the timeout window (4100ms since last ping) +ADVANCE_TIME(2100) + +# After idle timeout fires, the client enters DISCONNECTED and waits +# disconnectedRetryTimeout before reconnecting. If using fake timers, +# ensure time is advanced past both the idle timeout AND the retry delay. + +# Wait for reconnection to complete +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# Verify reconnection happened +ASSERT connection_attempt_count == 2 + +# Verify the client closed the first WebSocket connection +client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) +ASSERT client_close_events.length == 1 +CLOSE_CLIENT(client) +``` + +--- + +## RTN23b - Any protocol message also resets idle timer + +**Test ID**: `realtime/unit/RTN23b/any-message-resets-timer-3` + +**Spec requirement:** Any message from the server resets the idle timer, not just ping frames. + +Tests that both ping frames AND protocol messages reset the timer, and that when the timer eventually expires the client disconnects and reconnects. + +### Setup + +```pseudo +channel_name = "test-RTN23b-message-${random_id()}" +connection_attempt_count = 0 +state_changes = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-" + connection_attempt_count, + connectionKey: "connection-key-" + connection_attempt_count, + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-" + connection_attempt_count, + maxIdleInterval: 2000, # 2 seconds + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 1000, # 1 second + disconnectedRetryTimeout: 500, + autoConnect: false +)) + +# Record all state changes +client.connection.on().listen((change) => { + state_changes.append(change.current) +}) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Advance time +ADVANCE_TIME(1500) +# Send ping frame - resets timer +mock_ws.active_connection.send_ping_frame() +# Advance time +ADVANCE_TIME(1500) +# Still connected +ASSERT client.connection.state == ConnectionState.connected + +# Send MESSAGE from server - also resets timer +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "event", data: "data") + ] +)) +# Advance time +ADVANCE_TIME(1500) +# Still connected +ASSERT client.connection.state == ConnectionState.connected + +# Send another ping frame +mock_ws.active_connection.send_ping_frame() +# Advance time +ADVANCE_TIME(1500) +# Still only one connection attempt +ASSERT connection_attempt_count == 1 + +# Advance time past timeout without any activity +ADVANCE_TIME(1600) + +# After idle timeout fires, the client enters DISCONNECTED and waits +# disconnectedRetryTimeout before reconnecting. If using fake timers, +# ensure time is advanced past both the idle timeout AND the retry delay. + +# Wait for reconnection to complete +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# Verify the state change sequence includes disconnected +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + +# Verify two connection attempts +ASSERT connection_attempt_count == 2 + +# Verify the client closed the first WebSocket connection +client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) +ASSERT client_close_events.length == 1 +CLOSE_CLIENT(client) +``` + +--- + +## RTN23b - Ping frame timeout triggers immediate reconnection + +**Test ID**: `realtime/unit/RTN23b/timeout-triggers-reconnect-4` + +**Spec requirement:** When a ping frame timeout causes disconnection, the client should immediately attempt to reconnect (per RTN15a - DISCONNECTED state triggers reconnection). + +Tests that the client attempts to reconnect after a ping frame timeout, verifying the complete state change sequence. + +### Setup + +```pseudo +connection_attempt_count = 0 +state_changes = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-" + connection_attempt_count, + connectionKey: "connection-key-" + connection_attempt_count, + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-" + connection_attempt_count, + maxIdleInterval: 2000, # 2 seconds + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 1000, # 1 second + disconnectedRetryTimeout: 500, + autoConnect: false +)) + +# Record all state changes +client.connection.on().listen((change) => { + state_changes.append(change.current) +}) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT connection_attempt_count == 1 + +# Advance time past maxIdleInterval + realtimeRequestTimeout +# = 2000 + 1000 = 3000ms +ADVANCE_TIME(3100) + +# After idle timeout fires, the client enters DISCONNECTED and waits +# disconnectedRetryTimeout before reconnecting. If using fake timers, +# ensure time is advanced past both the idle timeout AND the retry delay. + +# Wait for reconnection to complete (immediate per RTN15a) +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# Verify the state change sequence shows disconnect then reconnect +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + +# Verify two connection attempts were made (initial + reconnect) +ASSERT connection_attempt_count == 2 + +# Verify the client is now connected with new connection details +ASSERT client.connection.state == ConnectionState.connected +ASSERT client.connection.id == "connection-id-2" + +# Verify the first connection was closed by the client +client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) +ASSERT client_close_events.length == 1 +CLOSE_CLIENT(client) +``` + +--- + +## RTN23b - Reconnection after ping frame timeout uses resume + +**Test ID**: `realtime/unit/RTN23b/reconnect-uses-resume-5` + +**Spec requirement:** When reconnecting after a ping frame timeout, the client should attempt to resume the connection using the previous connectionKey (per RTN15c). + +Tests that the reconnection attempt includes the resume parameters. + +### Setup + +```pseudo +connection_attempts = [] +state_changes = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempts.append({ + url: conn.url, + attempt_number: connection_attempts.length + 1 + }) + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-" + connection_attempts.length, + connectionKey: "connection-key-" + connection_attempts.length, + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-" + connection_attempts.length, + maxIdleInterval: 2000, # 2 seconds + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 1000, # 1 second + disconnectedRetryTimeout: 500, + autoConnect: false +)) + +# Record all state changes +client.connection.on().listen((change) => { + state_changes.append(change.current) +}) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Advance time past timeout to trigger disconnection and reconnection +ADVANCE_TIME(3100) + +# After idle timeout fires, the client enters DISCONNECTED and waits +# disconnectedRetryTimeout before reconnecting. If using fake timers, +# ensure time is advanced past both the idle timeout AND the retry delay. + +# Wait for reconnection to complete +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# Verify the state change sequence +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + +ASSERT connection_attempts.length == 2 + +# First connection should not have resume parameter +first_url = connection_attempts[0].url +ASSERT "resume" NOT IN first_url.query_params + +# Second connection should include resume parameter with first connectionKey +second_url = connection_attempts[1].url +ASSERT second_url.query_params["resume"] == "connection-key-1" +CLOSE_CLIENT(client) +``` + +--- + +## RTN23b - Multiple ping frames keep connection alive + +**Test ID**: `realtime/unit/RTN23b/multiple-pings-keep-alive-6` + +**Spec requirement:** Continuous ping frame activity keeps the connection alive indefinitely. + +Tests that regular ping frames prevent timeout. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 2000, # 2 seconds + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 1000, # 1 second + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Simulate regular ping frames every 1.5 seconds for 10 seconds +FOR i IN 1..7: + ADVANCE_TIME(1500) + mock_ws.active_connection.send_ping_frame() + ASSERT client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# Connection stayed alive through all ping frames +ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) +``` + +--- + +# Implementation Notes + +> **Implementation note:** Some SDKs perform an internet connectivity check (RTN17j) +> before reconnection. Implementations may need to mock the HTTP layer to respond +> successfully to connectivity check requests, to allow reconnection to proceed. + +## Choosing Between RTN23a and RTN23b + +A concrete SDK implementation should: + +1. **Determine platform capability**: Can the WebSocket client surface ping frame events? + +2. **If YES (ping frames observable)**: + - Send `heartbeats=false` (or omit) in connection URL + - Listen for ping frame events as heartbeat indicators + - Implement RTN23b tests + +3. **If NO (ping frames not observable)**: + - Send `heartbeats=true` in connection URL + - Expect HEARTBEAT protocol messages from server + - Implement RTN23a tests + +### Platform-Specific Notes + +**Dart:** The standard `dart:io` WebSocket does **not** surface ping frames to the application layer. The ping/pong mechanism is handled automatically and internally - there is no `onPing` callback. Therefore, Dart implementations must use **RTN23a** (HEARTBEAT protocol messages) for idle timeout detection. The RTN23b tests do not apply to Dart. + +## Timer Mocking + +These tests use `enable_fake_timers()` and `ADVANCE_TIME()` to avoid slow tests. Implementations should: + +1. **Prefer fake timers** (JavaScript Jest, Python freezegun, Go testing.Clock) +2. **Or use dependency injection** for timer/clock interfaces +3. **Or use very short timeout values** (e.g., 50ms instead of 5s) +4. **Last resort:** Use actual delays with generous test timeouts + +## State Sequence Assertion Pattern + +All heartbeat tests that involve disconnection follow the same pattern: record the full sequence of state changes, let the complete cycle play out, then assert the sequence at the end. This avoids flaky tests caused by trying to observe transient intermediate states (like DISCONNECTED) that may pass too quickly due to immediate reconnection (RTN15a). + +The `CONTAINS_IN_ORDER` assertion verifies that the expected states appear in the recorded sequence in the correct order, without requiring that they are the only states present (allowing for implementation-specific intermediate states). + +See `mock_websocket.md` for more details on event sequence verification. + +See the "Timer Mocking" section in `write-test-spec.md` for detailed guidance. diff --git a/uts/realtime/unit/connection/network_change_test.md b/uts/realtime/unit/connection/network_change_test.md new file mode 100644 index 000000000..285f70ba7 --- /dev/null +++ b/uts/realtime/unit/connection/network_change_test.md @@ -0,0 +1,476 @@ +# Network Change Tests (RTN20) + +Spec points: `RTN20`, `RTN20a`, `RTN20b`, `RTN20c` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Platform Network Connectivity Listener + +> **Implementation requirement:** These tests require an abstract network connectivity +> listener interface that can be mocked in tests. The Ably spec (RTN20) states "when +> the client library can subscribe to OS events for network/internet connectivity +> changes" -- this means implementations need a platform-specific abstraction that: +> +> 1. Provides a way for the Ably client to subscribe to network connectivity change events +> 2. Can be injected or replaced in tests with a mock that allows programmatic triggering +> of "network available" and "network unavailable" events +> +> Example mock interface: +> ```pseudo +> interface MockNetworkListener: +> simulate_network_lost() # Triggers "internet connection no longer available" +> simulate_network_available() # Triggers "internet connection now available" +> ``` +> +> The mock should be installed before creating the Realtime client, typically via +> dependency injection or a platform-specific test hook. + +## Overview + +RTN20 defines how the client should respond to OS-level network connectivity change events: + +- **RTN20a**: Network loss while CONNECTED or CONNECTING triggers immediate DISCONNECTED +- **RTN20b**: Network available while DISCONNECTED or SUSPENDED triggers immediate connect attempt +- **RTN20c**: Network available while CONNECTING restarts the pending connection attempt + +### Verifying Transient States + +These tests use the record-and-verify pattern for state transitions. Network change events may trigger rapid state transitions, so we record all state changes and verify the sequence at the end rather than trying to observe intermediate states. + +--- + +## RTN20a - Network loss while CONNECTED triggers immediate DISCONNECTED transition + +**Test ID**: `realtime/unit/RTN20a/network-loss-connected-disconnects-0` + +| Spec | Requirement | +|------|-------------| +| RTN20 | When the client library can subscribe to OS events for network/internet connectivity changes | +| RTN20a | When CONNECTED, if the OS indicates that the underlying internet connection is no longer available, the client should immediately transition to DISCONNECTED with an appropriate reason | + +Tests that losing network connectivity while in the CONNECTED state causes an immediate transition to DISCONNECTED, which then triggers automatic reconnection per RTN15. + +### Setup + +```pseudo +connection_attempt_count = 0 +state_changes = [] + +mock_network = MockNetworkListener() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-" + connection_attempt_count, + connectionKey: "connection-key-" + connection_attempt_count, + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-" + connection_attempt_count, + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) +install_mock(mock_network) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + useBinaryProtocol: false +)) + +# Record all state changes +client.connection.on((change) => { + state_changes.append({ + current: change.current, + previous: change.previous, + reason: change.reason + }) +}) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT connection_attempt_count == 1 + +# Simulate OS reporting network loss +mock_network.simulate_network_lost() + +# The client should transition to DISCONNECTED and then automatically +# attempt to reconnect (per RTN15). Wait for the full cycle to complete. +# The reconnection may succeed immediately since the mock WebSocket +# always accepts connections. +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +``` + +### Assertions + +```pseudo +# Verify the state change sequence includes DISCONNECTED transition +ASSERT state_changes CONTAINS_IN_ORDER [ + { current: ConnectionState.connecting }, + { current: ConnectionState.connected }, + { current: ConnectionState.disconnected }, + { current: ConnectionState.connecting }, + { current: ConnectionState.connected } +] + +# Verify the DISCONNECTED state change has an appropriate reason +disconnected_change = state_changes.find(s => s.current == ConnectionState.disconnected) +ASSERT disconnected_change.reason IS NOT null + +# Verify reconnection happened +ASSERT connection_attempt_count == 2 + +CLOSE_CLIENT(client) +``` + +--- + +## RTN20a - Network loss while CONNECTING triggers DISCONNECTED transition + +**Test ID**: `realtime/unit/RTN20a/network-loss-connecting-disconnects-1` + +| Spec | Requirement | +|------|-------------| +| RTN20 | When the client library can subscribe to OS events for network/internet connectivity changes | +| RTN20a | When CONNECTING, if the OS indicates that the underlying internet connection is no longer available, the client should immediately transition to DISCONNECTED | + +Tests that losing network connectivity while in the CONNECTING state (before the WebSocket connection completes) causes an immediate transition to DISCONNECTED. + +### Setup + +```pseudo +connection_attempt_count = 0 +state_changes = [] + +mock_network = MockNetworkListener() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + IF connection_attempt_count == 1: + # First attempt: don't respond yet - leave in CONNECTING state. + # The network loss event will fire while we're still connecting. + # Do NOT call respond_with_success() here. + ELSE: + # Subsequent attempts succeed + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) +install_mock(mock_network) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + useBinaryProtocol: false +)) + +# Record all state changes +client.connection.on((change) => { + state_changes.append(change.current) +}) +``` + +### Test Steps + +```pseudo +# Start connecting - the mock won't respond, so we stay in CONNECTING +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Simulate OS reporting network loss while still CONNECTING +mock_network.simulate_network_lost() + +# Client should transition to DISCONNECTED, then eventually reconnect +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +``` + +### Assertions + +```pseudo +# Verify the state change sequence +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + +# The first connection attempt was abandoned, second succeeded +ASSERT connection_attempt_count >= 2 + +CLOSE_CLIENT(client) +``` + +--- + +## RTN20b - Network available while DISCONNECTED triggers immediate connect attempt + +**Test ID**: `realtime/unit/RTN20b/network-available-disconnected-connects-0` + +| Spec | Requirement | +|------|-------------| +| RTN20 | When the client library can subscribe to OS events for network/internet connectivity changes | +| RTN20b | When DISCONNECTED, if the OS indicates that the underlying internet connection is now available, the client should immediately attempt to connect | + +Tests that a network-available event while in the DISCONNECTED state triggers an immediate connection attempt, rather than waiting for the scheduled retry timer. + +### Setup + +```pseudo +connection_attempt_count = 0 +state_changes = [] + +mock_network = MockNetworkListener() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + IF connection_attempt_count == 1: + # First connection succeeds + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-1", + connectionKey: "connection-key-1", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + ELSE IF connection_attempt_count == 2: + # Second attempt (after disconnect) fails - puts client in DISCONNECTED + conn.respond_with_refused() + ELSE: + # Third attempt (triggered by network available) succeeds + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-2", + connectionKey: "connection-key-2", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) +install_mock(mock_network) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: 30000, # 30 seconds - deliberately long + autoConnect: false, + useBinaryProtocol: false +)) + +# Record all state changes +client.connection.on((change) => { + state_changes.append(change.current) +}) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Connect successfully +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Force disconnect - the reconnection attempt will fail (respond_with_refused), +# putting the client into DISCONNECTED state with a 30-second retry timer. +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection +ws_connection.simulate_disconnect() + +# Wait for the client to reach DISCONNECTED after the failed reconnection +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds + +# Record the connection attempt count before network event +attempts_before = connection_attempt_count + +# Simulate OS reporting network is now available. +# This should trigger an IMMEDIATE connection attempt, bypassing the +# 30-second disconnectedRetryTimeout. +mock_network.simulate_network_available() + +# Should connect immediately without needing to advance time +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Verify the state change sequence +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + +# A new connection attempt was made immediately after network available event +ASSERT connection_attempt_count > attempts_before + +# Successfully reconnected +ASSERT client.connection.state == ConnectionState.connected + +CLOSE_CLIENT(client) +``` + +> **Implementation note:** The key assertion here is that reconnection happens without +> advancing fake timers past the 30-second `disconnectedRetryTimeout`. If the network +> available event did NOT trigger an immediate attempt, the client would remain +> DISCONNECTED until the 30-second timer fires. The fact that we reach CONNECTED +> without advancing time proves the network event bypassed the retry timer. + +--- + +## RTN20c - Network available while CONNECTING restarts the connection attempt + +**Test ID**: `realtime/unit/RTN20c/network-available-connecting-restarts-0` + +| Spec | Requirement | +|------|-------------| +| RTN20 | When the client library can subscribe to OS events for network/internet connectivity changes | +| RTN20c | When CONNECTING, if the OS indicates that the underlying internet connection is now available, the client should restart the pending connection attempt | + +Tests that a network-available event while in the CONNECTING state causes the client to restart (abandon and retry) the pending connection attempt. + +### Setup + +```pseudo +connection_attempt_count = 0 +state_changes = [] + +mock_network = MockNetworkListener() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + IF connection_attempt_count == 1: + # First attempt: don't respond - leave pending (simulates slow connection) + # The network-available event will fire while this attempt is pending. + ELSE: + # Second attempt (restarted after network event) succeeds + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) +install_mock(mock_network) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + useBinaryProtocol: false +)) + +# Record all state changes +client.connection.on((change) => { + state_changes.append(change.current) +}) +``` + +### Test Steps + +```pseudo +# Start connecting - the mock won't respond to the first attempt, +# leaving the client in CONNECTING state +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connecting + +ASSERT connection_attempt_count == 1 + +# Simulate OS reporting network is now available while still CONNECTING. +# The client should abandon the pending connection and start a new attempt. +mock_network.simulate_network_available() + +# The restarted connection attempt should succeed +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +``` + +### Assertions + +```pseudo +# The first connection attempt was abandoned and a new one was made +ASSERT connection_attempt_count >= 2 + +# Client is now connected +ASSERT client.connection.state == ConnectionState.connected + +CLOSE_CLIENT(client) +``` + +> **Implementation note:** Some implementations may briefly transition through +> DISCONNECTED when restarting the connection attempt (abandon old attempt -> +> DISCONNECTED -> CONNECTING -> CONNECTED). Others may stay in CONNECTING and +> simply restart the underlying transport. Both approaches satisfy RTN20c. +> The state change assertions use `CONTAINS_IN_ORDER` with minimal requirements +> to accommodate either approach. + +--- + +## Implementation Notes + +### Network Connectivity Abstraction + +Each platform has different mechanisms for observing network connectivity changes: + +| Platform | Mechanism | +|----------|-----------| +| **iOS/macOS** | `NWPathMonitor` (Network framework) or `SCNetworkReachability` | +| **Android** | `ConnectivityManager.NetworkCallback` | +| **Dart/Flutter** | `connectivity_plus` package or platform channels | +| **JavaScript (Browser)** | `navigator.onLine` + `online`/`offline` events on `window` | +| **JavaScript (Node.js)** | Not typically available - RTN20 may not apply | +| **Python** | Not typically available - RTN20 may not apply | + +The mock used in these tests should be injected via the same mechanism the SDK uses to receive real network events. For example, if the SDK accepts a `NetworkConnectivityListener` interface in its constructor or options, the mock should implement that interface. + +### RTN20 Conditionality + +RTN20 begins with "When the client library can subscribe to OS events" -- this means the feature is optional for platforms where network monitoring is not feasible. SDKs that do not implement network monitoring should skip these tests entirely. + +### Timer Interaction (RTN20b) + +When the client is in DISCONNECTED state, there is typically a retry timer scheduled (per RTB1). When a network-available event triggers an immediate connection attempt (RTN20b), implementations should cancel the pending retry timer to avoid a duplicate connection attempt. diff --git a/uts/realtime/unit/connection/server_initiated_reauth_test.md b/uts/realtime/unit/connection/server_initiated_reauth_test.md new file mode 100644 index 000000000..d3a6cc586 --- /dev/null +++ b/uts/realtime/unit/connection/server_initiated_reauth_test.md @@ -0,0 +1,296 @@ +# Server-Initiated Re-authentication Tests + +Spec points: `RTN22`, `RTN22a` + +## Test Type +Unit test with mocked WebSocket client and authCallback + +## Mock Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Purpose + +These tests verify that when Ably sends an `AUTH` protocol message to a connected client, +the client immediately starts a new authentication process as described in RTC8: it obtains +a new token via the configured auth mechanism and sends an `AUTH` protocol message back to +Ably containing the new token. + +RTN22a covers the fallback: if the client does not re-authenticate within an acceptable +period, Ably forcibly disconnects via a `DISCONNECTED` message with a token error code +(40140–40149), triggering RTN15h token-error recovery. + +--- + +## RTN22 - Server sends AUTH, client re-authenticates + +**Test ID**: `realtime/unit/RTN22/server-auth-triggers-reauth-0` + +**Spec requirement:** Ably can request that a connected client re-authenticates by sending the client an `AUTH` ProtocolMessage. The client must then immediately start a new authentication process as described in RTC8. + +Tests that receiving an `AUTH` message from the server triggers the client to obtain a new token and send an `AUTH` message back. + +### Setup +```pseudo +auth_callback_count = 0 +captured_auth_messages = [] + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Record state changes during reauth +state_changes = [] +client.connection.on((change) => { + state_changes.append(change) +}) + +# When client sends AUTH back, record it and respond with CONNECTED (update) +mock_ws.on_client_message((msg) => { + IF msg.action == AUTH: + captured_auth_messages.append(msg) + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key-2", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) +}) + +# Server requests re-authentication +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: AUTH +)) + +# Wait for the UPDATE event that signals reauth completion +AWAIT UNTIL state_changes.any(c => c.event == ConnectionEvent.update) +``` + +### Assertions +```pseudo +# authCallback was called twice: once for initial connect, once for reauth +ASSERT auth_callback_count == 2 + +# Client sent AUTH message back with new token +ASSERT captured_auth_messages.length == 1 +ASSERT captured_auth_messages[0].auth IS NOT null +ASSERT captured_auth_messages[0].auth.accessToken == "token-2" + +# Connection stayed CONNECTED throughout (no state transitions, only UPDATE) +connected_to_other = state_changes.filter(c => c.current != ConnectionState.connected) +ASSERT connected_to_other.length == 0 + +# UPDATE event was emitted (RTN24) +update_events = state_changes.filter(c => c.event == ConnectionEvent.update) +ASSERT update_events.length == 1 +CLOSE_CLIENT(client) +``` + +--- + +## RTN22 - Connection remains CONNECTED during server-initiated reauth + +**Test ID**: `realtime/unit/RTN22/stays-connected-during-reauth-1` + +**Spec requirement:** The re-authentication triggered by the server's AUTH message must follow the RTC8 flow — if the connection is CONNECTED, an AUTH message is sent without disconnecting. + +Tests that the connection state does not change during server-initiated re-authentication. + +### Setup +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "reauth-token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "conn-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +# Auto-respond to AUTH with CONNECTED +mock_ws.on_client_message((msg) => { + IF msg.action == AUTH: + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "conn-1", + connectionKey: "key-1-updated", + connectionDetails: ConnectionDetails( + connectionKey: "key-1-updated", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) +}) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +state_changes = [] +client.connection.on((change) => { + state_changes.append(change) +}) + +# Server sends AUTH +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: AUTH +)) + +# Wait for UPDATE event +AWAIT UNTIL state_changes.length >= 1 +``` + +### Assertions +```pseudo +# Connection never left CONNECTED +ASSERT client.connection.state == ConnectionState.connected + +# Only an UPDATE event, no state change events +ASSERT state_changes.length == 1 +ASSERT state_changes[0].event == ConnectionEvent.update +ASSERT state_changes[0].current == ConnectionState.connected +ASSERT state_changes[0].previous == ConnectionState.connected +CLOSE_CLIENT(client) +``` + +--- + +## RTN22a - Forced disconnect on reauth failure + +**Test ID**: `realtime/unit/RTN22a/forced-disconnect-reauth-failure-0` + +**Spec requirement:** Ably reserves the right to forcibly disconnect a client that does not re-authenticate within an acceptable period. A client is forcibly disconnected following a `DISCONNECTED` message containing an error code in the range 40140–40149. This forces the client to re-authenticate and resume via RTN15h. + +Tests that when the server sends a `DISCONNECTED` message with a token error code after requesting reauth, the client transitions to DISCONNECTED and initiates token-error recovery. + +### Setup +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "recovery-token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "conn-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +state_changes = [] +client.connection.on((change) => { + state_changes.append(change) +}) + +# Server forcibly disconnects with token error (simulating reauth timeout) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DISCONNECTED, + error: ErrorInfo( + message: "Token expired", + code: 40142, + statusCode: 401 + ) +)) + +# Wait for client to transition to DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected +``` + +### Assertions +```pseudo +# Client transitioned to DISCONNECTED with the token error +disconnected_change = state_changes.find(c => c.current == ConnectionState.disconnected) +ASSERT disconnected_change IS NOT null +ASSERT disconnected_change.reason.code == 40142 + +# The client should attempt to reconnect (RTN15h token-error recovery +# will obtain a new token and reconnect) +CLOSE_CLIENT(client) +``` + +### Note +The full RTN15h recovery flow (obtain new token, reconnect) is tested in `connection_failures_test.md`. This test only verifies that the forced disconnect with a token error code is handled correctly as the entry point for that recovery. diff --git a/uts/realtime/unit/connection/update_events_test.md b/uts/realtime/unit/connection/update_events_test.md new file mode 100644 index 000000000..480c5bdf5 --- /dev/null +++ b/uts/realtime/unit/connection/update_events_test.md @@ -0,0 +1,396 @@ +# UPDATE Events Tests (RTN24) + +Spec point: `RTN24` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTN24 - CONNECTED message while already CONNECTED emits UPDATE event + +**Test ID**: `realtime/unit/RTN24/connected-emits-update-0` + +**Spec requirement:** A connected client may receive a CONNECTED ProtocolMessage from Ably at any point (typically triggered by reauth). The connectionDetails must override stored details. The Connection should emit an UPDATE event with ConnectionStateChange having both previous and current attributes set to CONNECTED, and reason set to the error member of the CONNECTED ProtocolMessage (if any). The library must NOT emit a CONNECTED event if already connected. + +Tests that receiving CONNECTED while CONNECTED emits UPDATE, not CONNECTED. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-1", + connectionKey: "connection-key-1", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000, + clientId: "client-123" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Track events +connected_events = [] +update_events = [] + +client.connection.on(ConnectionState.connected, (change) => { + connected_events.push(change) +}) + +client.connection.on(ConnectionEvent.update, (change) => { + update_events.push(change) +}) + +# Start connection +client.connect() + +# Wait for initial CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +# Verify initial connection +ASSERT connected_events.length == 1 +ASSERT update_events.length == 0 + +# Server sends another CONNECTED message (e.g., after reauth) +# Note: connectionId is a top-level ProtocolMessage field, NOT inside +# connectionDetails, so it never changes for an in-progress connection. +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-1", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-1", + maxIdleInterval: 20000, # Different value + connectionStateTtl: 120000, + clientId: "client-123" + ) +)) + +# Wait for event to be processed +WAIT(100) +``` + +### Assertions + +```pseudo +# State remains CONNECTED +ASSERT client.connection.state == ConnectionState.connected + +# No additional CONNECTED event was emitted +ASSERT connected_events.length == 1 + +# UPDATE event was emitted +ASSERT update_events.length == 1 + +# UPDATE event has correct structure +update_change = update_events[0] +ASSERT update_change.previous == ConnectionState.connected +ASSERT update_change.current == ConnectionState.connected +ASSERT update_change.reason IS null # No error in this case + +# connection.id and connection.key are unchanged — connectionId is a +# top-level ProtocolMessage field not inside connectionDetails, so RTN24's +# "connectionDetails must override stored details" does not apply to it. +ASSERT client.connection.id == "connection-id-1" +ASSERT client.connection.key == "connection-key-1" +CLOSE_CLIENT(client) +``` + +--- + +## RTN24 - UPDATE event with error reason + +**Test ID**: `realtime/unit/RTN24/update-event-with-error-1` + +**Spec requirement:** The UPDATE event's reason attribute should be set to the error member of the CONNECTED ProtocolMessage (if any). + +Tests that UPDATE events include error information when present. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-1", + connectionKey: "connection-key-1", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Track UPDATE events +update_events = [] + +client.connection.on(ConnectionEvent.update, (change) => { + update_events.push(change) +}) + +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +# Server sends CONNECTED with error (e.g., token was renewed due to expiry) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-1", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ), + error: ErrorInfo( + code: 40142, + statusCode: 401, + message: "Token expired; renewed automatically" + ) +)) + +# Wait for event to be processed +WAIT(100) +``` + +### Assertions + +```pseudo +# UPDATE event was emitted +ASSERT update_events.length == 1 + +# UPDATE event has error reason +update_change = update_events[0] +ASSERT update_change.previous == ConnectionState.connected +ASSERT update_change.current == ConnectionState.connected +ASSERT update_change.reason IS NOT null +ASSERT update_change.reason.code == 40142 +ASSERT update_change.reason.statusCode == 401 +ASSERT update_change.reason.message CONTAINS "Token expired" +CLOSE_CLIENT(client) +``` + +--- + +## RTN24 - ConnectionDetails override + +**Test ID**: `realtime/unit/RTN24/connection-details-override-2` + +**Spec requirement:** The connectionDetails in the ProtocolMessage must override any stored details (see RTN21). Note: `connectionId` is a top-level ProtocolMessage field, NOT inside `connectionDetails`, so it is never updated by RTN24. The connectionDetails fields that are overridden include operational parameters like `maxIdleInterval`, `connectionStateTtl`, `maxMessageSize`, and `serverId`. + +Tests that receiving a new CONNECTED message overrides stored connectionDetails. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-1", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-1", + maxIdleInterval: 10000, + connectionStateTtl: 60000, + maxMessageSize: 16384, + serverId: "server-1", + clientId: "client-original" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +# Verify initial connection +ASSERT client.connection.id == "connection-id-1" +ASSERT client.connection.key == "connection-key-1" + +# Server sends new CONNECTED with different connectionDetails (RTN24) +# connectionId stays the same — the server never changes it for an +# in-progress connection. +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-1", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-1", + maxIdleInterval: 20000, # Changed + connectionStateTtl: 120000, # Changed + maxMessageSize: 32768, # Changed + serverId: "server-2", # Changed + clientId: "client-updated" # Changed + ) +)) + +# Wait for update to be processed +WAIT(100) +``` + +### Assertions + +```pseudo +# connection.id is unchanged (not inside connectionDetails) +ASSERT client.connection.id == "connection-id-1" +ASSERT client.connection.key == "connection-key-1" + +# connectionDetails fields were overridden (RTN21) +# The exact accessors for these details may vary by implementation. +# The effect can be observed indirectly — e.g., the heartbeat timeout +# changes when maxIdleInterval is overridden. + +# State remains CONNECTED +ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) +``` + +--- + +## RTN24 - No duplicate CONNECTED event + +**Test ID**: `realtime/unit/RTN24/no-duplicate-connected-event-3` + +**Spec requirement:** The library must not emit a CONNECTED event if the client was already connected (see RTN4h). + +Tests that only UPDATE events are emitted, not CONNECTED events, when receiving CONNECTED while already connected. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-1", + connectionKey: "connection-key-1", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Track all events +all_events = [] + +# Subscribe to all connection events +FOR EACH state IN [ConnectionState.initialized, ConnectionState.connecting, + ConnectionState.connected, ConnectionState.disconnected, + ConnectionState.suspended, ConnectionState.closing, + ConnectionState.closed, ConnectionState.failed]: + client.connection.on(state, (change) => { + all_events.push({type: "state", state: state, change: change}) + }) + +# Also subscribe to UPDATE +client.connection.on(ConnectionEvent.update, (change) => { + all_events.push({type: "update", change: change}) +}) + +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +# Record event count after initial connection +initial_event_count = all_events.length + +# Send multiple CONNECTED messages (same connectionId — it never changes) +FOR i IN [1, 2, 3]: + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-1", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + WAIT(50) +``` + +### Assertions + +```pseudo +# Exactly 3 UPDATE events were added (one per subsequent CONNECTED message) +new_events = all_events[initial_event_count:] +ASSERT new_events.length == 3 + +# All new events are UPDATE events, not CONNECTED state events +FOR EACH event IN new_events: + ASSERT event.type == "update" + ASSERT event.change.previous == ConnectionState.connected + ASSERT event.change.current == ConnectionState.connected + +# No additional CONNECTED state events were emitted +connected_state_events = FILTER all_events WHERE event.type == "state" + AND event.state == ConnectionState.connected +ASSERT connected_state_events.length == 1 # Only the initial one +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/connection/when_state_test.md b/uts/realtime/unit/connection/when_state_test.md new file mode 100644 index 000000000..6dda70a88 --- /dev/null +++ b/uts/realtime/unit/connection/when_state_test.md @@ -0,0 +1,498 @@ +# Connection whenState Tests (RTN26) + +Spec points: `RTN26`, `RTN26a`, `RTN26b` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTN26a - whenState calls listener immediately if already in state + +**Test ID**: `realtime/unit/RTN26a/immediate-callback-current-state-0` + +**Spec requirement:** If the connection is already in the given state, calls the listener with a null argument. + +Tests that whenState invokes callback immediately when the connection is already in the target state. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +# Now call whenState for the current state +callback_invoked = false +callback_arg = undefined + +client.connection.whenState(ConnectionState.connected, (change) => { + callback_invoked = true + callback_arg = change +}) + +# Callback should be invoked synchronously or very quickly +WAIT(50) +``` + +### Assertions + +```pseudo +# Callback was invoked immediately +ASSERT callback_invoked == true + +# Callback was invoked with null argument (not a StateChange object) +ASSERT callback_arg IS null +CLOSE_CLIENT(client) +``` + +--- + +## RTN26b - whenState waits for state if not already in it + +**Test ID**: `realtime/unit/RTN26b/deferred-callback-future-state-0` + +**Spec requirement:** Else, calls #once with the given state and listener. + +Tests that whenState waits for state transition when not currently in the target state. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connection is in INITIALIZED state +ASSERT client.connection.state == ConnectionState.initialized + +# Set up whenState before connecting +callback_invoked = false +callback_arg = undefined + +client.connection.whenState(ConnectionState.connected, (change) => { + callback_invoked = true + callback_arg = change +}) + +# Callback should not be invoked yet +ASSERT callback_invoked == false + +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +# Give callback a moment to execute +WAIT(50) +``` + +### Assertions + +```pseudo +# Callback was invoked after state transition +ASSERT callback_invoked == true + +# Callback was invoked with a ConnectionStateChange object (not null) +ASSERT callback_arg IS NOT null +ASSERT callback_arg.previous IN [ConnectionState.initialized, ConnectionState.connecting] +ASSERT callback_arg.current == ConnectionState.connected +CLOSE_CLIENT(client) +``` + +--- + +## RTN26b - whenState only fires once + +**Test ID**: `realtime/unit/RTN26b/fires-only-once-1` + +**Spec requirement:** whenState uses #once, meaning it should only fire once, not on every subsequent occurrence of the state. + +Tests that whenState callback is invoked only once even if state is entered multiple times. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + IF connection_attempt_count == 1: + # First attempt: connect then disconnect + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-1", + connectionKey: "connection-key-1", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + ELSE: + # Second attempt: connect again + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-2", + connectionKey: "connection-key-2", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: 100, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Set up whenState listener +callback_count = 0 + +client.connection.whenState(ConnectionState.connected, (change) => { + callback_count++ +}) + +# Start connection +client.connect() + +# Wait for first CONNECTED +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +WAIT(50) + +# Verify callback was invoked once +ASSERT callback_count == 1 + +# Force a disconnection +mock_ws.active_connection.close() + +# Wait for DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 2 seconds + +# Advance time to trigger reconnection +ADVANCE_TIME(150) + +# Wait for second CONNECTED +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +WAIT(50) +``` + +### Assertions + +```pseudo +# Callback was still only invoked once (not again on reconnection) +ASSERT callback_count == 1 +CLOSE_CLIENT(client) +``` + +--- + +## RTN26a - Multiple whenState calls + +**Test ID**: `realtime/unit/RTN26a/multiple-whenstate-calls-1` + +**Spec requirement:** Multiple calls to whenState should each be handled independently. + +Tests that multiple whenState listeners can be registered and each behaves correctly. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Set up multiple whenState listeners before connecting +callback1_invoked = false +callback2_invoked = false +callback3_invoked = false + +client.connection.whenState(ConnectionState.connected, (change) => { + callback1_invoked = true +}) + +client.connection.whenState(ConnectionState.connected, (change) => { + callback2_invoked = true +}) + +client.connection.whenState(ConnectionState.connecting, (change) => { + callback3_invoked = true +}) + +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +WAIT(50) +``` + +### Assertions + +```pseudo +# All whenState callbacks were invoked +ASSERT callback1_invoked == true +ASSERT callback2_invoked == true +ASSERT callback3_invoked == true +CLOSE_CLIENT(client) +``` + +--- + +## RTN26a - whenState with already-passed state + +**Test ID**: `realtime/unit/RTN26a/no-fire-for-past-state-2` + +**Spec requirement:** whenState should invoke immediately with null if already in the target state. + +Tests that whenState for a state that was passed but is no longer current does NOT fire immediately. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +# Now call whenState for a past state (CONNECTING) +callback_invoked = false + +client.connection.whenState(ConnectionState.connecting, (change) => { + callback_invoked = true +}) + +# Wait to see if callback is invoked +WAIT(200) +``` + +### Assertions + +```pseudo +# Callback should NOT be invoked (we're not in CONNECTING state anymore) +ASSERT callback_invoked == false + +# This demonstrates whenState checks current state, not historical states +CLOSE_CLIENT(client) +``` + +--- + +## RTN26 - whenState with different states + +**Test ID**: `realtime/unit/RTN26/whenstate-different-states-0` + +**Spec requirement:** whenState should work correctly for all connection states. + +Tests that whenState functions correctly across different state transitions. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Connection attempt fails + conn.respond_with_refused() + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Set up whenState listeners for various states +initialized_fired = false +connecting_fired = false +disconnected_fired = false + +client.connection.whenState(ConnectionState.initialized, (change) => { + initialized_fired = true +}) + +client.connection.whenState(ConnectionState.connecting, (change) => { + connecting_fired = true +}) + +client.connection.whenState(ConnectionState.disconnected, (change) => { + disconnected_fired = true +}) + +# Initially in INITIALIZED +WAIT(50) + +# Should fire immediately for current state +ASSERT initialized_fired == true +ASSERT connecting_fired == false +ASSERT disconnected_fired == false + +# Start connection +client.connect() + +# Wait for DISCONNECTED (connection will fail) +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds + +WAIT(50) +``` + +### Assertions + +```pseudo +# All states were reached and callbacks invoked +ASSERT initialized_fired == true +ASSERT connecting_fired == true +ASSERT disconnected_fired == true +CLOSE_CLIENT(client) +``` + +--- + +## Implementation Notes + +The `whenState` function is a convenience utility that: + +1. **Immediate invocation**: If `connection.state == targetState`, invoke callback with `null` immediately +2. **Deferred invocation**: Otherwise, it's equivalent to `connection.once(targetState, callback)` +3. **One-time only**: Each `whenState` call fires at most once +4. **Multiple calls**: Multiple `whenState` calls with same state are independent +5. **Return value**: Some implementations may return a way to unregister the listener (implementation-specific) + +Implementations may differ in: +- Whether immediate invocation is synchronous or scheduled for next tick +- Whether a cleanup/unregister function is returned +- Exact behavior with edge cases like rapid state changes diff --git a/uts/realtime/unit/helpers/mock_vcdiff.md b/uts/realtime/unit/helpers/mock_vcdiff.md new file mode 100644 index 000000000..a8748a5f0 --- /dev/null +++ b/uts/realtime/unit/helpers/mock_vcdiff.md @@ -0,0 +1,210 @@ +# Mock VCDiff Infrastructure + +This document specifies the mock VCDiff encoder and decoder for unit tests. Tests that need to encode or decode vcdiff deltas should reference this document. + +## Purpose + +The mock VCDiff infrastructure provides a deterministic, predictable encoding and decoding algorithm for testing delta compression functionality without a real vcdiff library. The algorithm is designed so that: + +1. **Encoded deltas are inspectable** — the delta payload contains both the base and the new value in a human-readable format +2. **Decoding validates the base** — the decoder verifies that the base argument matches what was used during encoding, catching base payload storage bugs +3. **Round-trip is exact** — `decode(base, encode(base, value)) == value` + +## Algorithm + +### Encoding + +The encoder takes a base payload and a new value, and produces a delta. + +**String inputs:** +```pseudo +encode(base: String, value: String) -> String: + return encode_uri_component(base) + "/" + encode_uri_component(value) +``` + +**Binary inputs:** +```pseudo +encode(base: byte[], value: byte[]) -> byte[]: + return utf8_encode(base64url_encode(base) + "/" + base64url_encode(value)) +``` + +### Decoding + +The decoder takes a base payload and a delta, validates the base, and returns the original value. + +**String inputs:** +```pseudo +decode(base: String, delta: String) -> String: + parts = delta.split("/") + IF length(parts) != 2: + THROW "Invalid delta format" + encoded_base = parts[0] + encoded_value = parts[1] + decoded_base = decode_uri_component(encoded_base) + IF decoded_base != base: + THROW "Base mismatch: expected base does not match delta" + return decode_uri_component(encoded_value) +``` + +**Binary inputs:** +```pseudo +decode(base: byte[], delta: byte[]) -> byte[]: + delta_string = utf8_decode(delta) + parts = delta_string.split("/") + IF length(parts) != 2: + THROW "Invalid delta format" + encoded_base = parts[0] + encoded_value = parts[1] + decoded_base = base64url_decode(encoded_base) + IF decoded_base != base: + THROW "Base mismatch: expected base does not match delta" + return base64url_decode(encoded_value) +``` + +### Examples + +**String round-trip:** +```pseudo +base = "hello world" +value = "goodbye world" + +delta = encode(base, value) +# delta == "hello%20world/goodbye%20world" + +result = decode(base, delta) +# result == "goodbye world" +``` + +**String with special characters:** +```pseudo +base = "msg/1" +value = "msg/2" + +delta = encode(base, value) +# delta == "msg%2F1/msg%2F2" + +result = decode(base, delta) +# result == "msg/2" +``` + +**Binary round-trip:** +```pseudo +base = [0x48, 0x65, 0x6C, 0x6C, 0x6F] # "Hello" in UTF-8 +value = [0x57, 0x6F, 0x72, 0x6C, 0x64] # "World" in UTF-8 + +delta = encode(base, value) +# delta == utf8_encode("SGVsbG8/V29ybGQ") +# == [0x53, 0x47, 0x56, 0x73, 0x62, 0x47, 0x38, 0x2F, +# 0x56, 0x32, 0x39, 0x79, 0x62, 0x47, 0x51] + +result = decode(base, delta) +# result == [0x57, 0x6F, 0x72, 0x6C, 0x64] # "World" +``` + +**Base mismatch (decode fails):** +```pseudo +base = "hello" +value = "world" +delta = encode(base, value) # "hello/world" + +wrong_base = "wrong" +decode(wrong_base, delta) # THROWS "Base mismatch" +``` + +## Mock Interface + +### MockVCDiffEncoder + +```pseudo +interface MockVCDiffEncoder: + encode(base: String, value: String) -> String + encode(base: byte[], value: byte[]) -> byte[] +``` + +### MockVCDiffDecoder + +The decoder implements the `VCDiffDecoder` interface specified in VD2a. + +```pseudo +interface MockVCDiffDecoder: + decode(delta: byte[], base: byte[]) -> byte[] +``` + +Note: The `VCDiffDecoder` interface (VD2) only specifies a binary API +(`decode(delta, base) -> byte[]`). The string overloads on the encoder and +decoder are a convenience for test setup — they allow tests to construct delta +payloads from string values without manually converting to binary. The SDK's +vcdiff plugin integration point uses the binary-only `VCDiffDecoder` interface. + +### FailingMockVCDiffDecoder + +For testing RTL18 decode failure recovery, a decoder that always throws: + +```pseudo +interface FailingMockVCDiffDecoder: + decode(delta: byte[], base: byte[]) -> byte[]: + THROW "Simulated vcdiff decode failure" +``` + +## Usage in Tests + +### Creating delta payloads for mock server messages + +When the mock WebSocket server needs to send a delta-encoded MESSAGE, use the +encoder to construct the delta payload from known base and value strings: + +```pseudo +encoder = MockVCDiffEncoder() + +# First message (non-delta, establishes base payload) +base_data = "first message" + +# Second message (delta, references first) +new_data = "second message" +delta_payload = encoder.encode(base_data, new_data) + +# Server sends the delta message +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + { + id: "msg-2", + data: delta_payload, + encoding: "vcdiff", + extras: { delta: { from: "msg-1", format: "vcdiff" } } + } + ] +)) +``` + +### Registering the decoder as a plugin + +```pseudo +decoder = MockVCDiffDecoder() + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: decoder } +)) +``` + +### Testing decode failure recovery (RTL18) + +```pseudo +failing_decoder = FailingMockVCDiffDecoder() + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: failing_decoder } +)) +``` + +## Notes on Base64URL + +Base64URL encoding uses the URL-safe alphabet (`A-Z`, `a-z`, `0-9`, `-`, `_`) +with no padding (`=`). This is distinct from standard Base64 which uses `+` and +`/`. The URL-safe alphabet is used here because `/` is the separator character +in the delta format. diff --git a/uts/realtime/unit/helpers/mock_websocket.md b/uts/realtime/unit/helpers/mock_websocket.md new file mode 100644 index 000000000..345496aa3 --- /dev/null +++ b/uts/realtime/unit/helpers/mock_websocket.md @@ -0,0 +1,419 @@ +# Mock WebSocket Infrastructure + +This document specifies the mock WebSocket infrastructure for Realtime unit tests. All Realtime unit tests that need to intercept WebSocket connections should reference this document. + +## Purpose + +The mock infrastructure enables unit testing of Realtime client behavior without making real network calls. It supports: + +1. **Intercepting connection attempts** - Capture the URL and query parameters used when connecting +2. **Injecting server messages** - Deliver protocol messages to the client as if from the server +3. **Capturing client messages** - Record protocol messages sent by the client +4. **Controlling connection outcomes** - Simulate various connection results including successful connections, connection refused, DNS errors, timeouts, and other network-level failures +5. **Simulating connection events** - Trigger disconnect and error conditions on established connections + +## Installation Mechanism + +The mechanism for injecting the mock is implementation-specific and not part of the public API. Possible approaches include: + +- Package-level variable substitution (e.g., `var dialWebsocket = ...`) +- Build tag conditional compilation +- Internal test exports (`export_test.go` pattern in Go) +- Dependency injection via internal constructors + +## Mock Interface + +```pseudo +interface MockWebSocket: + # Event sequence tracking - unified timeline of all events + events: List # Ordered sequence of all connection and message events + + # Message injection (server -> client) + send_to_client(message: ProtocolMessage) + send_to_client_and_close(message: ProtocolMessage) # Send then close connection + simulate_disconnect(error?: ErrorInfo) # Close without sending a message + + # WebSocket ping frame simulation (for RTN23b) + # Simulates the server sending a WebSocket ping frame. + # On platforms where the WebSocket client surfaces ping events, + # this allows testing heartbeat behavior via ping frames instead of + # HEARTBEAT protocol messages. + send_ping_frame() + + # Awaitable event triggers for test code + await_next_message_from_client(timeout?: Duration): Future + await_connection_attempt(timeout?: Duration): Future + await_client_close(timeout?: Duration): Future # Wait for client to close WebSocket + + # Test management + reset() # Clear all state + +enum MockEventType: + CONNECTION_ATTEMPT # Client attempted to connect + CONNECTION_SUCCESS # Connection established successfully + CONNECTION_FAILURE # Connection failed (refused, timeout, DNS error, etc.) + MESSAGE_FROM_CLIENT # Client sent a protocol message + MESSAGE_TO_CLIENT # Server sent a protocol message (test injected) + PING_FRAME # WebSocket ping frame sent to client (test injected) + SERVER_DISCONNECT # Server closed the connection or transport failure + CLIENT_CLOSE # Client initiated WebSocket close + +struct MockEvent: + type: MockEventType + timestamp: Time + data: Any # Event-specific data (PendingConnection, ProtocolMessage, ErrorInfo, etc.) + +struct ClientCloseEvent: + code: Int? # WebSocket close code (e.g., 1000 for normal closure) + reason: String? # Optional close reason + +interface PendingConnection: + url: URL + protocol: String # "application/json" or "application/x-msgpack" + timestamp: Time + + # Methods for test code to respond to the connection attempt + respond_with_success(connected_message: ProtocolMessage) + respond_with_refused() # Connection refused at network level + respond_with_timeout() # Connection times out (unresponsive) + respond_with_dns_error() # DNS resolution fails + respond_with_error(error_message: ProtocolMessage, then_close: bool = true) # WebSocket connects but server sends ERROR +``` + +## Handler-Based Configuration + +For simple test scenarios, implementations may support handler-based configuration: + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED_MESSAGE) + }, + onMessageFromClient: (msg) => { + # Handle decoded messages from client + }, + onTextDataFrame: (text) => { + # Handle raw text WebSocket data frame (JSON protocol) + }, + onBinaryDataFrame: (bytes) => { + # Handle raw binary WebSocket data frame (msgpack protocol) + } +) +``` + +Handlers are called automatically when connection attempts or messages occur. The await-based API should always be available for tests that need to coordinate responses with test state. + +### Raw Data Frame Hooks + +The `onTextDataFrame` and `onBinaryDataFrame` handlers provide access to the raw WebSocket data frames before they are decoded into `ProtocolMessage` objects. This is useful for tests that need to verify the wire encoding (e.g., that null fields are omitted from the encoded representation). + +- **`onTextDataFrame(text: String)`** — Called when the client sends a text WebSocket frame. This occurs when using the JSON protocol (`useBinaryProtocol: false`). The `text` parameter is the raw JSON string. +- **`onBinaryDataFrame(bytes: Bytes)`** — Called when the client sends a binary WebSocket frame. This occurs when using the msgpack protocol (`useBinaryProtocol: true`). The `bytes` parameter is the raw msgpack-encoded bytes. + +Both raw frame handlers are called **in addition to** `onMessageFromClient` (which receives the decoded `ProtocolMessage`). If only `onMessageFromClient` is provided, raw frames are not surfaced to the test. + +### When to Use Each Pattern + +**Handler pattern** (recommended for most tests): +- Response is predetermined based on request count or content +- Simple "first attempt fails, second succeeds" scenarios +- No need to coordinate with external test state + +**Await pattern** (for advanced scenarios): +- Need to inspect connection details before deciding how to respond +- Test logic depends on external state not known at setup time +- Complex coordination between multiple async operations + +**Important note on await pattern**: When awaiting multiple sequential connection attempts, you must set up the await for the next attempt BEFORE responding to the current one to avoid race conditions: + +```pseudo +# Correct pattern for sequential awaits +first_conn = AWAIT mock_ws.await_connection_attempt() +second_future = mock_ws.await_connection_attempt() # Set up BEFORE responding +first_conn.respond_with_error(...) # This triggers retry +second_conn = AWAIT second_future +``` + +## Connection Closing Semantics + +### Server-Initiated Close (Test Simulating Server) + +When simulating server behavior, use the correct method based on the scenario: + +| Scenario | Method | Event Recorded | +|----------|--------|----------------| +| Server sends DISCONNECTED | `send_to_client_and_close()` | `SERVER_DISCONNECT` | +| Server sends ERROR (connection-level) | `send_to_client_and_close()` | `SERVER_DISCONNECT` | +| Server sends ERROR (channel-level) | `send_to_client()` | (none - connection stays open) | +| Server sends CONNECTED, HEARTBEAT, ACK, MESSAGE | `send_to_client()` | (none - connection stays open) | +| Unexpected transport failure | `simulate_disconnect()` | `SERVER_DISCONNECT` | + +**Key rule:** Whenever the server sends DISCONNECTED, or ERROR without a specified channel, it will be accompanied by the server closing the WebSocket connection. An ERROR with a specified channel is an attachment failure and doesn't end the connection. + +### Client-Initiated Close (Library Closing Connection) + +When the Ably library closes the WebSocket connection (e.g., due to heartbeat timeout, explicit close, or fatal error), a `CLIENT_CLOSE` event is recorded. Tests can: + +1. **Inspect events list:** Check `mock_ws.events` for `CLIENT_CLOSE` event +2. **Await the close:** Use `await_client_close()` to wait for the library to close + +```pseudo +# Example: Assert client closed the connection after heartbeat timeout +AWAIT mock_ws.await_client_close(timeout: 1000) + +# Or inspect the events list +client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) +ASSERT client_close_events.length == 1 +``` + +The `ClientCloseEvent` contains: +- `code`: WebSocket close code (e.g., 1000 for normal, 1001 for going away) +- `reason`: Optional human-readable close reason + +## WebSocket Ping Frame Simulation (RTN23b) + +Some WebSocket client implementations surface ping frame events to the application layer. Per RTN23b, if the WebSocket client can observe ping frames, the Ably library can use them as heartbeat indicators instead of requiring HEARTBEAT protocol messages. + +Use `send_ping_frame()` to simulate the server sending a WebSocket ping frame: + +```pseudo +# Simulate server sending a ping frame (transport-level heartbeat) +mock_ws.active_connection.send_ping_frame() +``` + +**When to use ping frames vs HEARTBEAT messages:** + +| Scenario | Method | Use Case | +|----------|--------|----------| +| Platform surfaces ping events | `send_ping_frame()` | RTN23b - Test heartbeat via ping frames | +| Platform doesn't surface pings | `send_to_client(HEARTBEAT_MESSAGE)` | RTN23a - Test heartbeat via protocol messages | + +**Connection URL query parameter:** +- If the client sends `heartbeats=true`, it expects HEARTBEAT protocol messages +- If the client sends `heartbeats=false` (or omits it), the server may use ping frames +- The test should verify which parameter the client sends based on platform capabilities + +## Protocol Message Templates + +Common protocol messages for testing: + +```pseudo +CONNECTED_MESSAGE = ProtocolMessage( + action: CONNECTED, + connectionId: "test-connection-id", + connectionDetails: ConnectionDetails( + connectionKey: "test-connection-key", + clientId: null, + connectionStateTtl: 120000, + maxIdleInterval: 15000 + ) +) + +CLOSED_MESSAGE = ProtocolMessage( + action: CLOSED +) + +DISCONNECTED_MESSAGE = ProtocolMessage( + action: DISCONNECTED, + error: ErrorInfo(code: 80003, message: "Connection disconnected") +) + +ERROR_MESSAGE(code, message) = ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: code, statusCode: code / 100, message: message) +) + +HEARTBEAT_MESSAGE = ProtocolMessage( + action: HEARTBEAT +) +``` + +## Example: Handler Pattern with State + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + IF connection_attempt_count == 1: + # First attempt fails + conn.respond_with_refused() + ELSE: + # Second attempt succeeds + conn.respond_with_success(CONNECTED_MESSAGE) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +client.connect() + +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT connection_attempt_count == 2 +``` + +## Example: Server Sends Token Error + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + # Server sends token error and closes connection + conn.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40142, + statusCode: 401, + message: "Token expired" + ) + )) + } +) +``` + +## Test Isolation + +Each test should: + +1. Create a fresh mock WebSocket +2. Install the mock +3. Create the Realtime client +4. Perform test steps and assertions +5. Close the client +6. Restore/cleanup the mock + +```pseudo +BEFORE EACH TEST: + mock_ws = MockWebSocket() + install_mock(mock_ws) + +AFTER EACH TEST: + IF client IS NOT null: + client.close() + uninstall_mock() +``` + +## Timer Mocking + +Tests that verify timeout behavior should use timer mocking where practical. See the Timer Mocking section below. + +**Pseudocode convention:** + +```pseudo +enable_fake_timers() + +# Start operation +client.connect() + +# Advance time to trigger timeout +ADVANCE_TIME(15000) # Advance 15 seconds instantly + +# Assert timeout behavior +ASSERT client.connection.state == ConnectionState.disconnected +``` + +**Implementation guidance:** + +- **Preferred**: Mock/fake the timer/clock mechanism (e.g., `jest.advanceTimersByTime()` in JavaScript) +- **Alternative**: Use dependency injection of clock/timer abstractions +- **Fallback**: Use actual time delays with short timeout values + +## Async Behavior and Event Loop Considerations + +### Mock close() Must Be Asynchronous + +The mock WebSocket's `close()` method must call `listener.onClose()` **asynchronously** (e.g., via `scheduleMicrotask` or `setTimeout(..., 0)`), not synchronously. This matches the behavior of real WebSocket implementations where `onClose` is triggered via the stream's `onDone` callback. + +```pseudo +# CORRECT - matches real WebSocket behavior +close(code, reason): + IF already_closed: RETURN + closed = true + record_event(CLIENT_CLOSE, {code, reason}) + schedule_microtask(() => listener.onClose(code, reason)) + +# WRONG - would cause issues with state machine timing +close(code, reason): + IF already_closed: RETURN + closed = true + listener.onClose(code, reason) # Synchronous - BAD +``` + +### respondWithSuccess() Ordering + +When a connection attempt succeeds, `respondWithSuccess()` must: +1. **First** - Complete the connection future (so `connect()` returns) +2. **Then** - Deliver the CONNECTED message asynchronously + +This ensures the library has stored the WebSocket connection reference before processing the CONNECTED message (which may start timers that reference the connection). + +```pseudo +respond_with_success(connected_message): + connection = create_mock_connection(listener) + completer.complete(connection) # 1. Connection established + schedule_microtask(() => { + listener.onMessage(connected_message) # 2. Then deliver message + }) +``` + +### Avoiding Arbitrary Real-Time Delays + +Tests should **never** use fixed real-time delays like `await Future.delayed(Duration(milliseconds: 100))`. These cause: +- Slow tests +- Flaky tests (timing varies by machine load) +- Non-deterministic behavior + +Instead: +- Use fake timers with `ADVANCE_TIME()` +- Wait for specific state changes with `AWAIT_STATE` + +```pseudo +# BAD - arbitrary real-time delay +ADVANCE_TIME(3000) +WAIT 100ms # Real-time delay - flaky! +ASSERT state == disconnected + +# GOOD - advance time and wait for state +ADVANCE_TIME(3000) +AWAIT_STATE state == disconnected +``` + +## Verifying State Transitions with Event Sequences + +When testing behavior that involves transient states (e.g., DISCONNECTED during reconnection), **do not** try to catch the state at a specific moment. Instead, record the full sequence of state changes and verify it at the end: + +```pseudo +state_changes = [] +client.connection.on().listen((change) => { + state_changes.append(change.current) +}) + +# Trigger the behavior +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Trigger disconnect and reconnect +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Verify the sequence included the expected states +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] +``` + +This approach is more robust because: +- It doesn't depend on catching a transient state at exactly the right moment +- It works even when immediate reconnection (RTN15a) causes rapid state transitions +- It verifies the complete behavior, not just the final state diff --git a/uts/realtime/unit/presence/local_presence_map.md b/uts/realtime/unit/presence/local_presence_map.md new file mode 100644 index 000000000..f5f631bb3 --- /dev/null +++ b/uts/realtime/unit/presence/local_presence_map.md @@ -0,0 +1,501 @@ +# LocalPresenceMap Tests + +Spec points: `RTP17`, `RTP17b`, `RTP17h` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +Tests the `LocalPresenceMap` (internal PresenceMap per RTP17) that maintains a map of +members entered by the current connection. This map is used for automatic re-entry +(RTP17i, RTP17g) when the channel reattaches. + +Key differences from the main PresenceMap: +- Keyed by `clientId` only (RTP17h), not by `memberKey` (`connectionId:clientId`) +- Only stores members matching the current `connectionId` (RTP17b) +- Applies ENTER, PRESENT, UPDATE, and non-synthesized LEAVE events (RTP17b) +- Ignores synthesized LEAVE events — where connectionId is not a prefix of id (RTP17b, per RTP2b1) +- No sync protocol (startSync/endSync) — that is only on the main PresenceMap +- Messages are applied "in the same way as for the normal PresenceMap" (RTP17), including newness comparison (RTP2a, RTP2b) + +## Interface Under Test + +``` +LocalPresenceMap: + put(message: PresenceMessage) + remove(message: PresenceMessage) -> bool # returns true if removed, false if synthesized leave (ignored) + get(clientId: String) -> PresenceMessage? + values() -> List + clear() +``` + +--- + +## RTP17h - Keyed by clientId, not memberKey + +**Test ID**: `realtime/unit/RTP17h/keyed-by-clientid-0` + +**Spec requirement:** Unlike the main PresenceMap (keyed by memberKey), the RTP17 +PresenceMap must be keyed only by clientId. Otherwise, entries associated with old +connectionIds would never be removed, even if the user deliberately leaves presence. + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +msg1 = PresenceMessage( + action: ENTER, + clientId: "user-1", + connectionId: "conn-A", + id: "conn-A:0:0", + timestamp: 1000, + data: "first" +) +msg2 = PresenceMessage( + action: ENTER, + clientId: "user-1", + connectionId: "conn-B", + id: "conn-B:0:0", + timestamp: 2000, + data: "second" +) + +map.put(msg1) +map.put(msg2) +``` + +### Assertions +```pseudo +# Only one entry — keyed by clientId, second put overwrites the first +ASSERT map.values().length == 1 +ASSERT map.get("user-1") IS NOT null +ASSERT map.get("user-1").data == "second" +ASSERT map.get("user-1").connectionId == "conn-B" +``` + +--- + +## RTP17b - ENTER adds to map + +**Test ID**: `realtime/unit/RTP17b/enter-adds-to-map-0` + +**Spec requirement:** Any ENTER event with a connectionId matching the current client's +connectionId should be applied to the RTP17 presence map. + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000, + data: "hello" +)) +``` + +### Assertions +```pseudo +ASSERT map.get("client-1") IS NOT null +ASSERT map.get("client-1").action == PRESENT # RTP2d2: stored action is always PRESENT +ASSERT map.get("client-1").data == "hello" +ASSERT map.values().length == 1 +``` + +--- + +## RTP17b - UPDATE with no prior entry adds to map + +**Test ID**: `realtime/unit/RTP17b/update-adds-to-map-1` + +**Spec requirement:** ENTER and UPDATE are interchangeable — both add a member to the +map. An UPDATE on a clientId that has no prior entry behaves identically to an ENTER. + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage( + action: UPDATE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000, + data: "from-update" +)) +``` + +### Assertions +```pseudo +ASSERT map.get("client-1") IS NOT null +ASSERT map.get("client-1").action == PRESENT # RTP2d2: stored action is always PRESENT +ASSERT map.get("client-1").data == "from-update" +ASSERT map.values().length == 1 +``` + +--- + +## RTP17b - ENTER after ENTER overwrites + +**Test ID**: `realtime/unit/RTP17b/enter-overwrites-enter-2` + +**Spec requirement:** ENTER and UPDATE are interchangeable. A second ENTER for the same +clientId overwrites the first, just as an UPDATE would. + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000, + data: "first" +)) + +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:1:0", + timestamp: 2000, + data: "second" +)) +``` + +### Assertions +```pseudo +ASSERT map.values().length == 1 +ASSERT map.get("client-1").action == PRESENT # RTP2d2: stored action is always PRESENT +ASSERT map.get("client-1").data == "second" +``` + +--- + +## RTP17b - UPDATE after ENTER overwrites + +**Test ID**: `realtime/unit/RTP17b/update-overwrites-enter-3` + +**Spec requirement:** UPDATE overwrites a prior ENTER for the same clientId. + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000, + data: "initial" +)) + +map.put(PresenceMessage( + action: UPDATE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:1:0", + timestamp: 2000, + data: "updated" +)) +``` + +### Assertions +```pseudo +ASSERT map.values().length == 1 +ASSERT map.get("client-1").action == PRESENT # RTP2d2: stored action is always PRESENT +ASSERT map.get("client-1").data == "updated" +``` + +--- + +## RTP17b - PRESENT adds to map + +**Test ID**: `realtime/unit/RTP17b/present-adds-to-map-4` + +**Spec requirement:** Any PRESENT event with a matching connectionId should be applied. + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage( + action: PRESENT, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000, + data: "present" +)) +``` + +### Assertions +```pseudo +ASSERT map.get("client-1") IS NOT null +ASSERT map.get("client-1").action == PRESENT +ASSERT map.get("client-1").data == "present" +``` + +--- + +## RTP17b - Non-synthesized LEAVE removes from map + +**Test ID**: `realtime/unit/RTP17b/non-synthesized-leave-removes-5` + +**Spec requirement:** Any LEAVE event with a connectionId matching the current client's +connectionId that is NOT a synthesized leave should remove the member. + +A non-synthesized leave has a connectionId that IS an initial substring of its id +(normal server-delivered leave, e.g. id="conn-1:1:0" starts with connectionId="conn-1"). + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +# Add member +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000 +)) + +ASSERT map.get("client-1") IS NOT null + +# Non-synthesized LEAVE: connectionId "conn-1" IS an initial substring of id "conn-1:1:0" +result = map.remove(PresenceMessage( + action: LEAVE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:1:0", + timestamp: 2000 +)) +``` + +### Assertions +```pseudo +ASSERT result == true +ASSERT map.get("client-1") IS null +ASSERT map.values().length == 0 +``` + +--- + +## RTP17b - Synthesized LEAVE is ignored + +**Test ID**: `realtime/unit/RTP17b/synthesized-leave-ignored-6` + +**Spec requirement:** A synthesized leave event (where connectionId is NOT an initial +substring of its id, per RTP2b1) should NOT be applied to the RTP17 presence map. +The remove method checks whether the connectionId is a prefix of the message id. +If it is not, the leave is synthesized and the member must NOT be removed. + +> **Implementation note:** Synthesized-LEAVE filtering (checking whether the LEAVE's +> connectionId matches the local connection) may be implemented either inside the +> presence map's `remove()` method, or at the calling level (e.g., in RealtimePresence). +> The key requirement is that synthesized LEAVEs are not applied to the local presence +> map — the level at which this is enforced is implementation-dependent. + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +# Add member +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000, + data: "entered" +)) + +# Synthesized LEAVE: connectionId "conn-1" is NOT an initial substring of id "synthesized-leave-id" +result = map.remove(PresenceMessage( + action: LEAVE, + clientId: "client-1", + connectionId: "conn-1", + id: "synthesized-leave-id", + timestamp: 2000 +)) +``` + +### Assertions +```pseudo +# remove returns false — synthesized leave was ignored +ASSERT result == false + +# Member is still present +ASSERT map.get("client-1") IS NOT null +ASSERT map.get("client-1").data == "entered" +ASSERT map.values().length == 1 +``` + +--- + +## RTP17 - Multiple clientIds coexist + +**Test ID**: `realtime/unit/RTP17/multiple-clientids-coexist-0` + +**Spec requirement:** The local presence map can contain multiple members with different +clientIds (e.g., when a single connection enters presence with multiple clientIds using +enterClient). + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "conn-1", id: "conn-1:0:0", timestamp: 100, data: "alice-data")) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "conn-1", id: "conn-1:0:1", timestamp: 100, data: "bob-data")) +map.put(PresenceMessage(action: ENTER, clientId: "carol", connectionId: "conn-1", id: "conn-1:0:2", timestamp: 100, data: "carol-data")) +``` + +### Assertions +```pseudo +ASSERT map.values().length == 3 +ASSERT map.get("alice") IS NOT null +ASSERT map.get("bob") IS NOT null +ASSERT map.get("carol") IS NOT null +ASSERT map.get("alice").data == "alice-data" +ASSERT map.get("bob").data == "bob-data" +ASSERT map.get("carol").data == "carol-data" +``` + +--- + +## RTP17 - Remove one of multiple members + +**Test ID**: `realtime/unit/RTP17/remove-one-of-multiple-1` + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "conn-1", id: "conn-1:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "conn-1", id: "conn-1:0:1", timestamp: 100)) + +map.remove(PresenceMessage(action: LEAVE, clientId: "alice", connectionId: "conn-1", id: "conn-1:1:0", timestamp: 200)) +``` + +### Assertions +```pseudo +ASSERT map.get("alice") IS null +ASSERT map.get("bob") IS NOT null +ASSERT map.values().length == 1 +``` + +--- + +## clear() resets all state + +**Test ID**: `realtime/unit/RTP17/clear-resets-state-2` + +**Spec requirement (RTP5a):** When the channel enters DETACHED or FAILED state, the +internal PresenceMap is cleared. This ensures members are not automatically re-entered +if the channel later becomes attached. + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "conn-1", id: "conn-1:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "conn-1", id: "conn-1:0:1", timestamp: 100)) + +ASSERT map.values().length == 2 + +map.clear() +``` + +### Assertions +```pseudo +ASSERT map.values().length == 0 +ASSERT map.get("alice") IS null +ASSERT map.get("bob") IS null +``` + +--- + +## RTP17 - Get returns null for unknown clientId + +**Test ID**: `realtime/unit/RTP17/get-null-unknown-clientid-3` + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +result = map.get("nonexistent") +``` + +### Assertions +```pseudo +ASSERT result IS null +``` + +--- + +## RTP17 - Remove for unknown clientId is a no-op + +**Test ID**: `realtime/unit/RTP17/remove-unknown-noop-4` + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "conn-1", id: "conn-1:0:0", timestamp: 100)) + +# Remove a clientId that was never added (non-synthesized leave) +map.remove(PresenceMessage(action: LEAVE, clientId: "nonexistent", connectionId: "conn-1", id: "conn-1:1:0", timestamp: 200)) +``` + +### Assertions +```pseudo +# Original member is unaffected +ASSERT map.get("alice") IS NOT null +ASSERT map.values().length == 1 +``` diff --git a/uts/realtime/unit/presence/presence_map.md b/uts/realtime/unit/presence/presence_map.md new file mode 100644 index 000000000..9576adb54 --- /dev/null +++ b/uts/realtime/unit/presence/presence_map.md @@ -0,0 +1,769 @@ +# PresenceMap Tests + +Spec points: `RTP2`, `RTP2a`, `RTP2b`, `RTP2b1`, `RTP2b1a`, `RTP2b2`, `RTP2c`, `RTP2d`, `RTP2d1`, `RTP2d2`, `RTP2h`, `RTP2h1`, `RTP2h1a`, `RTP2h1b`, `RTP2h2`, `RTP2h2a`, `RTP2h2b` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +Tests the `PresenceMap` data structure that maintains a map of members currently present +on a channel. The map is keyed by `memberKey` (TP3h: `connectionId:clientId`) and stores +`PresenceMessage` values with action set to `PRESENT` (or `ABSENT` during sync). + +This is a portable data structure test — no WebSocket, connection, or channel infrastructure +is needed. Tests operate directly on the PresenceMap by calling `put()` and `remove()` with +constructed `PresenceMessage` objects. + +## Interface Under Test + +``` +PresenceMap: + put(message: PresenceMessage) -> PresenceMessage? # returns message to emit, or null if stale + remove(message: PresenceMessage) -> PresenceMessage? # returns LEAVE to emit, or null + get(memberKey: String) -> PresenceMessage? + values() -> List # only PRESENT members + clear() + startSync() + endSync() -> List # returns synthesized LEAVE events + isSyncInProgress -> bool +``` + +--- + +## RTP2 - Basic put and get + +**Test ID**: `realtime/unit/RTP2/basic-put-and-get-0` + +**Spec requirement:** Use a PresenceMap to maintain a list of members present on a channel, +a map of memberKeys to presence messages. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +msg = PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000 +) +result = map.put(msg) +``` + +### Assertions +```pseudo +ASSERT result IS NOT null +ASSERT map.get("conn-1:client-1") IS NOT null +ASSERT map.get("conn-1:client-1").clientId == "client-1" +ASSERT map.get("conn-1:client-1").connectionId == "conn-1" +``` + +--- + +## RTP2d2 - ENTER stored as PRESENT + +**Test ID**: `realtime/unit/RTP2d2/enter-stored-as-present-0` + +**Spec requirement:** When an ENTER, UPDATE, or PRESENT message is received, add to the +presence map with action set to PRESENT. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +enter_msg = PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000, + data: "entered" +) +map.put(enter_msg) +``` + +### Assertions +```pseudo +stored = map.get("conn-1:client-1") +ASSERT stored IS NOT null +ASSERT stored.action == PRESENT # RTP2d2: stored as PRESENT regardless of original action +ASSERT stored.data == "entered" +``` + +--- + +## RTP2d2 - UPDATE stored as PRESENT + +**Test ID**: `realtime/unit/RTP2d2/update-stored-as-present-1` + +**Spec requirement:** UPDATE messages are also stored with action PRESENT. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# First enter +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000, + data: "initial" +)) + +# Then update +map.put(PresenceMessage( + action: UPDATE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:1:0", + timestamp: 2000, + data: "updated" +)) +``` + +### Assertions +```pseudo +stored = map.get("conn-1:client-1") +ASSERT stored.action == PRESENT +ASSERT stored.data == "updated" +``` + +--- + +## RTP2d2 - PRESENT stored as PRESENT + +**Test ID**: `realtime/unit/RTP2d2/present-stored-as-present-2` + +**Spec requirement:** PRESENT messages (from SYNC) are stored with action PRESENT. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage( + action: PRESENT, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000 +)) +``` + +### Assertions +```pseudo +stored = map.get("conn-1:client-1") +ASSERT stored IS NOT null +ASSERT stored.action == PRESENT +``` + +--- + +## RTP2d1 - put returns message with original action + +**Test ID**: `realtime/unit/RTP2d1/put-returns-original-action-0` + +**Spec requirement:** Emit to subscribers with the original action (ENTER, UPDATE, or PRESENT), +not the stored PRESENT action. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +emitted_enter = map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000 +)) + +emitted_update = map.put(PresenceMessage( + action: UPDATE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:1:0", + timestamp: 2000, + data: "updated" +)) +``` + +### Assertions +```pseudo +ASSERT emitted_enter IS NOT null +ASSERT emitted_enter.action == ENTER # Original action preserved for emission + +ASSERT emitted_update IS NOT null +ASSERT emitted_update.action == UPDATE # Original action preserved for emission +``` + +--- + +## RTP2h1 - LEAVE outside sync removes member + +**Test ID**: `realtime/unit/RTP2h1/leave-outside-sync-removes-0` + +**Spec requirement:** When a LEAVE message is received and SYNC is NOT in progress, +emit LEAVE and delete from presence map. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Add a member +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000 +)) + +# Remove the member +emitted = map.remove(PresenceMessage( + action: LEAVE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:1:0", + timestamp: 2000 +)) +``` + +### Assertions +```pseudo +# RTP2h1a: Emit LEAVE to subscribers +ASSERT emitted IS NOT null +ASSERT emitted.action == LEAVE + +# RTP2h1b: Delete from presence map +ASSERT map.get("conn-1:client-1") IS null +ASSERT map.values().length == 0 +``` + +--- + +## RTP2h1 - LEAVE for non-existent member returns null + +**Test ID**: `realtime/unit/RTP2h1/leave-nonexistent-returns-null-1` + +**Spec requirement:** If there is no matching memberKey in the map, there is nothing to remove. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +emitted = map.remove(PresenceMessage( + action: LEAVE, + clientId: "unknown", + connectionId: "conn-x", + id: "conn-x:0:0", + timestamp: 1000 +)) +``` + +### Assertions +```pseudo +ASSERT emitted IS null +``` + +--- + +## RTP2h2a - LEAVE during sync stores as ABSENT + +**Test ID**: `realtime/unit/RTP2h2a/leave-during-sync-stores-absent-0` + +**Spec requirement:** If a SYNC is in progress and a LEAVE message is received, +store the member in the presence map with action set to ABSENT. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Add a member +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000 +)) + +# Start sync +map.startSync() + +# LEAVE during sync +emitted = map.remove(PresenceMessage( + action: LEAVE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:1:0", + timestamp: 2000 +)) +``` + +### Assertions +```pseudo +# No LEAVE emitted during sync +ASSERT emitted IS null + +# Member is stored as ABSENT (not deleted) +stored = map.get("conn-1:client-1") +ASSERT stored IS NOT null +ASSERT stored.action == ABSENT +``` + +--- + +## RTP2h2b - ABSENT members deleted on endSync + +**Test ID**: `realtime/unit/RTP2h2b/absent-deleted-on-endsync-0` + +**Spec requirement:** When SYNC completes, delete all members with action ABSENT. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Add two members +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100)) + +# Start sync +map.startSync() + +# Alice gets updated during sync (still present) +map.put(PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 200)) + +# Bob sends LEAVE during sync (stored as ABSENT) +map.remove(PresenceMessage(action: LEAVE, clientId: "bob", connectionId: "c2", id: "c2:1:0", timestamp: 200)) + +# End sync +leave_events = map.endSync() +``` + +### Assertions +```pseudo +# Bob's ABSENT entry was deleted +ASSERT map.get("c2:bob") IS null + +# Alice remains +ASSERT map.get("c1:alice") IS NOT null +ASSERT map.get("c1:alice").action == PRESENT + +ASSERT map.values().length == 1 +``` + +--- + +## RTP2b2 - Newness comparison by id (msgSerial:index) + +**Test ID**: `realtime/unit/RTP2b2/newness-by-msgserial-index-0` + +**Spec requirement:** When the connectionId IS an initial substring of the message id, +split the id into `connectionId:msgSerial:index` and compare msgSerial then index numerically. +Larger values are newer. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Add initial message with msgSerial=5, index=0 +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:5:0", + timestamp: 1000, + data: "first" +)) + +# Try to put an older message (msgSerial=3) +stale_result = map.put(PresenceMessage( + action: UPDATE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:3:0", + timestamp: 2000, + data: "stale" +)) + +# Put a newer message (msgSerial=7) +newer_result = map.put(PresenceMessage( + action: UPDATE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:7:0", + timestamp: 500, + data: "newer" +)) +``` + +### Assertions +```pseudo +# Stale message rejected (RTP2a) +ASSERT stale_result IS null +ASSERT map.get("conn-1:client-1").data == "first" + +# Newer message accepted (even though timestamp is older) +ASSERT newer_result IS NOT null +ASSERT map.get("conn-1:client-1").data == "newer" +``` + +--- + +## RTP2b2 - Newness comparison by index when msgSerial equal + +**Test ID**: `realtime/unit/RTP2b2/newness-by-index-same-serial-1` + +**Spec requirement:** When msgSerial values are equal, compare by index. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:5:2", + timestamp: 1000, + data: "index-2" +)) + +# Same msgSerial, lower index — stale +stale = map.put(PresenceMessage( + action: UPDATE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:5:1", + timestamp: 2000, + data: "index-1" +)) + +# Same msgSerial, higher index — newer +newer = map.put(PresenceMessage( + action: UPDATE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:5:5", + timestamp: 500, + data: "index-5" +)) +``` + +### Assertions +```pseudo +ASSERT stale IS null +ASSERT newer IS NOT null +ASSERT map.get("conn-1:client-1").data == "index-5" +``` + +--- + +## RTP2b1 - Newness comparison by timestamp (synthesized leave) + +**Test ID**: `realtime/unit/RTP2b1/newness-by-timestamp-0` + +**Spec requirement:** If either message has a connectionId which is NOT an initial substring +of its id, compare by timestamp. This handles "synthesized leave" events where the server +generates a LEAVE on behalf of a disconnected client. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Add member with normal id (connectionId is prefix of id) +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000, + data: "entered" +)) + +# Synthesized leave: id does NOT start with connectionId +# (server-generated, uses a different id format) +synth_leave = map.remove(PresenceMessage( + action: LEAVE, + clientId: "client-1", + connectionId: "conn-1", + id: "synthesized-leave-id", + timestamp: 2000 +)) +``` + +### Assertions +```pseudo +# Timestamp 2000 > 1000, so the synthesized leave is newer +ASSERT synth_leave IS NOT null +ASSERT synth_leave.action == LEAVE +ASSERT map.get("conn-1:client-1") IS null +``` + +--- + +## RTP2b1 - Synthesized leave rejected when older by timestamp + +**Test ID**: `realtime/unit/RTP2b1/older-synth-leave-rejected-1` + +**Spec requirement:** When comparing by timestamp, an older synthesized leave is rejected. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 5000, + data: "entered" +)) + +# Synthesized leave with older timestamp +result = map.remove(PresenceMessage( + action: LEAVE, + clientId: "client-1", + connectionId: "conn-1", + id: "synthesized-leave-id", + timestamp: 3000 +)) +``` + +### Assertions +```pseudo +# Rejected — existing message (timestamp 5000) is newer +ASSERT result IS null +ASSERT map.get("conn-1:client-1") IS NOT null +ASSERT map.get("conn-1:client-1").data == "entered" +``` + +--- + +## RTP2b1a - Equal timestamps: incoming message is newer + +**Test ID**: `realtime/unit/RTP2b1a/equal-timestamps-incoming-wins-0` + +**Spec requirement:** If timestamps are equal, the newly-incoming message is considered newer. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "synthesized-id-1", + timestamp: 1000, + data: "first" +)) + +# Same timestamp, incoming wins +result = map.put(PresenceMessage( + action: UPDATE, + clientId: "client-1", + connectionId: "conn-1", + id: "synthesized-id-2", + timestamp: 1000, + data: "second" +)) +``` + +### Assertions +```pseudo +ASSERT result IS NOT null +ASSERT map.get("conn-1:client-1").data == "second" +``` + +--- + +## RTP2c - SYNC messages use same newness comparison + +**Test ID**: `realtime/unit/RTP2c/sync-uses-same-newness-0` + +**Spec requirement:** Presence events from a SYNC must be compared for newness +the same way as PRESENCE messages. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.startSync() + +# First SYNC message +map.put(PresenceMessage( + action: PRESENT, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:5:0", + timestamp: 1000, + data: "sync-first" +)) + +# Second SYNC message with older serial — rejected +stale = map.put(PresenceMessage( + action: PRESENT, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:3:0", + timestamp: 2000, + data: "sync-stale" +)) + +# Third SYNC message with newer serial — accepted +newer = map.put(PresenceMessage( + action: PRESENT, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:8:0", + timestamp: 500, + data: "sync-newer" +)) +``` + +### Assertions +```pseudo +ASSERT stale IS null +ASSERT newer IS NOT null +ASSERT map.get("conn-1:client-1").data == "sync-newer" +``` + +--- + +## RTP2 - Multiple members coexist + +**Test ID**: `realtime/unit/RTP2/multiple-members-coexist-1` + +**Spec requirement:** The presence map maintains multiple members with different memberKeys. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c3", id: "c3:0:0", timestamp: 100)) +``` + +### Assertions +```pseudo +# Three distinct members (alice on c1, bob on c2, alice on c3) +ASSERT map.values().length == 3 +ASSERT map.get("c1:alice") IS NOT null +ASSERT map.get("c2:bob") IS NOT null +ASSERT map.get("c3:alice") IS NOT null +``` + +--- + +## RTP2 - values() excludes ABSENT members + +**Test ID**: `realtime/unit/RTP2/values-excludes-absent-2` + +**Spec requirement:** The values() method returns only PRESENT members. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100)) + +# Start sync and mark bob as ABSENT +map.startSync() +map.remove(PresenceMessage(action: LEAVE, clientId: "bob", connectionId: "c2", id: "c2:1:0", timestamp: 200)) +``` + +### Assertions +```pseudo +# Bob is stored as ABSENT but excluded from values() +ASSERT map.get("c2:bob") IS NOT null +ASSERT map.get("c2:bob").action == ABSENT + +members = map.values() +ASSERT members.length == 1 +ASSERT members[0].clientId == "alice" +``` + +--- + +## clear() resets all state + +**Test ID**: `realtime/unit/RTP2/clear-resets-state-3` + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) +map.startSync() + +map.clear() +``` + +### Assertions +```pseudo +ASSERT map.values().length == 0 +ASSERT map.get("c1:alice") IS null +ASSERT map.isSyncInProgress == false +``` diff --git a/uts/realtime/unit/presence/presence_sync.md b/uts/realtime/unit/presence/presence_sync.md new file mode 100644 index 000000000..f3da077b4 --- /dev/null +++ b/uts/realtime/unit/presence/presence_sync.md @@ -0,0 +1,617 @@ +# Presence Sync Tests + +Spec points: `RTP18`, `RTP18a`, `RTP18b`, `RTP18c`, `RTP19`, `RTP19a` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +Tests the sync protocol on the `PresenceMap` data structure. A presence sync allows the +server to send a complete list of members present on a channel. The sync lifecycle is: +1. `startSync()` — marks existing members as potentially stale (residual) +2. `put()` during sync — marks members as current (removes from residual set) +3. `endSync()` — removes stale members not seen during sync, returns synthesized LEAVE events + +These tests operate directly on the PresenceMap, verifying the sync lifecycle without +any WebSocket, connection, or channel infrastructure. + +## Interface Under Test + +``` +PresenceMap: + put(message: PresenceMessage) -> PresenceMessage? + remove(message: PresenceMessage) -> PresenceMessage? + get(memberKey: String) -> PresenceMessage? + values() -> List + clear() + startSync() + endSync() -> List # returns synthesized LEAVE events for stale members + isSyncInProgress -> bool +``` + +--- + +## RTP18a - startSync sets isSyncInProgress + +**Test ID**: `realtime/unit/RTP18a/startsync-sets-flag-0` + +**Spec requirement:** A new sync has started. The client library must track that a sync +is in progress. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +ASSERT map.isSyncInProgress == false + +map.startSync() +``` + +### Assertions +```pseudo +ASSERT map.isSyncInProgress == true +``` + +--- + +## RTP18b - endSync clears isSyncInProgress + +**Test ID**: `realtime/unit/RTP18b/endsync-clears-flag-0` + +**Spec requirement:** The sync operation has completed once the cursor is empty. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.startSync() +ASSERT map.isSyncInProgress == true + +map.endSync() +``` + +### Assertions +```pseudo +ASSERT map.isSyncInProgress == false +``` + +--- + +## RTP19 - Stale members get LEAVE events after sync + +**Test ID**: `realtime/unit/RTP19/stale-members-leave-after-sync-0` + +**Spec requirement:** If the PresenceMap has existing members when a SYNC is started, +members no longer present on the channel are removed from the local PresenceMap once +the sync is complete. A LEAVE event should be emitted for each removed member. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Pre-populate with two members +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100)) + +ASSERT map.values().length == 2 + +# Start sync — only alice appears in the sync data +map.startSync() +map.put(PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 200)) + +# End sync — bob was not updated, gets removed +leave_events = map.endSync() +``` + +### Assertions +```pseudo +# Bob gets a synthesized LEAVE +ASSERT leave_events.length == 1 +ASSERT leave_events[0].clientId == "bob" +ASSERT leave_events[0].action == LEAVE + +# Only alice remains +ASSERT map.values().length == 1 +ASSERT map.get("c1:alice") IS NOT null +ASSERT map.get("c2:bob") IS null +``` + +--- + +## RTP19 - Synthesized LEAVE has id=null and current timestamp + +**Test ID**: `realtime/unit/RTP19/synth-leave-null-id-timestamp-1` + +**Spec requirement:** The PresenceMessage emitted should contain the original attributes +of the presence member with the action set to LEAVE, PresenceMessage#id set to null, +and the timestamp set to the current time. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage( + action: ENTER, + clientId: "bob", + connectionId: "c2", + id: "c2:0:0", + timestamp: 100, + data: "bob-data" +)) + +before_time = NOW() + +map.startSync() +# No messages for bob during sync +leave_events = map.endSync() + +after_time = NOW() +``` + +### Assertions +```pseudo +ASSERT leave_events.length == 1 + +leave = leave_events[0] +ASSERT leave.action == LEAVE +ASSERT leave.clientId == "bob" +ASSERT leave.connectionId == "c2" +ASSERT leave.data == "bob-data" # Original attributes preserved +ASSERT leave.id IS null # RTP19: id set to null +ASSERT leave.timestamp >= before_time # RTP19: timestamp set to current time +ASSERT leave.timestamp <= after_time +``` + +--- + +## RTP19 - Members updated during sync survive + +**Test ID**: `realtime/unit/RTP19/updated-members-survive-sync-2` + +**Spec requirement:** A member can be added or updated when received in a SYNC message +or when received in a PRESENCE message during the sync process. Members that have been +added or updated should NOT be removed. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Pre-populate with three members +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "carol", connectionId: "c3", id: "c3:0:0", timestamp: 100)) + +map.startSync() + +# Alice arrives via SYNC (PRESENT action) +map.put(PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 200)) + +# Bob arrives via PRESENCE during sync (UPDATE action) +map.put(PresenceMessage(action: UPDATE, clientId: "bob", connectionId: "c2", id: "c2:1:0", timestamp: 200, data: "new-data")) + +# Carol does NOT appear during sync + +leave_events = map.endSync() +``` + +### Assertions +```pseudo +# Only carol is stale +ASSERT leave_events.length == 1 +ASSERT leave_events[0].clientId == "carol" + +# Alice and bob survive +ASSERT map.values().length == 2 +ASSERT map.get("c1:alice") IS NOT null +ASSERT map.get("c2:bob") IS NOT null +ASSERT map.get("c2:bob").data == "new-data" +``` + +--- + +## RTP18a - New sync discards previous in-flight sync + +**Test ID**: `realtime/unit/RTP18a/new-sync-discards-previous-1` + +**Spec requirement:** If a new sequence identifier is sent from Ably, then the client +library must consider that to be the start of a new sync sequence and any previous +in-flight sync should be discarded. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Pre-populate +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100)) + +# First sync starts — only alice appears +map.startSync() +map.put(PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 200)) + +# Before first sync ends, a NEW sync starts (new sequence identifier) +# This discards the previous sync — bob is no longer marked as residual from the first sync +map.startSync() + +# In the new sync, both alice and bob appear +map.put(PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:2:0", timestamp: 300)) +map.put(PresenceMessage(action: PRESENT, clientId: "bob", connectionId: "c2", id: "c2:1:0", timestamp: 300)) + +leave_events = map.endSync() +``` + +### Assertions +```pseudo +# No stale members — both were seen in the new sync +ASSERT leave_events.length == 0 + +ASSERT map.values().length == 2 +ASSERT map.get("c1:alice") IS NOT null +ASSERT map.get("c2:bob") IS NOT null +``` + +--- + +## RTP18c - Single-message sync (no channelSerial) + +**Test ID**: `realtime/unit/RTP18c/single-message-sync-0` + +**Spec requirement:** A SYNC may also be sent with no channelSerial attribute. In this +case, the sync data is entirely contained within that ProtocolMessage. This is modeled +as a startSync + put + endSync in one step. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Pre-populate with alice and bob +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100)) + +# Single-message sync: start, put one member, end immediately +map.startSync() +map.put(PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 200)) +leave_events = map.endSync() +``` + +### Assertions +```pseudo +# Bob was not in the sync — gets LEAVE +ASSERT leave_events.length == 1 +ASSERT leave_events[0].clientId == "bob" +ASSERT leave_events[0].action == LEAVE + +ASSERT map.values().length == 1 +ASSERT map.get("c1:alice") IS NOT null +ASSERT map.isSyncInProgress == false +``` + +--- + +## RTP19a - ATTACHED without HAS_PRESENCE clears all members + +**Test ID**: `realtime/unit/RTP19a/no-has-presence-clears-members-0` + +**Spec requirement:** If the PresenceMap has existing members when an ATTACHED message +is received without a HAS_PRESENCE flag, emit a LEAVE event for each existing member +and remove all members from the PresenceMap. + +Note: The detection of HAS_PRESENCE is handled by the RealtimeChannel, which calls +PresenceMap methods. At the data structure level, this scenario is equivalent to +startSync() followed immediately by endSync() with no puts — all existing members +become stale and are removed. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Pre-populate with members +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100, data: "a")) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100, data: "b")) +map.put(PresenceMessage(action: ENTER, clientId: "carol", connectionId: "c3", id: "c3:0:0", timestamp: 100, data: "c")) + +# No HAS_PRESENCE: immediate sync with no members +map.startSync() +leave_events = map.endSync() +``` + +### Assertions +```pseudo +# All members get LEAVE events +ASSERT leave_events.length == 3 + +# Verify each leave preserves original attributes +alice_leave = leave_events.find(e => e.clientId == "alice") +bob_leave = leave_events.find(e => e.clientId == "bob") +carol_leave = leave_events.find(e => e.clientId == "carol") + +ASSERT alice_leave IS NOT null +ASSERT alice_leave.action == LEAVE +ASSERT alice_leave.data == "a" +ASSERT alice_leave.id IS null + +ASSERT bob_leave IS NOT null +ASSERT bob_leave.action == LEAVE +ASSERT bob_leave.data == "b" +ASSERT bob_leave.id IS null + +ASSERT carol_leave IS NOT null +ASSERT carol_leave.action == LEAVE +ASSERT carol_leave.data == "c" +ASSERT carol_leave.id IS null + +# Map is empty +ASSERT map.values().length == 0 +``` + +--- + +## RTP2h2a - LEAVE during sync stored as ABSENT (in sync context) + +**Test ID**: `realtime/unit/RTP2h2a/leave-during-sync-absent-cleanup-0` + +**Spec requirement:** If a SYNC is in progress and a LEAVE message is received, store +the member with action set to ABSENT. On endSync, ABSENT members are deleted (RTP2h2b). + +This test verifies the interaction between LEAVE-during-sync and endSync cleanup. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Pre-populate +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100)) + +map.startSync() + +# Alice appears in sync +map.put(PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 200)) + +# Bob sends LEAVE during sync — stored as ABSENT, not emitted yet +leave_result = map.remove(PresenceMessage(action: LEAVE, clientId: "bob", connectionId: "c2", id: "c2:1:0", timestamp: 200)) + +# Verify bob is ABSENT but still in map +ASSERT leave_result IS null +ASSERT map.get("c2:bob") IS NOT null +ASSERT map.get("c2:bob").action == ABSENT + +# End sync +leave_events = map.endSync() +``` + +### Assertions +```pseudo +# Bob's ABSENT entry is cleaned up on endSync (RTP2h2b) — no synthesized +# LEAVE event is emitted for bob because he was explicitly marked ABSENT +# via a LEAVE message (not stale-by-absence-from-sync). ABSENT members +# are simply deleted on endSync without generating LEAVE events. +# Synthesized LEAVE events (RTP19) are only for PRESENT members that +# were not updated during sync (residuals). +ASSERT leave_events.length == 0 +ASSERT map.get("c2:bob") IS null + +# Alice survives +ASSERT map.values().length == 1 +ASSERT map.get("c1:alice") IS NOT null +``` + +--- + +## RTP19 - Empty map sync produces no leave events + +**Test ID**: `realtime/unit/RTP19/empty-map-sync-no-leaves-3` + +**Spec requirement:** If there are no existing members when sync starts, endSync +produces no leave events. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.startSync() +map.put(PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) +leave_events = map.endSync() +``` + +### Assertions +```pseudo +ASSERT leave_events.length == 0 +ASSERT map.values().length == 1 +ASSERT map.get("c1:alice") IS NOT null +``` + +--- + +## RTP18 - endSync without startSync is a no-op + +**Test ID**: `realtime/unit/RTP18/endsync-without-startsync-noop-0` + +**Spec requirement:** Calling endSync when no sync is in progress should not +corrupt the map state. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) + +# endSync without startSync +leave_events = map.endSync() +``` + +### Assertions +```pseudo +ASSERT leave_events.length == 0 +ASSERT map.values().length == 1 +ASSERT map.get("c1:alice") IS NOT null +ASSERT map.isSyncInProgress == false +``` + +--- + +## RTP19 - Stale SYNC message still removes member from residuals + +**Test ID**: `realtime/unit/RTP19/stale-sync-removes-from-residuals-4` + +**Spec requirement:** When a member exists from a PRESENCE event and a SYNC starts, +a SYNC message arriving with the same or older id for that member is stale (rejected +by the newness check). However, the member has been "seen" during sync — it must NOT +be evicted as residual on endSync. The residual removal must happen before the newness +check in put(). + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Pre-populate with a member via ENTER +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:5:0", timestamp: 500, data: "original")) + +# Start sync +map.startSync() + +# SYNC message arrives with OLDER id (stale — same connectionId, lower msgSerial) +result = map.put(PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:3:0", timestamp: 300, data: "stale")) + +leave_events = map.endSync() +``` + +### Assertions +```pseudo +# The stale put was rejected (returns null) +ASSERT result IS null + +# But alice must NOT be evicted — she was "seen" during sync +ASSERT leave_events.length == 0 +ASSERT map.values().length == 1 +ASSERT map.get("c1:alice") IS NOT null + +# Original data is preserved (stale message did not overwrite) +ASSERT map.get("c1:alice").data == "original" +``` + +--- + +## RTP19 - PRESENCE echoes followed by SYNC preserves all members + +**Test ID**: `realtime/unit/RTP19/presence-echoes-then-sync-preserves-5` + +**Spec requirement:** When a client enters multiple members, the server echoes each +as a PRESENCE event. When the server subsequently sends a SYNC containing the same +members, all members should survive even though the SYNC messages may have the same +or older ids as the PRESENCE echoes. + +This tests the real protocol flow where PRESENCE echoes populate the map before SYNC. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Simulate server echoing PRESENCE events for 3 members +map.put(PresenceMessage(action: ENTER, clientId: "user-0", connectionId: "c1", id: "c1:0:0", timestamp: 100, data: "data-0")) +map.put(PresenceMessage(action: ENTER, clientId: "user-1", connectionId: "c1", id: "c1:1:0", timestamp: 100, data: "data-1")) +map.put(PresenceMessage(action: ENTER, clientId: "user-2", connectionId: "c1", id: "c1:2:0", timestamp: 100, data: "data-2")) + +ASSERT map.values().length == 3 + +# Server starts SYNC — members already exist from PRESENCE echoes +map.startSync() + +# SYNC messages arrive with the SAME ids as the PRESENCE echoes (stale) +map.put(PresenceMessage(action: PRESENT, clientId: "user-0", connectionId: "c1", id: "c1:0:0", timestamp: 100, data: "data-0")) +map.put(PresenceMessage(action: PRESENT, clientId: "user-1", connectionId: "c1", id: "c1:1:0", timestamp: 100, data: "data-1")) +map.put(PresenceMessage(action: PRESENT, clientId: "user-2", connectionId: "c1", id: "c1:2:0", timestamp: 100, data: "data-2")) + +leave_events = map.endSync() +``` + +### Assertions +```pseudo +# No members evicted — all were seen during sync despite stale ids +ASSERT leave_events.length == 0 +ASSERT map.values().length == 3 + +FOR i IN 0..2: + member = map.get("c1:user-${i}") + ASSERT member IS NOT null + ASSERT member.data == "data-${i}" +``` + +--- + +## RTP19 - New member added during sync is not stale + +**Test ID**: `realtime/unit/RTP19/new-member-during-sync-survives-6` + +**Spec requirement:** A member can be added during the sync process. New members +that did not exist before the sync should survive endSync. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Pre-populate with alice only +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) + +map.startSync() + +# Alice appears in sync +map.put(PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 200)) + +# Bob is NEW — entered via PRESENCE message during sync (not from SYNC data) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 200)) + +leave_events = map.endSync() +``` + +### Assertions +```pseudo +# No leave events — both alice and bob are current +ASSERT leave_events.length == 0 +ASSERT map.values().length == 2 +ASSERT map.get("c1:alice") IS NOT null +ASSERT map.get("c2:bob") IS NOT null +``` diff --git a/uts/realtime/unit/presence/realtime_presence_channel_state.md b/uts/realtime/unit/presence/realtime_presence_channel_state.md new file mode 100644 index 000000000..6a54a2aff --- /dev/null +++ b/uts/realtime/unit/presence/realtime_presence_channel_state.md @@ -0,0 +1,846 @@ +# RealtimePresence Channel State Tests + +Spec points: `RTL9`, `RTL9a`, `RTL11`, `RTL11a`, `RTP1`, `RTP5`, `RTP5a`, `RTP5b`, `RTP5f`, `RTP13` + +## Test Type +Unit test — mock WebSocket required. + +## Purpose + +Tests the interaction between channel state transitions and presence. Covers the +HAS_PRESENCE flag triggering a sync, channel state side effects on presence maps, +the syncComplete attribute, the RealtimeChannel#presence attribute (RTL9), and +channel state effects on queued presence actions (RTL11). + +--- + +## RTP1 - HAS_PRESENCE flag triggers sync + +**Test ID**: `realtime/unit/RTP1/has-presence-triggers-sync-0` + +**Spec requirement:** When a channel ATTACHED ProtocolMessage is received with the +HAS_PRESENCE flag set, the server will perform a SYNC operation. If the flag is 0 +or absent, the presence map should be considered in sync immediately with no members. + +### Setup +```pseudo +channel_name = "test-RTP1-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + # Server follows up with SYNC + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Wait for sync to complete +members = AWAIT channel.presence.get() +``` + +### Assertions +```pseudo +ASSERT members.length == 1 +ASSERT members[0].clientId == "alice" +ASSERT channel.presence.syncComplete == true + +CLOSE_CLIENT(client) +``` + +--- + +## RTP1 - No HAS_PRESENCE flag means empty presence + +**Test ID**: `realtime/unit/RTP1/no-has-presence-empty-1` + +**Spec requirement:** If the flag is 0 or absent, the presence map should be considered +in sync immediately with no members present on the channel. + +### Setup +```pseudo +channel_name = "test-RTP1-empty-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # No HAS_PRESENCE flag + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +members = AWAIT channel.presence.get() +``` + +### Assertions +```pseudo +ASSERT members.length == 0 +ASSERT channel.presence.syncComplete == true # Immediately in sync + +CLOSE_CLIENT(client) +``` + +--- + +## RTP1, RTP19a - No HAS_PRESENCE clears existing members + +**Test ID**: `realtime/unit/RTP1/no-has-presence-clears-existing-2` + +**Spec requirement (RTP19a):** If the PresenceMap has existing members when an ATTACHED +message is received without a HAS_PRESENCE flag, emit a LEAVE event for each existing +member and remove all members from the PresenceMap. + +### Setup +```pseudo +channel_name = "test-RTP19a-${random_id()}" + +connection_count = 0 +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "conn-${connection_count}" + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + IF connection_count == 1: + # First attach: has presence + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100), + PresenceMessage(action: PRESENT, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100) + ] + )) + ELSE: + # Second attach: no HAS_PRESENCE + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Verify members exist after first sync +members = AWAIT channel.presence.get() +ASSERT members.length == 2 + +# Track LEAVE events +leave_events = [] +channel.presence.subscribe(action: LEAVE, (event) => { + leave_events.append(event) +}) + +# Simulate disconnect and reconnect +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Reconnect — this time ATTACHED without HAS_PRESENCE +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT_STATE channel.state == ChannelState.attached + +members_after = AWAIT channel.presence.get() +``` + +### Assertions +```pseudo +# All members removed +ASSERT members_after.length == 0 + +# LEAVE events emitted for each member +ASSERT leave_events.length == 2 +ASSERT leave_events.any(e => e.clientId == "alice") +ASSERT leave_events.any(e => e.clientId == "bob") + +# LEAVE events have id=null per RTP19a +ASSERT leave_events.every(e => e.id IS null) + +CLOSE_CLIENT(client) +``` + +--- + +## RTP5a - DETACHED clears both presence maps + +**Test ID**: `realtime/unit/RTP5a/detached-clears-presence-maps-0` + +**Spec requirement:** If the channel enters the DETACHED state, all queued presence +messages fail immediately, and both the PresenceMap and internal PresenceMap (RTP17) +are cleared. LEAVE events should NOT be emitted when clearing. + +### Setup +```pseudo +channel_name = "test-RTP5a-detached-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100) + ] + )) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage(action: DETACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Verify member exists +members = AWAIT channel.presence.get() +ASSERT members.length == 1 + +# Track events — LEAVE should NOT be emitted on clear +leave_events = [] +channel.presence.subscribe(action: LEAVE, (event) => { + leave_events.append(event) +}) + +# Detach the channel +AWAIT channel.detach() +ASSERT channel.state == ChannelState.detached +``` + +### Assertions +```pseudo +# RTP5a: No LEAVE events emitted when clearing on DETACHED +ASSERT leave_events.length == 0 + +# Presence map is cleared +members_after = channel.presence.get(waitForSync: false) +ASSERT members_after.length == 0 + +CLOSE_CLIENT(client) +``` + +--- + +## RTP5a - FAILED clears both presence maps + +**Test ID**: `realtime/unit/RTP5a/failed-clears-presence-maps-1` + +**Spec requirement:** Same as DETACHED — FAILED state clears both maps, no LEAVE emitted. + +### Setup +```pseudo +channel_name = "test-RTP5a-failed-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +members = AWAIT channel.presence.get() +ASSERT members.length == 1 + +leave_events = [] +channel.presence.subscribe(action: LEAVE, (event) => { + leave_events.append(event) +}) + +# Server sends channel ERROR to put channel in FAILED state +mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 90001, message: "Channel failed") +)) + +AWAIT_STATE channel.state == ChannelState.failed +``` + +### Assertions +```pseudo +# RTP5a: No LEAVE events emitted +ASSERT leave_events.length == 0 + +CLOSE_CLIENT(client) +``` + +--- + +## RTP5b - ATTACHED sends queued presence messages + +**Test ID**: `realtime/unit/RTP5b/attached-sends-queued-presence-0` + +**Spec requirement:** If a channel enters the ATTACHED state then all queued presence +messages will be sent immediately. + +### Setup +```pseudo +channel_name = "test-RTP5b-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Delay attach response + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach — channel goes to ATTACHING +channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Queue presence while channel is ATTACHING +enter_future = channel.presence.enter(data: "queued") + +# No presence sent yet +ASSERT captured_presence.length == 0 + +# Complete the attach +mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + +AWAIT enter_future +``` + +### Assertions +```pseudo +# Queued presence was sent after attach completed +ASSERT captured_presence.length == 1 +ASSERT captured_presence[0].presence[0].action == ENTER +ASSERT captured_presence[0].presence[0].data == "queued" + +CLOSE_CLIENT(client) +``` + +--- + +## RTP5f - SUSPENDED maintains presence map + +**Test ID**: `realtime/unit/RTP5f/suspended-maintains-presence-map-0` + +**Spec requirement:** If the channel enters SUSPENDED, all queued presence messages fail +immediately, but the PresenceMap is maintained. This ensures that when the channel later +becomes ATTACHED, it will only emit presence events for changes that occurred while +disconnected. + +### Setup +```pseudo +channel_name = "test-RTP5f-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100), + PresenceMessage(action: PRESENT, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +members = AWAIT channel.presence.get() +ASSERT members.length == 2 + +# Channel becomes SUSPENDED (e.g., connection transitions to SUSPENDED) +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE channel.state == ChannelState.suspended + +# PresenceMap is maintained during SUSPENDED +members_during_suspended = channel.presence.get(waitForSync: false) +``` + +### Assertions +```pseudo +# Members still exist in the map +ASSERT members_during_suspended.length == 2 + +CLOSE_CLIENT(client) +``` + +--- + +## RTP13 - syncComplete attribute + +**Test ID**: `realtime/unit/RTP13/sync-complete-attribute-0` + +**Spec requirement:** RealtimePresence#syncComplete is true if the initial SYNC +operation has completed for the members present on the channel. + +### Setup +```pseudo +channel_name = "test-RTP13-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + # Start multi-message SYNC (cursor is non-empty) + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:cursor1", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Sync is in progress — not yet complete +ASSERT channel.presence.syncComplete == false + +# Complete the sync (empty cursor) +mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100) + ] +)) +``` + +### Assertions +```pseudo +ASSERT channel.presence.syncComplete == true + +CLOSE_CLIENT(client) +``` + +--- + +## RTL9, RTL9a - RealtimeChannel#presence attribute + +**Test ID**: `realtime/unit/RTL9/presence-attribute-0` + +**Spec requirement (RTL9):** `RealtimeChannel#presence` attribute. +**Spec requirement (RTL9a):** Returns the `RealtimePresence` object for this channel. + +### Setup +```pseudo +channel_name = "test-RTL9a-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => {} +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +presence = channel.presence +``` + +### Assertions +```pseudo +ASSERT presence IS RealtimePresence +ASSERT presence IS NOT null +``` + +### RTL9a - Same presence object returned for same channel + +```pseudo +ASSERT channel.presence === channel.presence # identity check — same instance + +CLOSE_CLIENT(client) +``` + +--- + +## RTL11 - Queued presence actions fail on DETACHED + +**Test ID**: `realtime/unit/RTL11/queued-presence-fail-detached-0` + +**Spec requirement (RTL11):** If a channel enters the DETACHED, SUSPENDED or FAILED +state, then all presence actions that are still queued for send on that channel per +RTP16b should be deleted from the queue, and any callback passed to the corresponding +presence method invocation should be called with an ErrorInfo indicating the failure. + +**Note on ATTACHING → DETACHED transition:** Per RTL13b, when a channel is in the +ATTACHING state and receives a DETACHED ProtocolMessage from the server, the SDK +should retry the attach. If the retry fails or the connection is not in a state +that permits re-attach, the channel may transition to SUSPENDED rather than DETACHED. +The test below puts the channel into DETACHED state via an explicit `detach()` call +after a successful attach, which avoids the RTL13b retry path. + +### Setup +```pseudo +channel_name = "test-RTL11-detached-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage(action: DETACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach then detach to put channel in DETACHED state +AWAIT channel.attach() +AWAIT channel.detach() +ASSERT channel.state == ChannelState.detached + +# Attempting presence on a DETACHED channel should error immediately +AWAIT channel.presence.enter(data: "queued-enter") FAILS WITH error +``` + +### Assertions +```pseudo +# No presence messages were sent +ASSERT captured_presence.length == 0 + +# The enter completed with an error +ASSERT error IS ErrorInfo +ASSERT error.code IS NOT null + +CLOSE_CLIENT(client) +``` + +--- + +## RTL11 - Queued presence actions fail on SUSPENDED + +**Test ID**: `realtime/unit/RTL11/queued-presence-fail-suspended-1` + +### Setup +```pseudo +channel_name = "test-RTL11-suspended-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Do NOT respond — leave channel in ATTACHING + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Queue multiple presence actions +enter_future = channel.presence.enter(data: "queued-enter") +update_future = channel.presence.update(data: "queued-update") + +ASSERT captured_presence.length == 0 + +# Connection goes SUSPENDED, causing channel to go SUSPENDED +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE channel.state == ChannelState.suspended +``` + +### Assertions +```pseudo +# No presence messages were sent +ASSERT captured_presence.length == 0 + +# Both queued futures completed with errors +AWAIT enter_future FAILS WITH enter_error +ASSERT enter_error IS ErrorInfo + +AWAIT update_future FAILS WITH update_error +ASSERT update_error IS ErrorInfo + +CLOSE_CLIENT(client) +``` + +--- + +## RTL11 - Queued presence actions fail on FAILED + +**Test ID**: `realtime/unit/RTL11/queued-presence-fail-failed-2` + +### Setup +```pseudo +channel_name = "test-RTL11-failed-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Do NOT respond — leave channel in ATTACHING + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Queue presence +enter_future = channel.presence.enter(data: "queued-enter") + +ASSERT captured_presence.length == 0 + +# Server sends ERROR for this channel — channel goes FAILED +mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 90001, message: "Channel failed") +)) + +AWAIT_STATE channel.state == ChannelState.failed +``` + +### Assertions +```pseudo +# No presence messages were sent +ASSERT captured_presence.length == 0 + +# Queued future completed with an error +AWAIT enter_future FAILS WITH error +ASSERT error IS ErrorInfo + +CLOSE_CLIENT(client) +``` + +--- + +## RTL11a - ACK/NACK unaffected by channel state changes + +**Test ID**: `realtime/unit/RTL11a/ack-nack-unaffected-by-state-0` + +**Spec requirement (RTL11a):** For clarity, any messages awaiting an ACK or NACK are +unaffected by channel state changes i.e. a channel that becomes detached following an +explicit request to detach may still receive an ACK or NACK for messages published on +that channel later. + +### Setup +```pseudo +channel_name = "test-RTL11a-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + # Do NOT send ACK yet — hold it + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage(action: DETACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Send presence — it goes to the server, but no ACK yet +enter_future = channel.presence.enter(data: "awaiting-ack") +ASSERT captured_presence.length == 1 + +# Detach the channel +channel.detach() +AWAIT_STATE channel.state == ChannelState.detached + +# Now the server sends the ACK for the presence message that was already sent +mock_ws.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: captured_presence[0].msgSerial, + count: 1 +)) +``` + +### Assertions +```pseudo +# The enter future resolves successfully — ACK was processed despite channel being DETACHED +AWAIT enter_future # should complete without error + +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/presence/realtime_presence_enter.md b/uts/realtime/unit/presence/realtime_presence_enter.md new file mode 100644 index 000000000..23fadf70e --- /dev/null +++ b/uts/realtime/unit/presence/realtime_presence_enter.md @@ -0,0 +1,1244 @@ +# RealtimePresence Enter/Update/Leave Tests + +Spec points: `RTP4`, `RTP8`, `RTP8a`–`RTP8j`, `RTP9`, `RTP9a`–`RTP9e`, `RTP10`, `RTP10a`–`RTP10e`, `RTP14`, `RTP14a`–`RTP14d`, `RTP15`, `RTP15a`–`RTP15f`, `RTP16`, `RTP16a`–`RTP16c` + +## Test Type +Unit test — mock WebSocket required. + +## Purpose + +Tests the `RealtimePresence#enter`, `update`, `leave`, `enterClient`, `updateClient`, +and `leaveClient` functions. These methods send PRESENCE ProtocolMessages to the server +and handle ACK/NACK responses. Tests cover protocol message format, implicit channel +attach, connection state conditions, and error cases. + +**Note on wildcard clientId:** Several tests use `clientId: "*"` (wildcard) which is +the Ably convention for clients permitted to act on behalf of any clientId via +`enterClient`/`updateClient`/`leaveClient`. Some SDKs may reject `"*"` at the +`ClientOptions` construction level. In such cases, adapt these tests to use a +concrete clientId (e.g., `"admin"`) and skip the client-side `enterClient` clientId +mismatch check (RTP15f), or configure the mock to accept any clientId. + +--- + +## RTP8a, RTP8c - enter sends PRESENCE with ENTER action + +**Test ID**: `realtime/unit/RTP8a/enter-sends-presence-enter-0` + +**Spec requirement:** Enters the current client into this channel. A PRESENCE +ProtocolMessage with a PresenceMessage with action ENTER is sent. The clientId +attribute of the PresenceMessage must not be present (implicitly uses the connection's +clientId). + +### Setup +```pseudo +channel_name = "test-RTP8a-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: msg.msgSerial, + count: 1 + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enter() +``` + +### Assertions +```pseudo +ASSERT captured_presence.length == 1 +ASSERT captured_presence[0].action == PRESENCE +ASSERT captured_presence[0].channel == channel_name +ASSERT captured_presence[0].presence.length == 1 +ASSERT captured_presence[0].presence[0].action == ENTER +# RTP8c: clientId must NOT be present in the PresenceMessage +ASSERT captured_presence[0].presence[0].clientId IS null + +CLOSE_CLIENT(client) +``` + +--- + +## RTP8e - enter with data + +**Test ID**: `realtime/unit/RTP8e/enter-with-data-0` + +**Spec requirement:** Optional data can be included when entering. Data will be encoded +and decoded as with normal messages. + +### Setup +```pseudo +channel_name = "test-RTP8e-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enter(data: "hello world") +``` + +### Assertions +```pseudo +ASSERT captured_presence.length == 1 +ASSERT captured_presence[0].presence[0].action == ENTER +ASSERT captured_presence[0].presence[0].data == "hello world" + +CLOSE_CLIENT(client) +``` + +--- + +## RTP8d - enter implicitly attaches channel + +**Test ID**: `realtime/unit/RTP8d/enter-implicitly-attaches-0` + +**Spec requirement:** Implicitly attaches the RealtimeChannel if the channel is in the +INITIALIZED state. + +### Setup +```pseudo +channel_name = "test-RTP8d-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT channel.state == ChannelState.initialized + +# enter() on INITIALIZED channel triggers implicit attach +AWAIT channel.presence.enter() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached + +CLOSE_CLIENT(client) +``` + +--- + +## RTP8g - enter on DETACHED or FAILED channel errors + +**Test ID**: `realtime/unit/RTP8g/enter-detached-failed-errors-0` + +**Spec requirement:** If the channel is DETACHED or FAILED, the enter request results +in an error immediately. + +### Setup +```pseudo +channel_name = "test-RTP8g-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Respond with error to put channel in FAILED state + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 90001, message: "Channel failed") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Put channel into FAILED state +AWAIT channel.attach() FAILS WITH attach_error +ASSERT channel.state == ChannelState.failed + +# enter() on FAILED channel should error immediately +AWAIT channel.presence.enter() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null + +CLOSE_CLIENT(client) +``` + +--- + +## RTP8j - enter with wildcard or null clientId errors + +**Test ID**: `realtime/unit/RTP8j/enter-null-clientid-errors-0` + +**Spec requirement:** If the connection is CONNECTED and the clientId is '*' (wildcard) +or null (anonymous), the enter request results in an error immediately. + +### Setup +```pseudo +channel_name = "test-RTP8j-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +# No clientId — anonymous client +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# enter() without clientId should error +AWAIT channel.presence.enter() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null + +CLOSE_CLIENT(client) +``` + +--- + +## RTP8j - enter with wildcard clientId errors + +**Test ID**: `realtime/unit/RTP8j/enter-wildcard-clientid-errors-1` + +### Setup + +Note: Some SDKs may reject wildcard clientId `"*"` at the `ClientOptions` +construction level rather than at `enter()` time. In that case, this test +validates that the error occurs at `ClientOptions` creation instead. +```pseudo +channel_name = "test-RTP8j-wild-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +# Wildcard clientId +# Note: clientId "*" may not be accepted by all SDKs at construction time. +# See top-level note for alternative auth patterns (e.g., key auth without clientId). +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enter() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null + +CLOSE_CLIENT(client) +``` + +--- + +## RTP8h - NACK for missing presence permission + +**Test ID**: `realtime/unit/RTP8h/nack-presence-permission-denied-0` + +**Spec requirement:** If the Ably service determines that the client does not have +required presence permission, a NACK is sent resulting in an error. + +### Setup +```pseudo +channel_name = "test-RTP8h-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + mock_ws.send_to_client(ProtocolMessage( + action: NACK, + msgSerial: msg.msgSerial, + count: 1, + error: ErrorInfo(code: 40160, statusCode: 401, message: "Presence permission denied") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enter() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code == 40160 + +CLOSE_CLIENT(client) +``` + +--- + +## RTP9a, RTP9d - update sends PRESENCE with UPDATE action + +**Test ID**: `realtime/unit/RTP9a/update-sends-presence-update-0` + +**Spec requirement:** Updates the data for the present member. A PRESENCE ProtocolMessage +with action UPDATE is sent. The clientId must not be present. + +### Setup +```pseudo +channel_name = "test-RTP9a-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.update(data: "new-status") +``` + +### Assertions +```pseudo +ASSERT captured_presence.length == 1 +ASSERT captured_presence[0].presence[0].action == UPDATE +ASSERT captured_presence[0].presence[0].data == "new-status" +ASSERT captured_presence[0].presence[0].clientId IS null # RTP9d + +CLOSE_CLIENT(client) +``` + +--- + +## RTP10a, RTP10c - leave sends PRESENCE with LEAVE action + +**Test ID**: `realtime/unit/RTP10a/leave-sends-presence-leave-0` + +**Spec requirement:** Leaves this client from the channel. A PRESENCE ProtocolMessage +with action LEAVE is sent. The clientId must not be present. + +### Setup +```pseudo +channel_name = "test-RTP10a-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.leave() +``` + +### Assertions +```pseudo +ASSERT captured_presence.length == 1 +ASSERT captured_presence[0].presence[0].action == LEAVE +ASSERT captured_presence[0].presence[0].clientId IS null # RTP10c + +CLOSE_CLIENT(client) +``` + +--- + +## RTP10a - leave with data updates the member data + +**Test ID**: `realtime/unit/RTP10a/leave-with-data-1` + +**Spec requirement:** The data will be updated with the values provided when leaving. + +### Setup +```pseudo +channel_name = "test-RTP10a-data-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.leave(data: "goodbye") +``` + +### Assertions +```pseudo +ASSERT captured_presence[0].presence[0].action == LEAVE +ASSERT captured_presence[0].presence[0].data == "goodbye" + +CLOSE_CLIENT(client) +``` + +--- + +## RTP14a - enterClient enters on behalf of another clientId + +**Test ID**: `realtime/unit/RTP14a/enterclient-on-behalf-0` + +**Spec requirement:** Enters into presence on a channel on behalf of another clientId. +This allows a single client with suitable permissions to register presence on behalf +of any number of clients using a single connection. + +### Setup +```pseudo +channel_name = "test-RTP14a-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +# Note: clientId "*" may not be accepted by all SDKs at construction time. +# See top-level note for alternative auth patterns (e.g., key auth without clientId). +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enterClient("user-alice", data: "alice-data") +AWAIT channel.presence.enterClient("user-bob", data: "bob-data") +``` + +### Assertions +```pseudo +ASSERT captured_presence.length == 2 + +# First enter: user-alice +ASSERT captured_presence[0].presence[0].action == ENTER +ASSERT captured_presence[0].presence[0].clientId == "user-alice" +ASSERT captured_presence[0].presence[0].data == "alice-data" + +# Second enter: user-bob +ASSERT captured_presence[1].presence[0].action == ENTER +ASSERT captured_presence[1].presence[0].clientId == "user-bob" +ASSERT captured_presence[1].presence[0].data == "bob-data" + +CLOSE_CLIENT(client) +``` + +--- + +## RTP15a - updateClient and leaveClient + +**Test ID**: `realtime/unit/RTP15a/updateclient-leaveclient-0` + +**Spec requirement:** Performs update or leave for a given clientId. Functionally +equivalent to the corresponding enter, update, and leave methods. + +### Setup +```pseudo +channel_name = "test-RTP15a-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +# Note: clientId "*" may not be accepted by all SDKs at construction time. +# See top-level note for alternative auth patterns (e.g., key auth without clientId). +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enterClient("user-1", data: "entered") +AWAIT channel.presence.updateClient("user-1", data: "updated") +AWAIT channel.presence.leaveClient("user-1", data: "leaving") +``` + +### Assertions +```pseudo +ASSERT captured_presence.length == 3 + +ASSERT captured_presence[0].presence[0].action == ENTER +ASSERT captured_presence[0].presence[0].clientId == "user-1" +ASSERT captured_presence[0].presence[0].data == "entered" + +ASSERT captured_presence[1].presence[0].action == UPDATE +ASSERT captured_presence[1].presence[0].clientId == "user-1" +ASSERT captured_presence[1].presence[0].data == "updated" + +ASSERT captured_presence[2].presence[0].action == LEAVE +ASSERT captured_presence[2].presence[0].clientId == "user-1" +ASSERT captured_presence[2].presence[0].data == "leaving" + +CLOSE_CLIENT(client) +``` + +--- + +## RTP15e - enterClient implicitly attaches channel + +**Test ID**: `realtime/unit/RTP15e/enterclient-implicitly-attaches-0` + +**Spec requirement:** Implicitly attaches the RealtimeChannel if the channel is in the +INITIALIZED state. If the channel is in or enters the DETACHED or FAILED state, error. + +### Setup +```pseudo +channel_name = "test-RTP15e-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +# Note: clientId "*" may not be accepted by all SDKs at construction time. +# See top-level note for alternative auth patterns (e.g., key auth without clientId). +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT channel.state == ChannelState.initialized + +AWAIT channel.presence.enterClient("user-1") +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached + +CLOSE_CLIENT(client) +``` + +--- + +## RTP15f - enterClient with mismatched clientId errors + +**Test ID**: `realtime/unit/RTP15f/enterclient-mismatched-clientid-0` + +**Spec requirement:** If the client is identified and has a valid clientId, and the +clientId argument does not match the client's clientId, then it should indicate an error. + +### Setup +```pseudo +channel_name = "test-RTP15f-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +# Client has a specific (non-wildcard) clientId +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# enterClient with a different clientId than the connection's clientId +AWAIT channel.presence.enterClient("other-client") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +# Connection and channel remain available +ASSERT client.connection.state == ConnectionState.connected +ASSERT channel.state == ChannelState.attached + +CLOSE_CLIENT(client) +``` + +> **Implementation note:** Some SDKs do not perform client-side clientId validation +> for `enterClient`. In such cases, the PRESENCE message is sent to the server which +> responds with a NACK (error). The test mock should include a handler that returns a +> NACK for the mismatched clientId: +> ```pseudo +> mock_ws.onMessageFromClient = (msg) => +> IF msg.action == PRESENCE: +> conn.send_to_client(ProtocolMessage( +> action: NACK, +> msgSerial: msg.msgSerial, +> error: ErrorInfo(code: 40012, statusCode: 400, message: "Invalid clientId") +> )) +> ``` +> The key requirement is that the operation results in an error — whether client-side +> or via server NACK is implementation-dependent. + +--- + +## RTP16a - Presence message sent when channel is ATTACHED + +**Test ID**: `realtime/unit/RTP16a/presence-sent-when-attached-0` + +**Spec requirement:** If the channel is ATTACHED then presence messages are sent +immediately to the connection. + +### Setup +```pseudo +channel_name = "test-RTP16a-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enter() +``` + +### Assertions +```pseudo +# Message was sent immediately +ASSERT captured_presence.length == 1 + +CLOSE_CLIENT(client) +``` + +--- + +## RTP16b - Presence message queued when channel is ATTACHING + +**Test ID**: `realtime/unit/RTP16b/presence-queued-when-attaching-0` + +**Spec requirement:** If the channel is ATTACHING or INITIALIZED and queueMessages is +true, presence messages are queued at channel level, sent once channel becomes ATTACHED. + +### Setup +```pseudo +channel_name = "test-RTP16b-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Delay the ATTACHED response + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach but don't complete it +channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Queue presence while ATTACHING +enter_future = channel.presence.enter() + +# No messages sent yet +ASSERT captured_presence.length == 0 + +# Now complete the attach +mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + +AWAIT enter_future +``` + +### Assertions +```pseudo +# Queued presence message was sent after attach completed +ASSERT captured_presence.length == 1 +ASSERT captured_presence[0].presence[0].action == ENTER + +CLOSE_CLIENT(client) +``` + +--- + +## RTP16c - Presence message errors in other channel states + +**Test ID**: `realtime/unit/RTP16c/presence-errors-other-states-0` + +**Spec requirement:** In any other case (channel not ATTACHED, ATTACHING, or INITIALIZED +with queueMessages) the operation should result in an error. + +### Setup +```pseudo +channel_name = "test-RTP16c-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90001, message: "Detached") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Put channel in DETACHED state +AWAIT channel.attach() FAILS WITH attach_error +ASSERT channel.state == ChannelState.detached + +AWAIT channel.presence.enter() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null + +CLOSE_CLIENT(client) +``` + +--- + +## RTP15c - enterClient has no side effects on normal enter + +**Test ID**: `realtime/unit/RTP15c/enterclient-no-side-effects-0` + +**Spec requirement:** Using enterClient, updateClient, and leaveClient methods should +have no side effects on a client that has entered normally using enter. + +### Setup +```pseudo +channel_name = "test-RTP15c-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +# Wildcard clientId to allow both enter() and enterClient() on the same connection. +# See note in Purpose section about SDK-level wildcard validation. +# Note: clientId "*" may not be accepted by all SDKs at construction time. +# See top-level note for alternative auth patterns (e.g., key auth without clientId). +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Normal enter for the wildcard client +AWAIT channel.presence.enter(data: "main-client") + +# enterClient for a different user +AWAIT channel.presence.enterClient("other-user", data: "other-data") + +# leaveClient for the other user +AWAIT channel.presence.leaveClient("other-user") +``` + +### Assertions +```pseudo +# Three presence messages sent: enter, enterClient, leaveClient +ASSERT captured_presence.length == 3 + +# The main client's enter is unaffected by the enterClient/leaveClient calls +ASSERT captured_presence[0].presence[0].action == ENTER +ASSERT captured_presence[0].presence[0].data == "main-client" +ASSERT captured_presence[0].presence[0].clientId IS null # Uses connection clientId + +ASSERT captured_presence[1].presence[0].action == ENTER +ASSERT captured_presence[1].presence[0].clientId == "other-user" + +ASSERT captured_presence[2].presence[0].action == LEAVE +ASSERT captured_presence[2].presence[0].clientId == "other-user" + +CLOSE_CLIENT(client) +``` + +--- + +## RTP4 - 50 members via enterClient (same connection) + +**Test ID**: `realtime/unit/RTP4/bulk-enterclient-same-connection-0` + +**Spec requirement:** Ensure a test exists that enters 250 members using +RealtimePresence#enterClient on a single connection, and checks for PRESENT events +to be emitted for each member, and once sync is complete, all members should be +present in a RealtimePresence#get request. + +Note: The spec says 250 but we use 50 as a practical test size that validates the +same behavior (bulk enterClient, SYNC delivery, get correctness) without excessive +test runtime. + +This test variant uses a single connection that both enters members and subscribes +to presence. The server echoes ENTER events back on the same connection. + +### Setup +```pseudo +channel_name = "test-RTP4-same-${random_id()}" +member_count = 50 + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + + # Server echoes back the ENTER as a PRESENCE event + FOR idx, p IN enumerate(msg.presence): + mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage( + action: ENTER, + clientId: p.clientId, + connectionId: "conn-1", + id: "conn-1:${msg.msgSerial}:${idx}", + timestamp: NOW(), + data: p.data + ) + ] + )) + } +) +install_mock(mock_ws) + +# Note: clientId "*" may not be accepted by all SDKs at construction time. +# See top-level note for alternative auth patterns (e.g., key auth without clientId). +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Track ENTER events received by subscriber +received_enters = [] +channel.presence.subscribe(action: ENTER, (event) => { + received_enters.append(event) +}) + +# Enter 50 members +FOR i IN 0..member_count-1: + AWAIT channel.presence.enterClient("user-${i}", data: "data-${i}") + +# Send a complete SYNC with all 50 members as PRESENT +sync_members = [] +FOR i IN 0..member_count-1: + sync_members.append(PresenceMessage( + action: PRESENT, + clientId: "user-${i}", + connectionId: "conn-1", + id: "conn-1:${i}:0", + timestamp: NOW(), + data: "data-${i}" + )) + +mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: sync_members +)) + +# Get all members after sync +members = AWAIT channel.presence.get() +``` + +### Assertions +```pseudo +# All 50 members entered +ASSERT captured_presence.length == member_count + +# All 50 ENTER events received by subscriber +ASSERT received_enters.length == member_count + +# All 50 members present after sync +ASSERT members.length == member_count + +# Verify each member exists with correct data +FOR i IN 0..member_count-1: + member = members.find(m => m.clientId == "user-${i}") + ASSERT member IS NOT null + ASSERT member.data == "data-${i}" + +CLOSE_CLIENT(client) +``` + +--- + +## RTP4 - 50 members via enterClient (different connections) + +**Test ID**: `realtime/unit/RTP4/bulk-enterclient-diff-connections-1` + +**Spec requirement:** Same as above, but the original intent: one connection enters +members, a different connection observes the ENTER events and verifies all members +via get(). This is the more realistic scenario where one client populates presence +and another client discovers the members. + +### Setup +```pseudo +channel_name = "test-RTP4-diff-${random_id()}" +member_count = 50 + +# --- Connection A: the entering client --- +captured_presence_a = [] +mock_ws_a = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-A") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws_a.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + ELSE IF msg.action == PRESENCE: + captured_presence_a.append(msg) + mock_ws_a.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) + +# --- Connection B: the observing client --- +mock_ws_b = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-B") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws_b.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + } +) + +install_mock(mock_ws_a, client: "A") +install_mock(mock_ws_b, client: "B") + +# Note: clientId "*" may not be accepted by all SDKs at construction time. +# See top-level note for alternative auth patterns (e.g., key auth without clientId). +client_a = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +client_b = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel_a = client_a.channels.get(channel_name) +channel_b = client_b.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Connect and attach both clients +client_a.connect() +AWAIT_STATE client_a.connection.state == ConnectionState.connected +AWAIT channel_a.attach() + +client_b.connect() +AWAIT_STATE client_b.connection.state == ConnectionState.connected +AWAIT channel_b.attach() + +# Subscribe on client B to observe remote presence events +received_enters_b = [] +channel_b.presence.subscribe(action: ENTER, (event) => { + received_enters_b.append(event) +}) + +# Client A enters 50 members +FOR i IN 0..member_count-1: + AWAIT channel_a.presence.enterClient("user-${i}", data: "data-${i}") + +# Server delivers those ENTER events to client B as PRESENCE messages +# (In real Ably, the server broadcasts to all connections on the channel) +FOR i IN 0..member_count-1: + mock_ws_b.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage( + action: ENTER, + clientId: "user-${i}", + connectionId: "conn-A", + id: "conn-A:${i}:0", + timestamp: NOW(), + data: "data-${i}" + ) + ] + )) + +# Server sends a SYNC to client B with all 50 members +sync_members = [] +FOR i IN 0..member_count-1: + sync_members.append(PresenceMessage( + action: PRESENT, + clientId: "user-${i}", + connectionId: "conn-A", + id: "conn-A:${i}:0", + timestamp: NOW(), + data: "data-${i}" + )) + +mock_ws_b.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: sync_members +)) + +# Client B gets all members +members = AWAIT channel_b.presence.get() +``` + +### Assertions +```pseudo +# Client A sent all 50 presence messages +ASSERT captured_presence_a.length == member_count + +# Client B received all 50 ENTER events +ASSERT received_enters_b.length == member_count + +# All 50 members present via get() on client B +ASSERT members.length == member_count + +# Verify each member has correct data and connectionId from conn-A +FOR i IN 0..member_count-1: + member = members.find(m => m.clientId == "user-${i}") + ASSERT member IS NOT null + ASSERT member.data == "data-${i}" + ASSERT member.connectionId == "conn-A" + +CLOSE_CLIENT(client_a) +CLOSE_CLIENT(client_b) +``` diff --git a/uts/realtime/unit/presence/realtime_presence_get.md b/uts/realtime/unit/presence/realtime_presence_get.md new file mode 100644 index 000000000..6ecc72873 --- /dev/null +++ b/uts/realtime/unit/presence/realtime_presence_get.md @@ -0,0 +1,527 @@ +# RealtimePresence Get Tests + +Spec points: `RTP11`, `RTP11a`, `RTP11b`, `RTP11c`, `RTP11c1`, `RTP11c2`, `RTP11c3`, `RTP11d` + +## Test Type +Unit test — mock WebSocket required. + +## Purpose + +Tests the `RealtimePresence#get` function which returns the list of current members +on the channel from the local PresenceMap. By default it waits for the SYNC to complete +before returning. It supports filtering by clientId and connectionId, and has specific +error behaviour for SUSPENDED channels. + +--- + +## RTP11a - get returns current members (single-message sync) + +**Test ID**: `realtime/unit/RTP11a/get-returns-members-single-sync-0` + +**Spec requirement:** Returns the list of current members on the channel. By default, +will wait for the SYNC to be completed. + +This test uses a single-message sync: the ATTACHED has HAS_PRESENCE, but the SYNC +message is not sent immediately. The get() call must wait until the sync arrives +and completes. + +### Setup +```pseudo +channel_name = "test-RTP11a-single-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Send ATTACHED with HAS_PRESENCE but do NOT send SYNC yet + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Start get() — sync has not arrived yet, so this must wait +get_future = channel.presence.get() + +# Verify the get has not resolved yet (sync still pending) +ASSERT get_future IS NOT complete + +# Now send a single-message SYNC (channelSerial with empty cursor = complete) +mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100, data: "a"), + PresenceMessage(action: PRESENT, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100, data: "b") + ] +)) + +members = AWAIT get_future +``` + +### Assertions +```pseudo +ASSERT members.length == 2 +client_ids = members.map(m => m.clientId).sort() +ASSERT client_ids == ["alice", "bob"] + +CLOSE_CLIENT(client) +``` + +--- + +## RTP11a, RTP11c1 - get waits for multi-message sync + +**Test ID**: `realtime/unit/RTP11a/get-waits-for-multi-sync-1` + +**Spec requirement:** When waitForSync is true (default), the method will wait until +SYNC is complete before returning a list of members. A multi-message sync has a +non-empty cursor in the first message and an empty cursor in the final message. + +### Setup +```pseudo +channel_name = "test-RTP11c1-multi-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Send ATTACHED with HAS_PRESENCE but do NOT send SYNC yet + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Start get() — sync has not arrived yet +get_future = channel.presence.get() + +# Verify the get has not resolved yet +ASSERT get_future IS NOT complete + +# Send first SYNC message (non-empty cursor = more to come) +mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:cursor1", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100) + ] +)) + +# get() should still be waiting — sync not complete +ASSERT get_future IS NOT complete + +# Send final SYNC message (empty cursor = sync complete) +mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100) + ] +)) + +members = AWAIT get_future +``` + +### Assertions +```pseudo +# Both alice (from first SYNC message) and bob (from second) are present +ASSERT members.length == 2 +client_ids = members.map(m => m.clientId).sort() +ASSERT client_ids == ["alice", "bob"] + +CLOSE_CLIENT(client) +``` + +--- + +## RTP11c1 - get with waitForSync=false returns immediately + +**Test ID**: `realtime/unit/RTP11c1/get-no-wait-returns-immediately-0` + +**Spec requirement:** When waitForSync is false, the known set of presence members is +returned immediately, which may be incomplete if the SYNC is not finished. + +### Setup +```pseudo +channel_name = "test-RTP11c1-nowait-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + # Start SYNC but don't complete it (cursor is non-empty) + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:cursor1", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Sync is in progress but we don't wait +members = AWAIT channel.presence.get(waitForSync: false) +``` + +### Assertions +```pseudo +# Returns what's available so far (may be incomplete) +ASSERT members.length == 1 +ASSERT members[0].clientId == "alice" + +CLOSE_CLIENT(client) +``` + +--- + +## RTP11c2 - get filtered by clientId + +**Test ID**: `realtime/unit/RTP11c2/get-filtered-by-clientid-0` + +**Spec requirement:** clientId param filters members by the provided clientId. + +### Setup +```pseudo +channel_name = "test-RTP11c2-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100), + PresenceMessage(action: PRESENT, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100), + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c3", id: "c3:0:0", timestamp: 100) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +members = AWAIT channel.presence.get(clientId: "alice") +``` + +### Assertions +```pseudo +# Only alice entries returned (from two different connections) +ASSERT members.length == 2 +ASSERT members.every(m => m.clientId == "alice") + +CLOSE_CLIENT(client) +``` + +--- + +## RTP11c3 - get filtered by connectionId + +**Test ID**: `realtime/unit/RTP11c3/get-filtered-by-connectionid-0` + +**Spec requirement:** connectionId param filters members by the provided connectionId. + +### Setup +```pseudo +channel_name = "test-RTP11c3-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100), + PresenceMessage(action: PRESENT, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100), + PresenceMessage(action: PRESENT, clientId: "carol", connectionId: "c1", id: "c1:0:1", timestamp: 100) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +members = AWAIT channel.presence.get(connectionId: "c1") +``` + +### Assertions +```pseudo +# Only members from connection c1 (alice and carol) +ASSERT members.length == 2 +ASSERT members.every(m => m.connectionId == "c1") + +CLOSE_CLIENT(client) +``` + +--- + +## RTP11b - get implicitly attaches channel + +**Test ID**: `realtime/unit/RTP11b/get-implicitly-attaches-0` + +**Spec requirement:** Implicitly attaches the RealtimeChannel if the channel is in the +INITIALIZED state. If the channel enters DETACHED or FAILED before the operation +succeeds, error. + +### Setup +```pseudo +channel_name = "test-RTP11b-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT channel.state == ChannelState.initialized + +members = AWAIT channel.presence.get(waitForSync: false) +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT members IS NOT null + +CLOSE_CLIENT(client) +``` + +--- + +## RTP11d - get on SUSPENDED channel errors by default + +**Test ID**: `realtime/unit/RTP11d/get-suspended-errors-default-0` + +> **Reaching SUSPENDED state:** To transition a channel to SUSPENDED, the connection +> must first reach SUSPENDED state (by exhausting all reconnection attempts within +> `connectionStateTtl`). RTL3c then transitions ATTACHED channels to SUSPENDED. +> This requires: +> 1. The mock connectionDetails must include explicit `connectionStateTtl` (e.g., 5000ms) +> 2. ClientOptions should set `disconnectedRetryTimeout` to a small value (e.g., 500ms) +> 3. After disconnecting, refuse all reconnection attempts +> 4. Advance fake timers past `connectionStateTtl` to trigger SUSPENDED +> 5. Some SDKs perform a connectivity check (RTN17j) that may need an HTTP mock + +**Spec requirement:** If the RealtimeChannel is SUSPENDED, get will by default (or if +waitForSync is true) result in an error with code 91005. If waitForSync is false, +it returns the members currently stored in the PresenceMap. + +### Setup +```pseudo +channel_name = "test-RTP11d-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1", + connectionDetails: ConnectionDetails(connectionStateTtl: 5000)) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + # Deliver a member via SYNC + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Simulate channel becoming SUSPENDED (e.g., connection drops) +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE channel.state == ChannelState.suspended + +# Default get (waitForSync=true) should error +AWAIT channel.presence.get() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code == 91005 + +CLOSE_CLIENT(client) +``` + +--- + +## RTP11d - get on SUSPENDED channel with waitForSync=false returns members + +**Test ID**: `realtime/unit/RTP11d/get-suspended-no-wait-returns-1` + +**Spec requirement:** If waitForSync is false on a SUSPENDED channel, return the +members currently in the PresenceMap. + +### Setup +```pseudo +channel_name = "test-RTP11d-nowait-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1", + connectionDetails: ConnectionDetails(connectionStateTtl: 5000)) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Simulate channel becoming SUSPENDED +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE channel.state == ChannelState.suspended + +# waitForSync=false returns what's in the PresenceMap +members = AWAIT channel.presence.get(waitForSync: false) +``` + +### Assertions +```pseudo +ASSERT members.length == 1 +ASSERT members[0].clientId == "alice" + +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/presence/realtime_presence_history.md b/uts/realtime/unit/presence/realtime_presence_history.md new file mode 100644 index 000000000..61909e7cc --- /dev/null +++ b/uts/realtime/unit/presence/realtime_presence_history.md @@ -0,0 +1,137 @@ +# RealtimePresence History Tests + +Spec points: `RTP12`, `RTP12a`, `RTP12c`, `RTP12d` + +## Test Type +Unit test — mock WebSocket required (for channel setup), REST mock for history request. + +## Purpose + +Tests the `RealtimePresence#history` function which delegates to `RestPresence#history`. +It supports the same parameters as `RestPresence#history` and returns a `PaginatedResult`. + +--- + +## RTP12a - history supports same params as RestPresence#history + +**Test ID**: `realtime/unit/RTP12a/history-supports-rest-params-0` + +**Spec requirement:** Supports all the same params as RestPresence#history. + +### Setup +```pseudo +channel_name = "test-RTP12a-${random_id()}" + +captured_history_requests = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +# Mock the REST history endpoint +mock_rest = MockRest( + onRequest: (method, path, params) => { + captured_history_requests.append({ method: method, path: path, params: params }) + RETURN { + items: [], + statusCode: 200 + } + } +) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +result = AWAIT channel.presence.history( + start: 1000, + end: 2000, + direction: "backwards", + limit: 50 +) +``` + +### Assertions +```pseudo +ASSERT captured_history_requests.length == 1 +ASSERT captured_history_requests[0].path == "/channels/${encode_uri_component(channel_name)}/presence/history" +ASSERT captured_history_requests[0].params.start == 1000 +ASSERT captured_history_requests[0].params.end == 2000 +ASSERT captured_history_requests[0].params.direction == "backwards" +ASSERT captured_history_requests[0].params.limit == 50 + +CLOSE_CLIENT(client) +``` + +--- + +## RTP12c - history returns PaginatedResult + +**Test ID**: `realtime/unit/RTP12c/history-returns-paginated-result-0` + +**Spec requirement:** Returns a PaginatedResult page containing the first page of +messages in the PaginatedResult#items attribute. + +### Setup +```pseudo +channel_name = "test-RTP12c-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +# Note: The REST API returns presence actions as numeric values on the wire +# (ABSENT=0, PRESENT=1, ENTER=2, LEAVE=3, UPDATE=4). Mock responses should use +# the format appropriate for the SDK's REST layer. Assertions use symbolic names +# which correspond to the SDK's public API representation. +mock_rest = MockRest( + onRequest: (method, path, params) => { + RETURN { + items: [ + PresenceMessage(action: ENTER, clientId: "alice", timestamp: 1000), + PresenceMessage(action: UPDATE, clientId: "alice", timestamp: 2000), + PresenceMessage(action: LEAVE, clientId: "alice", timestamp: 3000) + ], + statusCode: 200 + } + } +) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +result = AWAIT channel.presence.history() +``` + +### Assertions +```pseudo +ASSERT result IS PaginatedResult +ASSERT result.items.length == 3 +ASSERT result.items[0].clientId == "alice" +ASSERT result.items[0].action == ENTER +ASSERT result.items[2].action == LEAVE + +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/presence/realtime_presence_reentry.md b/uts/realtime/unit/presence/realtime_presence_reentry.md new file mode 100644 index 000000000..dc5238baf --- /dev/null +++ b/uts/realtime/unit/presence/realtime_presence_reentry.md @@ -0,0 +1,559 @@ +# RealtimePresence Automatic Re-entry Tests + +Spec points: `RTP17a`, `RTP17e`, `RTP17g`, `RTP17g1`, `RTP17i` + +## Test Type +Unit test — mock WebSocket required. + +## Purpose + +Tests automatic re-entry of presence members when a channel reattaches. The +RealtimePresence object maintains an internal PresenceMap (RTP17) of locally-entered +members. When the channel receives an ATTACHED ProtocolMessage (except when already +attached with RESUMED flag), it re-publishes an ENTER for each member in the internal map. + +**Important:** The internal PresenceMap (LocalPresenceMap) is populated from server +PRESENCE echoes — messages with the current connection's connectionId — NOT directly +from the client's `enter()` or `enterClient()` calls. The server always echoes presence +events back to the originating client. Mock WebSocket setups must simulate this echo +for the LocalPresenceMap to contain any members for re-entry. + +--- + +## RTP17i - Automatic re-entry on ATTACHED (non-RESUMED) + +**Test ID**: `realtime/unit/RTP17i/auto-reentry-on-attached-0` + +**Spec requirement:** The RealtimePresence object should perform automatic re-entry +whenever the channel receives an ATTACHED ProtocolMessage, except in the case where +the channel is already attached and the ProtocolMessage has the RESUMED bit flag set. + +### Setup +```pseudo +channel_name = "test-RTP17i-${random_id()}" + +connection_count = 0 +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "conn-${connection_count}" + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + # Server echoes the presence event back to the client. + # This populates the LocalPresenceMap (RTP17) which is keyed by + # server echoes, not by the client's own enter() calls. + FOR idx, p IN enumerate(msg.presence): + mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + connectionId: "conn-${connection_count}", + presence: [ + PresenceMessage( + action: p.action, + clientId: p.clientId OR "my-client", + connectionId: "conn-${connection_count}", + id: "conn-${connection_count}:${msg.msgSerial}:${idx}", + timestamp: NOW(), + data: p.data + ) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Enter presence +AWAIT channel.presence.enter(data: "hello") + +ASSERT captured_presence.length == 1 + +# Simulate disconnect and reconnect (new connectionId) +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Clear captured to track only re-entry messages +captured_presence = [] + +# Reconnect — triggers reattach with new ATTACHED (non-RESUMED) +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +# RTP17i: Automatic re-entry sends ENTER for the member +ASSERT captured_presence.length >= 1 + +reenter = captured_presence.find(m => m.presence[0].action == ENTER) +ASSERT reenter IS NOT null + +CLOSE_CLIENT(client) +``` + +--- + +## RTP17g - Re-entry publishes ENTER with stored clientId and data + +**Test ID**: `realtime/unit/RTP17g/reentry-publishes-enter-with-data-0` + +**Spec requirement:** For each member of the RTP17 internal PresenceMap, publish a +PresenceMessage with an ENTER action using the clientId, data, and id attributes +from that member. + +### Setup +```pseudo +channel_name = "test-RTP17g-${random_id()}" + +connection_count = 0 +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "conn-${connection_count}" + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + # Server echoes the presence event back to populate LocalPresenceMap + FOR idx, p IN enumerate(msg.presence): + mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + connectionId: "conn-${connection_count}", + presence: [ + PresenceMessage( + action: p.action, + clientId: p.clientId, + connectionId: "conn-${connection_count}", + id: "conn-${connection_count}:${msg.msgSerial}:${idx}", + timestamp: NOW(), + data: p.data + ) + ] + )) + } +) +install_mock(mock_ws) + +# Use a non-wildcard clientId that has enterClient permission. +# Note: Some SDKs reject wildcard clientId "*" at the ClientOptions level. +# Use a concrete clientId and rely on server-side permission for enterClient. +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "admin", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Enter multiple members via enterClient +AWAIT channel.presence.enterClient("alice", data: "alice-data") +AWAIT channel.presence.enterClient("bob", data: "bob-data") + +ASSERT captured_presence.length == 2 + +# Simulate disconnect and reconnect +captured_presence = [] +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +# Both members re-entered with ENTER action and original data +reentry_messages = captured_presence.filter(m => m.action == PRESENCE) +presence_items = [] +FOR msg IN reentry_messages: + FOR p IN msg.presence: + presence_items.append(p) + +ASSERT presence_items.length >= 2 + +alice_reentry = presence_items.find(p => p.clientId == "alice") +bob_reentry = presence_items.find(p => p.clientId == "bob") + +ASSERT alice_reentry IS NOT null +ASSERT alice_reentry.action == ENTER +ASSERT alice_reentry.data == "alice-data" + +ASSERT bob_reentry IS NOT null +ASSERT bob_reentry.action == ENTER +ASSERT bob_reentry.data == "bob-data" + +CLOSE_CLIENT(client) +``` + +--- + +## RTP17g1 - Re-entry omits id when connectionId changed + +**Test ID**: `realtime/unit/RTP17g1/reentry-omits-id-new-connid-0` + +**Spec requirement:** If the current connection id is different from the connectionId +attribute of the stored member, the published PresenceMessage must not have its id set. + +### Setup +```pseudo +channel_name = "test-RTP17g1-${random_id()}" + +connection_count = 0 +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "conn-${connection_count}" + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + # Server echoes the presence event back to populate LocalPresenceMap + FOR idx, p IN enumerate(msg.presence): + mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + connectionId: "conn-${connection_count}", + presence: [ + PresenceMessage( + action: p.action, + clientId: p.clientId OR "my-client", + connectionId: "conn-${connection_count}", + id: "conn-${connection_count}:${msg.msgSerial}:${idx}", + timestamp: NOW(), + data: p.data + ) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enter(data: "hello") + +# First connection is conn-1 +ASSERT connection_count == 1 + +# Disconnect and reconnect — new connectionId (conn-2) +captured_presence = [] +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +AWAIT_STATE client.connection.state == ConnectionState.connected +ASSERT connection_count == 2 + +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +# Re-entry message should NOT have id set because connectionId changed +reentry = captured_presence.find(m => m.action == PRESENCE) +ASSERT reentry IS NOT null + +reentry_presence = reentry.presence[0] +ASSERT reentry_presence.action == ENTER +ASSERT reentry_presence.id IS null # RTP17g1: id not set when connectionId changed +ASSERT reentry_presence.data == "hello" + +CLOSE_CLIENT(client) +``` + +--- + +## RTP17i - No re-entry when ATTACHED with RESUMED flag + +**Test ID**: `realtime/unit/RTP17i/no-reentry-with-resumed-flag-1` + +**Spec requirement:** Automatic re-entry is NOT performed when the channel is already +attached and the ProtocolMessage has the RESUMED bit flag set. + +### Setup +```pseudo +channel_name = "test-RTP17i-resumed-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1", connectionKey: "key-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + # Server echoes the presence event back to populate LocalPresenceMap + FOR idx, p IN enumerate(msg.presence): + mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + connectionId: "conn-1", + presence: [ + PresenceMessage( + action: p.action, + clientId: p.clientId OR "my-client", + connectionId: "conn-1", + id: "conn-1:${msg.msgSerial}:${idx}", + timestamp: NOW(), + data: p.data + ) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enter(data: "hello") + +# Clear captured +captured_presence = [] + +# Server sends ATTACHED with RESUMED flag while already attached +# (e.g., after a brief transport-level reconnect that preserved the connection) +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: RESUMED +)) +``` + +### Assertions +```pseudo +# No re-entry — RESUMED flag means the server still has our presence state +ASSERT captured_presence.length == 0 + +CLOSE_CLIENT(client) +``` + +--- + +## RTP17e - Failed re-entry emits UPDATE with error + +**Test ID**: `realtime/unit/RTP17e/failed-reentry-emits-update-error-0` + +**Spec requirement:** If an automatic presence ENTER fails (e.g., NACK), emit an UPDATE +event on the channel with resumed=true and reason set to ErrorInfo with code 91004, +message indicating the failure and clientId, and cause set to the NACK error. + +### Setup +```pseudo +channel_name = "test-RTP17e-${random_id()}" + +connection_count = 0 +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "conn-${connection_count}" + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + IF connection_count == 1: + # First connection: ACK the enter and echo back the presence event + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + FOR idx, p IN enumerate(msg.presence): + mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + connectionId: "conn-1", + presence: [ + PresenceMessage( + action: p.action, + clientId: p.clientId OR "my-client", + connectionId: "conn-1", + id: "conn-1:${msg.msgSerial}:${idx}", + timestamp: NOW(), + data: p.data + ) + ] + )) + ELSE: + # Second connection: NACK the re-entry + mock_ws.send_to_client(ProtocolMessage( + action: NACK, + msgSerial: msg.msgSerial, + count: 1, + error: ErrorInfo(code: 40160, statusCode: 401, message: "Presence denied") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enter(data: "hello") + +# Listen for channel UPDATE events with the re-entry failure error code. +# Note: The ATTACHED state change itself may also emit an UPDATE event +# (e.g., when transitioning from ATTACHED to ATTACHED with resumed=false). +# Filter for the specific 91004 error code to distinguish re-entry failure. +channel_events = [] +channel.on(ChannelEvent.update, (change) => { + IF change.reason IS NOT null AND change.reason.code == 91004: + channel_events.append(change) +}) + +# Disconnect and reconnect — re-entry will be NACKed +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT_STATE channel.state == ChannelState.attached + +# Wait for the re-entry NACK to be processed +AWAIT UNTIL channel_events.length >= 1 +``` + +### Assertions +```pseudo +ASSERT channel_events.length >= 1 + +update_event = channel_events[0] +ASSERT update_event.resumed == true +ASSERT update_event.reason IS NOT null +ASSERT update_event.reason.code == 91004 +ASSERT update_event.reason.message CONTAINS "my-client" +ASSERT update_event.reason.cause IS NOT null +ASSERT update_event.reason.cause.code == 40160 + +CLOSE_CLIENT(client) +``` + +--- + +## RTP17a - Server publishes member regardless of subscribe capability + +**Test ID**: `realtime/unit/RTP17a/server-publishes-without-subscribe-0` + +**Spec requirement:** All members belonging to the current connection are published as a +PresenceMessage on the channel by the server irrespective of whether the client has +permission to subscribe. The member should be present in both the internal and public +presence set via get. + +### Setup +```pseudo +channel_name = "test-RTP17a-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Channel with presence capability but no subscribe capability + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: PRESENCE + )) + ELSE IF msg.action == PRESENCE: + # ACK the enter + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + # Server delivers the presence event back to the client + mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage( + action: ENTER, + clientId: "my-client", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000 + ) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enter() + +# Check public presence map +members = channel.presence.get(waitForSync: false) +``` + +### Assertions +```pseudo +ASSERT members.length == 1 +ASSERT members[0].clientId == "my-client" + +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/presence/realtime_presence_subscribe.md b/uts/realtime/unit/presence/realtime_presence_subscribe.md new file mode 100644 index 000000000..e4aa90071 --- /dev/null +++ b/uts/realtime/unit/presence/realtime_presence_subscribe.md @@ -0,0 +1,620 @@ +# RealtimePresence Subscribe/Unsubscribe Tests + +Spec points: `RTP6`, `RTP6a`, `RTP6b`, `RTP6d`, `RTP6e`, `RTP7`, `RTP7a`, `RTP7b`, `RTP7c` + +## Test Type +Unit test — mock WebSocket required. + +## Purpose + +Tests the `RealtimePresence#subscribe` and `RealtimePresence#unsubscribe` functions. +Subscribe registers listeners for incoming presence events (ENTER, LEAVE, UPDATE, PRESENT). +Unsubscribe removes previously registered listeners. Subscribe may implicitly attach the +channel depending on the `attachOnSubscribe` channel option. + +--- + +## RTP6a - Subscribe to all presence events + +**Test ID**: `realtime/unit/RTP6a/subscribe-all-presence-events-0` + +**Spec requirement:** Subscribe with a single listener argument subscribes a listener to +all presence messages. + +### Setup +```pseudo +channel_name = "test-RTP6a-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +received_events = [] +channel.presence.subscribe((event) => { + received_events.append(event) +}) + +AWAIT_STATE channel.state == ChannelState.attached + +# Server delivers ENTER, UPDATE, and LEAVE events +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 1000) + ] +)) + +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: UPDATE, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 2000, data: "updated") + ] +)) + +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: LEAVE, clientId: "alice", connectionId: "c1", id: "c1:2:0", timestamp: 3000) + ] +)) +``` + +### Assertions +```pseudo +ASSERT received_events.length == 3 +ASSERT received_events[0].action == ENTER +ASSERT received_events[0].clientId == "alice" +ASSERT received_events[1].action == UPDATE +ASSERT received_events[1].data == "updated" +ASSERT received_events[2].action == LEAVE + +CLOSE_CLIENT(client) +``` + +--- + +## RTP6b - Subscribe filtered by action + +**Test ID**: `realtime/unit/RTP6b/subscribe-filtered-by-action-0` + +**Spec requirement:** Subscribe with an action argument and a listener subscribes the +listener to receive only presence messages with that action. + +### Setup +```pseudo +channel_name = "test-RTP6b-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +enter_events = [] +leave_events = [] + +channel.presence.subscribe(action: ENTER, (event) => { + enter_events.append(event) +}) + +channel.presence.subscribe(action: LEAVE, (event) => { + leave_events.append(event) +}) + +# Server delivers all three action types +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 1000), + PresenceMessage(action: UPDATE, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 2000), + PresenceMessage(action: LEAVE, clientId: "alice", connectionId: "c1", id: "c1:2:0", timestamp: 3000) + ] +)) +``` + +### Assertions +```pseudo +# ENTER listener only gets ENTER events +ASSERT enter_events.length == 1 +ASSERT enter_events[0].action == ENTER + +# LEAVE listener only gets LEAVE events +ASSERT leave_events.length == 1 +ASSERT leave_events[0].action == LEAVE + +# Neither listener receives UPDATE + +CLOSE_CLIENT(client) +``` + +--- + +## RTP6b - Subscribe filtered by multiple actions + +**Test ID**: `realtime/unit/RTP6b/subscribe-filtered-multiple-actions-1` + +**Spec requirement:** The action argument may also be an array of actions. + +### Setup +```pseudo +channel_name = "test-RTP6b-multi-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +enter_leave_events = [] +channel.presence.subscribe(actions: [ENTER, LEAVE], (event) => { + enter_leave_events.append(event) +}) + +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 1000), + PresenceMessage(action: UPDATE, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 2000), + PresenceMessage(action: LEAVE, clientId: "alice", connectionId: "c1", id: "c1:2:0", timestamp: 3000) + ] +)) +``` + +### Assertions +```pseudo +# Only ENTER and LEAVE events received — UPDATE filtered out +ASSERT enter_leave_events.length == 2 +ASSERT enter_leave_events[0].action == ENTER +ASSERT enter_leave_events[1].action == LEAVE + +CLOSE_CLIENT(client) +``` + +--- + +## RTP6d - Subscribe implicitly attaches channel + +**Test ID**: `realtime/unit/RTP6d/subscribe-implicitly-attaches-0` + +**Spec requirement:** If the `attachOnSubscribe` channel option is true (default), +implicitly attach the RealtimeChannel if the channel is in the INITIALIZED, DETACHING, +or DETACHED states. + +### Setup +```pseudo +channel_name = "test-RTP6d-${random_id()}" + +attach_count = 0 +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT channel.state == ChannelState.initialized + +# Subscribe without explicitly attaching — should trigger implicit attach +channel.presence.subscribe((event) => {}) + +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +ASSERT attach_count == 1 +ASSERT channel.state == ChannelState.attached + +CLOSE_CLIENT(client) +``` + +--- + +## RTP6e - Subscribe with attachOnSubscribe=false does not attach + +**Test ID**: `realtime/unit/RTP6e/subscribe-no-attach-option-0` + +**Spec requirement:** If the `attachOnSubscribe` channel option is false, do not +implicitly attach. + +### Setup +```pseudo +channel_name = "test-RTP6e-${random_id()}" + +attach_count = 0 +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name, options: ChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT channel.state == ChannelState.initialized + +channel.presence.subscribe((event) => {}) +``` + +### Assertions +```pseudo +# Channel stays in INITIALIZED — no implicit attach +ASSERT channel.state == ChannelState.initialized +ASSERT attach_count == 0 + +CLOSE_CLIENT(client) +``` + +--- + +## RTP7c - Unsubscribe all listeners + +**Test ID**: `realtime/unit/RTP7c/unsubscribe-all-listeners-0` + +**Spec requirement:** Unsubscribe with no arguments unsubscribes all listeners. + +### Setup +```pseudo +channel_name = "test-RTP7c-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +events_a = [] +events_b = [] + +channel.presence.subscribe((event) => { events_a.append(event) }) +channel.presence.subscribe((event) => { events_b.append(event) }) + +# Deliver first event — both listeners receive it +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 1000) + ] +)) + +ASSERT events_a.length == 1 +ASSERT events_b.length == 1 + +# Unsubscribe all +channel.presence.unsubscribe() + +# Deliver second event — no listeners receive it +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 2000) + ] +)) +``` + +### Assertions +```pseudo +ASSERT events_a.length == 1 # No new events after unsubscribe +ASSERT events_b.length == 1 + +CLOSE_CLIENT(client) +``` + +--- + +## RTP7a - Unsubscribe specific listener + +**Test ID**: `realtime/unit/RTP7a/unsubscribe-specific-listener-0` + +**Spec requirement:** Unsubscribe with a single listener argument unsubscribes that +specific listener. + +### Setup +```pseudo +channel_name = "test-RTP7a-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +events_a = [] +events_b = [] + +listener_a = (event) => { events_a.append(event) } +listener_b = (event) => { events_b.append(event) } + +channel.presence.subscribe(listener_a) +channel.presence.subscribe(listener_b) + +# Unsubscribe only listener_a +channel.presence.unsubscribe(listener_a) + +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 1000) + ] +)) +``` + +### Assertions +```pseudo +ASSERT events_a.length == 0 # Unsubscribed — no events +ASSERT events_b.length == 1 # Still subscribed — receives event + +CLOSE_CLIENT(client) +``` + +--- + +## RTP7b - Unsubscribe listener for specific action + +**Test ID**: `realtime/unit/RTP7b/unsubscribe-for-specific-action-0` + +**Spec requirement:** Unsubscribe with an action argument and a listener unsubscribes +the listener for that action only. + +### Setup +```pseudo +channel_name = "test-RTP7b-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +received = [] +listener = (event) => { received.append(event) } + +# Subscribe to both ENTER and LEAVE +channel.presence.subscribe(action: ENTER, listener) +channel.presence.subscribe(action: LEAVE, listener) + +# Unsubscribe only for ENTER +channel.presence.unsubscribe(action: ENTER, listener) + +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 1000), + PresenceMessage(action: LEAVE, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 2000) + ] +)) +``` + +### Assertions +```pseudo +# Only LEAVE received — ENTER subscription was removed +ASSERT received.length == 1 +ASSERT received[0].action == LEAVE + +CLOSE_CLIENT(client) +``` + +--- + +## RTP6 - Presence events update the PresenceMap + +**Test ID**: `realtime/unit/RTP6/presence-events-update-map-0` + +**Spec requirement:** Incoming presence messages are applied to the PresenceMap (RTP2) +before being emitted to subscribers. + +### Setup +```pseudo +channel_name = "test-RTP6-map-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +channel.presence.subscribe((event) => {}) + +# Server delivers ENTER +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 1000, data: "hello") + ] +)) + +members = channel.presence.get(waitForSync: false) +``` + +### Assertions +```pseudo +ASSERT members.length == 1 +ASSERT members[0].clientId == "alice" +ASSERT members[0].data == "hello" +ASSERT members[0].action == PRESENT # Stored as PRESENT per RTP2d2 + +CLOSE_CLIENT(client) +``` + +--- + +## RTP6 - Multiple presence messages in single ProtocolMessage + +**Test ID**: `realtime/unit/RTP6/multiple-presence-in-single-message-1` + +**Spec requirement:** A PRESENCE ProtocolMessage may contain multiple PresenceMessages. + +### Setup +```pseudo +channel_name = "test-RTP6-batch-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +received = [] +channel.presence.subscribe((event) => { received.append(event) }) + +# Server delivers multiple presence events in one ProtocolMessage +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 1000), + PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 1000), + PresenceMessage(action: ENTER, clientId: "carol", connectionId: "c3", id: "c3:0:0", timestamp: 1000) + ] +)) +``` + +### Assertions +```pseudo +ASSERT received.length == 3 +ASSERT received[0].clientId == "alice" +ASSERT received[1].clientId == "bob" +ASSERT received[2].clientId == "carol" + +CLOSE_CLIENT(client) +``` diff --git a/uts/rest/integration/auth.md b/uts/rest/integration/auth.md new file mode 100644 index 000000000..20b93dde9 --- /dev/null +++ b/uts/rest/integration/auth.md @@ -0,0 +1,363 @@ +# Auth Integration Tests + +Spec points: `RSA4`, `RSA8` + +## Test Type +Integration test against Ably sandbox + +## Token Formats + +All tests in this file should be run with **both**: +1. **JWTs** (primary) - Generate using a third-party JWT library +2. **Ably native tokens** - Obtained using `requestToken()` + +JWT should be the primary token format. See README for details. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RSA4 - Basic auth with API key + +**Test ID**: `rest/integration/RSA4/basic-auth-key-0` + +**Spec requirement:** RSA4 - Client can authenticate using an API key via HTTP Basic Auth. + +Tests that API key authentication works against real server. + +### Setup +```pseudo +channel_name = "test-RSA4-" + random_id() +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) +``` + +### Test Steps +```pseudo +# Use channel status endpoint (requires authentication) +result = AWAIT client.request("GET", "/channels/" + channel_name) +``` + +### Assertions +```pseudo +# Just verify the request succeeded - don't check response body +ASSERT result.statusCode >= 200 AND result.statusCode < 300 +``` + +--- + +## RSA8 - Token auth with JWT + +**Test ID**: `rest/integration/RSA8/token-auth-jwt-0` + +**Spec requirement:** RSA8 - Client can authenticate using a JWT token. + +Tests authentication using a JWT token. + +### Setup +```pseudo +# Generate a valid JWT using a third-party library +jwt = generate_jwt( + key_name: extract_key_name(api_key), + key_secret: extract_key_secret(api_key), + ttl: 3600000 +) + +channel_name = "test-RSA8-jwt-" + random_id() +client = Rest(options: ClientOptions( + token: jwt, + endpoint: "nonprod:sandbox" +)) +``` + +### Test Steps +```pseudo +result = AWAIT client.request("GET", "/channels/" + channel_name) +``` + +### Assertions +```pseudo +ASSERT result.statusCode >= 200 AND result.statusCode < 300 +``` + +--- + +## RSA8 - Token auth with native token + +**Test ID**: `rest/integration/RSA8/token-auth-native-1` + +**Spec requirement:** RSA8 - Client can authenticate using an Ably native token obtained via `requestToken()`. + +Tests obtaining a native token and using it for authentication. + +### Setup +```pseudo +# First client with API key to obtain token +key_client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) +``` + +### Test Steps +```pseudo +# Obtain a native token +token_details = AWAIT key_client.auth.requestToken() + +# Create new client using only the token +channel_name = "test-RSA8-native-" + random_id() +token_client = Rest(options: ClientOptions( + token: token_details.token, + endpoint: "nonprod:sandbox" +)) + +# Verify token works +result = AWAIT token_client.request("GET", "/channels/" + channel_name) +``` + +### Assertions +```pseudo +ASSERT token_details.token IS String +ASSERT token_details.token.length > 0 +ASSERT token_details.expires > now() +ASSERT result.statusCode >= 200 AND result.statusCode < 300 +``` + +--- + +## RSA8 - authCallback with TokenRequest + +**Test ID**: `rest/integration/RSA8/auth-callback-token-request-2` + +**Spec requirement:** RSA8 - Client can use `authCallback` to obtain authentication via `TokenRequest`. + +Tests using an `authCallback` that returns a `TokenRequest`, which is then exchanged for a token. + +### Setup +```pseudo +# Client that generates token requests +token_request_client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) + +# authCallback that creates and returns a TokenRequest +auth_callback = FUNCTION(params): + RETURN AWAIT token_request_client.auth.createTokenRequest(params) + +channel_name = "test-RSA8-callback-" + random_id() +client = Rest(options: ClientOptions( + authCallback: auth_callback, + endpoint: "nonprod:sandbox" +)) +``` + +### Test Steps +```pseudo +result = AWAIT client.request("GET", "/channels/" + channel_name) +``` + +### Assertions +```pseudo +ASSERT result.statusCode >= 200 AND result.statusCode < 300 +``` + +--- + +## RSA8 - authCallback with JWT + +**Test ID**: `rest/integration/RSA8/auth-callback-jwt-3` + +**Spec requirement:** RSA8 - Client can use `authCallback` to obtain JWT tokens dynamically. + +Tests using an `authCallback` that returns a JWT. + +### Setup +```pseudo +auth_callback = FUNCTION(params): + RETURN generate_jwt( + key_name: extract_key_name(api_key), + key_secret: extract_key_secret(api_key), + client_id: params.clientId, + ttl: params.ttl OR 3600000 + ) + +channel_name = "test-RSA8-jwt-callback-" + random_id() +client = Rest(options: ClientOptions( + authCallback: auth_callback, + endpoint: "nonprod:sandbox" +)) +``` + +### Test Steps +```pseudo +result = AWAIT client.request("GET", "/channels/" + channel_name) +``` + +### Assertions +```pseudo +ASSERT result.statusCode >= 200 AND result.statusCode < 300 +``` + +--- + +## RSA4 - Invalid credentials rejected + +**Test ID**: `rest/integration/RSA4/invalid-credentials-rejected-1` + +**Spec requirement:** RSA4 - Server rejects requests with invalid API key credentials. + +Tests that invalid API keys are rejected by the server. + +### Setup +```pseudo +channel_name = "test-RSA4-invalid-" + random_id() + +# Use the real app_id with a fabricated key name. The server returns HTTP 401 +# with Ably error code 40400 (key not found). +invalid_key = app_id + ".invalidKey:invalidSecret" + +client = Rest(options: ClientOptions( + key: invalid_key, + endpoint: "nonprod:sandbox" +)) +``` + +### Test Steps +```pseudo +result = AWAIT client.request("GET", "/channels/" + channel_name) +ASSERT result.statusCode == 401 +ASSERT result.errorCode == 40400 +``` + +--- + +## RSC10 - Token renewal with expired JWT + +**Test ID**: `rest/integration/RSC10/token-renewal-expired-jwt-0` + +**Spec requirement:** RSC10 - When a REST request fails with a token error (40140-40149), the client should automatically renew the token and retry the request. + +Tests that an expired JWT triggers automatic token renewal via authCallback. + +### Setup +```pseudo +# Track how many times the callback is invoked +callback_count = 0 + +auth_callback = FUNCTION(params): + callback_count = callback_count + 1 + IF callback_count == 1: + # First call: return an already-expired JWT (expired 5 seconds ago) + RETURN generate_jwt( + key_name: extract_key_name(api_key), + key_secret: extract_key_secret(api_key), + expires_at: now() - 5_seconds + ) + ELSE: + # Subsequent calls: return a valid JWT + RETURN generate_jwt( + key_name: extract_key_name(api_key), + key_secret: extract_key_secret(api_key), + ttl: 3600000 + ) + +channel_name = "test-RSC10-renewal-" + random_id() +client = Rest(options: ClientOptions( + authCallback: auth_callback, + endpoint: "nonprod:sandbox" +)) +``` + +### Test Steps +```pseudo +# Make a REST request — first token is expired, should trigger renewal +result = AWAIT client.request("GET", "/channels/" + channel_name) +``` + +### Assertions +```pseudo +# The request succeeded (token was renewed and retried) +ASSERT result.statusCode >= 200 AND result.statusCode < 300 + +# The authCallback was called twice: once for expired token, once for renewal +ASSERT callback_count == 2 +``` + +--- + +## RSA8 - Capability restriction + +**Test ID**: `rest/integration/RSA8/capability-restriction-4` + +**Spec requirement:** RSA8 - Tokens with restricted capabilities should only allow the permitted operations. + +Tests that a JWT with restricted capability is enforced by the server. + +### Setup +```pseudo +# Create a JWT with capability restricted to a specific channel +allowed_channel = "test-RSA8-cap-allowed-" + random_id() +denied_channel = "test-RSA8-cap-denied-" + random_id() + +jwt = generate_jwt( + key_name: extract_key_name(api_key), + key_secret: extract_key_secret(api_key), + capability: '{"' + allowed_channel + '":["publish","subscribe"]}', + ttl: 3600000 +) + +client = Rest(options: ClientOptions( + token: jwt, + endpoint: "nonprod:sandbox" +)) +``` + +### Test Steps +```pseudo +# Publish to allowed channel should succeed — the JWT grants "publish" capability. +# Note: Do NOT use client.request("GET", "/channels/...") here — that is a channel +# status request which requires "channel-metadata" capability, not "publish". +AWAIT client.channels.get(allowed_channel).publish(name: "test", data: "hello") + +# Publish to denied channel should fail with 40160 (capability refused) +AWAIT client.channels.get(denied_channel).publish(name: "test", data: "hello") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40160 +ASSERT error.statusCode == 401 +``` + +--- + +## Notes + +### Tests moved to unit tests + +The following functionality is better tested via unit tests with a mocked HTTP client: + +- **`createTokenRequest()`** (RSA9) - This is a local signing operation that doesn't require server interaction +- **`authorize()` token renewal** (RSA14) - Unit tests can explicitly confirm that a new token is used on subsequent requests +- **Token expiry and renewal cycle** (RSA4b4) - See `unit/auth/token_renewal.md` diff --git a/uts/rest/integration/batch_presence.md b/uts/rest/integration/batch_presence.md new file mode 100644 index 000000000..85d1d6ba9 --- /dev/null +++ b/uts/rest/integration/batch_presence.md @@ -0,0 +1,304 @@ +# Batch Presence Integration Tests + +Spec points: `RSC24`, `BGR2`, `BGF2` + +## Test Type +Integration test against Ably sandbox + +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + +## Purpose + +End-to-end verification of `RestClient#batchPresence` against the Ably sandbox. +Client A enters presence members via Realtime, then the REST client calls +`batchPresence` and verifies the response structure and content. + +These tests complement the unit tests (which use mock HTTP) by verifying that the +real server returns correct batch presence responses, including per-channel success +and failure results. + +## Server Response Format + +With `X-Ably-Version >= 3` (sent by all current SDKs), the Ably server returns a +`BatchResult` envelope for all batch presence responses: + +```json +{ + "successCount": 2, + "failureCount": 0, + "results": [ + {"channel": "ch1", "presence": [...]}, + {"channel": "ch2", "presence": [...]} + ] +} +``` + +Both all-success and mixed success/failure responses return HTTP 200 with this +format. The `successCount`, `failureCount`, and `results` fields are provided by +the server — no client-side computation is needed. + +**Legacy format (no version header):** Without `X-Ably-Version`, the server +returns a plain array for all-success (HTTP 200) and `{error, batchResponse}` +for mixed results (HTTP 400). This format is not used by current SDKs. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. + +### App Provisioning + +Uses `ably-common/test-resources/test-app-setup.json` which provides: +- `keys[0]` — full access (default capability `{"*":["*"]}`) +- `keys[2]` — per-channel capabilities including `"channel6":["*"]` + +The restricted key uses an **explicit channel name** (not a wildcard pattern). +Wildcard capability patterns (e.g. `"allowed-*"`) do not work reliably with the +batch presence endpoint. + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + full_access_key = app_config.keys[0].key_str + restricted_key = app_config.keys[2].key_str # has "channel6":["*"] + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {full_access_key} +``` + +--- + +## RSC24, BGR2 - batchPresence returns members across multiple channels + +**Test ID**: `rest/integration/RSC24/batch-presence-multiple-channels-0` + +**Spec requirement:** `batchPresence` sends a GET to `/presence` with a `channels` +query parameter and returns a `BatchResult` containing per-channel presence data. +Each successful result contains the channel name and an array of `PresenceMessage`. + +This test enters members on two channels via Realtime, then queries both channels +in a single `batchPresence` call via REST and verifies the returned members. + +### Setup +```pseudo +channel_a_name = "batch-presence-a-" + random_id() +channel_b_name = "batch-presence-b-" + random_id() + +realtime = Realtime(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) +``` + +### Test Steps +```pseudo +# Connect and enter members on two channels +realtime.connect() +AWAIT_STATE realtime.connection.state == ConnectionState.connected + +ch_a = realtime.channels.get(channel_a_name) +AWAIT ch_a.attach() +AWAIT ch_a.presence.enterClient("user-1", data: "data-a1") +AWAIT ch_a.presence.enterClient("user-2", data: "data-a2") + +ch_b = realtime.channels.get(channel_b_name) +AWAIT ch_b.attach() +AWAIT ch_b.presence.enterClient("user-3", data: "data-b1") + +# Query via REST batchPresence (keep realtime open so presence persists) +rest = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +result = AWAIT rest.batchPresence([channel_a_name, channel_b_name]) +``` + +### Assertions +```pseudo +ASSERT result.successCount == 2 +ASSERT result.failureCount == 0 +ASSERT result.results.length == 2 + +# Find results by channel name +result_a = result.results.find(r => r.channel == channel_a_name) +result_b = result.results.find(r => r.channel == channel_b_name) + +ASSERT result_a IS BatchPresenceSuccessResult +ASSERT result_a.presence.length == 2 +client_ids_a = [m.clientId FOR m IN result_a.presence] +ASSERT "user-1" IN client_ids_a +ASSERT "user-2" IN client_ids_a + +# Verify data round-trips correctly +member_1 = result_a.presence.find(m => m.clientId == "user-1") +ASSERT member_1.data == "data-a1" + +ASSERT result_b IS BatchPresenceSuccessResult +ASSERT result_b.presence.length == 1 +ASSERT result_b.presence[0].clientId == "user-3" +ASSERT result_b.presence[0].data == "data-b1" +``` + +### Cleanup +```pseudo +AWAIT realtime.close() +``` + +--- + +## RSC24, BGF2 - Restricted key returns per-channel failure for unauthorized channels + +**Test ID**: `rest/integration/RSC24/restricted-key-channel-failure-1` + +**Spec requirement:** When a key lacks capability for a channel, the per-channel +result is a `BatchPresenceFailureResult` containing an `ErrorInfo`. Channels the key +does have access to return success results in the same batch response. + +The server returns HTTP 200 with `{"successCount": N, "failureCount": M, "results": [...]}` +for all batch responses, including those with per-channel errors. + +### Setup +```pseudo +# Use the fixed channel name matching keys[2] capability from ably-common +allowed_channel = "channel6" +denied_channel = "denied-batch-" + random_id() + +# Enter members on both channels using the full-access key +realtime = Realtime(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +realtime.connect() +AWAIT_STATE realtime.connection.state == ConnectionState.connected + +ch_allowed = realtime.channels.get(allowed_channel) +AWAIT ch_allowed.attach() +AWAIT ch_allowed.presence.enterClient("member-1", data: "hello") + +ch_denied = realtime.channels.get(denied_channel) +AWAIT ch_denied.attach() +AWAIT ch_denied.presence.enterClient("member-2", data: "world") + +AWAIT realtime.close() +``` + +### Test Steps +```pseudo +# Query with restricted key (only has access to "batch-allowed" channel) +restricted_rest = Rest(options: ClientOptions( + key: restricted_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +result = AWAIT restricted_rest.batchPresence([allowed_channel, denied_channel]) +``` + +### Assertions +```pseudo +ASSERT result.successCount == 1 +ASSERT result.failureCount == 1 +ASSERT result.results.length == 2 + +# Find results by channel name +success = result.results.find(r => r.channel == allowed_channel) +failure = result.results.find(r => r.channel == denied_channel) + +# Allowed channel succeeds with presence data +ASSERT success IS BatchPresenceSuccessResult +ASSERT success.presence.length == 1 +ASSERT success.presence[0].clientId == "member-1" + +# Denied channel fails with capability error +ASSERT failure IS BatchPresenceFailureResult +ASSERT failure.error IS ErrorInfo +ASSERT failure.error.code == 40160 +ASSERT failure.error.statusCode == 401 +``` + +### Cleanup + +No cleanup needed — the Realtime client was already closed during setup, +and the REST client has no persistent connection to close. + +--- + +## RSC24 - batchPresence with empty channel returns empty presence array + +**Test ID**: `rest/integration/RSC24/empty-channel-presence-2` + +**Spec requirement:** A channel with no presence members returns a success result +with an empty `presence` array. + +### Setup +```pseudo +empty_channel = "batch-empty-" + random_id() +populated_channel = "batch-populated-" + random_id() + +# Enter a member on only the populated channel +realtime = Realtime(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +realtime.connect() +AWAIT_STATE realtime.connection.state == ConnectionState.connected + +ch = realtime.channels.get(populated_channel) +AWAIT ch.attach() +AWAIT ch.presence.enterClient("someone", data: "here") + +# NOTE: Keep realtime open during the REST query so the presence member +# persists on the server. Closing realtime before the query would cause +# the member to leave. +``` + +### Test Steps +```pseudo +rest = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) + +result = AWAIT rest.batchPresence([empty_channel, populated_channel]) +``` + +### Assertions +```pseudo +ASSERT result.successCount == 2 +ASSERT result.failureCount == 0 +ASSERT result.results.length == 2 + +empty_result = result.results.find(r => r.channel == empty_channel) +populated_result = result.results.find(r => r.channel == populated_channel) + +# Empty channel succeeds with no members +ASSERT empty_result IS BatchPresenceSuccessResult +ASSERT empty_result.presence.length == 0 + +# Populated channel succeeds with the member +ASSERT populated_result IS BatchPresenceSuccessResult +ASSERT populated_result.presence.length == 1 +ASSERT populated_result.presence[0].clientId == "someone" +``` + +### Cleanup +```pseudo +AWAIT realtime.close() +``` diff --git a/uts/rest/integration/history.md b/uts/rest/integration/history.md new file mode 100644 index 000000000..c516d9fd5 --- /dev/null +++ b/uts/rest/integration/history.md @@ -0,0 +1,293 @@ +# REST Channel History Integration Tests + +Spec points: `RSL2a`, `RSL2b`, `RSL2b1`, `RSL2b2`, `RSL2b3` + +## Test Type +Integration test against Ably sandbox + +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RSL2a - History returns published messages + +**Test ID**: `rest/integration/RSL2a/history-returns-messages-0` + +**Spec requirement:** RSL2a - `history` returns a `PaginatedResult` containing messages for the channel. + +Tests that published messages appear in channel history. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) +channel_name = "history-test-RSL2a-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish some messages +AWAIT channel.publish(name: "event1", data: "data1") +AWAIT channel.publish(name: "event2", data: "data2") +AWAIT channel.publish(name: "event3", data: { "key": "value" }) + +# Poll until messages appear in history +history = poll_until( + condition: FUNCTION() => + result = AWAIT channel.history() + RETURN result.items.length == 3, + interval: 500ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +ASSERT history.items.length == 3 + +# Default order is backwards (newest first) +ASSERT history.items[0].name == "event3" +ASSERT history.items[0].data == { "key": "value" } + +ASSERT history.items[1].name == "event2" +ASSERT history.items[1].data == "data2" + +ASSERT history.items[2].name == "event1" +ASSERT history.items[2].data == "data1" + +# All messages should have timestamps +ASSERT ALL msg IN history.items: msg.timestamp IS NOT null +``` + +--- + +## RSL2b1 - History direction forwards + +**Test ID**: `rest/integration/RSL2b1/history-direction-forwards-0` + +**Spec requirement:** RSL2b1 - `direction` param controls message ordering (forwards = oldest first). + +Tests that `direction: forwards` returns messages oldest-first. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) +channel_name = "history-direction-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish messages - ordering is determined by server timestamp +AWAIT channel.publish(name: "first", data: "1") +AWAIT channel.publish(name: "second", data: "2") +AWAIT channel.publish(name: "third", data: "3") + +# Poll until all messages appear +poll_until( + condition: FUNCTION() => + result = AWAIT channel.history() + RETURN result.items.length == 3, + interval: 500ms, + timeout: 10s +) + +history = AWAIT channel.history(direction: "forwards") +``` + +### Assertions +```pseudo +ASSERT history.items.length == 3 +ASSERT history.items[0].name == "first" +ASSERT history.items[1].name == "second" +ASSERT history.items[2].name == "third" +``` + +--- + +## RSL2b2 - History limit parameter + +**Test ID**: `rest/integration/RSL2b2/history-limit-parameter-0` + +**Spec requirement:** RSL2b2 - `limit` param restricts the number of messages returned. + +Tests that `limit` parameter restricts number of returned messages. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) +channel_name = "history-limit-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish multiple messages +FOR i IN 1..10: + AWAIT channel.publish(name: "event-" + str(i), data: str(i)) + +# Poll until all messages are persisted +poll_until( + condition: FUNCTION() => + result = AWAIT channel.history() + RETURN result.items.length == 10, + interval: 500ms, + timeout: 10s +) + +history = AWAIT channel.history(limit: 5) +``` + +### Assertions +```pseudo +ASSERT history.items.length == 5 + +# Should get the 5 most recent (backwards direction by default) +ASSERT history.items[0].name == "event-10" +ASSERT history.items[4].name == "event-6" +``` + +--- + +## RSL2b3 - History time range parameters + +**Test ID**: `rest/integration/RSL2b3/history-time-range-0` + +**Spec requirement:** RSL2b3 - `start` and `end` params filter messages by timestamp range. + +Tests that `start` and `end` parameters filter messages by time. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) +channel_name = "history-timerange-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish early messages +AWAIT channel.publish(name: "early1", data: "e1") +AWAIT channel.publish(name: "early2", data: "e2") + +# Small delay to help ensure server assigns distinct timestamps between batches +WAIT 2ms + +# Publish late messages +AWAIT channel.publish(name: "late1", data: "l1") +AWAIT channel.publish(name: "late2", data: "l2") + +# Poll until all messages appear and retrieve with server timestamps +all_messages = poll_until( + condition: FUNCTION() => + result = AWAIT channel.history() + RETURN result.items IF result.items.length == 4 ELSE null, + interval: 500ms, + timeout: 10s +) + +# Use server-assigned timestamps to define the time boundary. +# Client-side now() must not be used here — client and server clocks may +# differ, and publishes may complete within the same client-clock millisecond. +early_timestamps = all_messages.filter(m => m.name STARTS WITH "early").map(m => m.timestamp) +late_timestamps = all_messages.filter(m => m.name STARTS WITH "late").map(m => m.timestamp) + +max_early_ts = max(early_timestamps) +min_late_ts = min(late_timestamps) +time_boundary = floor((max_early_ts + min_late_ts) / 2) + +# Query only early messages (up to the boundary) +early_history = AWAIT channel.history( + start: max_early_ts - 1000, + end: time_boundary +) + +# Query only late messages (from the boundary onwards) +late_history = AWAIT channel.history( + start: time_boundary + 1, + end: min_late_ts + 1000 +) +``` + +### Assertions +```pseudo +ASSERT early_history.items.length >= 1 +ASSERT late_history.items.length >= 1 + +# Early messages should contain "early" names +ASSERT ANY msg IN early_history.items: msg.name STARTS WITH "early" + +# Late messages should contain "late" names +ASSERT ANY msg IN late_history.items: msg.name STARTS WITH "late" +``` + +--- + +## RSL2 - History on channel with no messages + +**Test ID**: `rest/integration/RSL2/history-empty-channel-0` + +**Spec requirement:** RSL2a - `history` returns empty `PaginatedResult` when channel has no messages. + +Tests that history on an empty channel returns empty result. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) +# Use a fresh channel with no messages +channel_name = "history-empty-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +history = AWAIT channel.history() +``` + +### Assertions +```pseudo +ASSERT history.items IS List +ASSERT history.items.length == 0 +ASSERT history.hasNext() == false +ASSERT history.isLast() == true +``` diff --git a/uts/rest/integration/mutable_messages.md b/uts/rest/integration/mutable_messages.md new file mode 100644 index 000000000..aee3eebe2 --- /dev/null +++ b/uts/rest/integration/mutable_messages.md @@ -0,0 +1,470 @@ +# REST Mutable Messages Integration Tests + +Spec points: `RSL1n`, `RSL11`, `RSL14`, `RSL15`, `RSAN1`, `RSAN2`, `RSAN3` + +## Test Type +Integration test against Ably sandbox + +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. + +### App Provisioning + +Uses `ably-common/test-resources/test-app-setup.json` which provides: +- `keys[0]` — full access (default capability `{"*":["*"]}`) + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + full_access_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {full_access_key} +``` + +### Notes +- All clients use `useBinaryProtocol: PROTOCOL == "msgpack"` (see Protocol Variants) +- All clients use `endpoint: "nonprod:sandbox"` +- All channel names use the `mutable:` namespace prefix — the test app setup configures the `mutable` namespace with `mutableMessages: true`, which is required for getMessage, updateMessage, deleteMessage, appendMessage, and annotations + +### Annotation HTTP Body Format + +The annotation publish and delete endpoints (`POST /channels/{channel}/messages/{serial}/annotations`) +expect the HTTP request body to be a **JSON array** containing a single annotation object: + +```json +[{"type": "com.ably.reactions", "name": "like", "action": 0}] +``` + +Sending a bare object (not wrapped in an array) returns HTTP 400 "invalid request body". + +The `action` field is **required** by the server and must be set by the SDK: +- `0` = `ANNOTATION_CREATE` (for publish) +- `1` = `ANNOTATION_DELETE` (for delete) + +The SDK's `annotations.publish()` and `annotations.delete()` methods must set the +`action` field and wrap the annotation in an array before sending. + +--- + +## RSL1n — publish returns serials from sandbox + +**Test ID**: `rest/integration/RSL1n/publish-returns-serials-0` + +**Spec requirement:** RSL1n — On success, returns a `PublishResult` containing message serials. + +Tests that publish returns real serials from the Ably sandbox. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) +channel_name = "mutable:test-RSL1n-serials-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps and Assertions +```pseudo +# Single message +result1 = AWAIT channel.publish(name: "event1", data: "data1") +ASSERT result1 IS PublishResult +ASSERT result1.serials IS List +ASSERT result1.serials.length == 1 +ASSERT result1.serials[0] IS String +ASSERT result1.serials[0].length > 0 + +# Multiple messages +result2 = AWAIT channel.publish(messages: [ + Message(name: "event2", data: "data2"), + Message(name: "event3", data: "data3"), + Message(name: "event4", data: "data4") +]) +ASSERT result2.serials.length == 3 +ASSERT ALL serial IN result2.serials: serial IS String AND serial.length > 0 + +# Serials should be unique +ASSERT result2.serials[0] != result2.serials[1] +ASSERT result2.serials[1] != result2.serials[2] +``` + +--- + +## RSL11 — getMessage retrieves published message + +**Test ID**: `rest/integration/RSL11/get-message-by-serial-0` + +**Spec requirement:** RSL11 — `getMessage()` retrieves a message by serial. + +Tests that a published message can be retrieved by its serial. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) +channel_name = "mutable:test-RSL11-getMessage-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish a message and get its serial +publish_result = AWAIT channel.publish(name: "test-event", data: "hello world") +serial = publish_result.serials[0] + +# Retrieve the message by serial +msg = AWAIT channel.getMessage(serial) +``` + +### Assertions +```pseudo +ASSERT msg IS Message +ASSERT msg.name == "test-event" +ASSERT msg.data == "hello world" +ASSERT msg.serial == serial +ASSERT msg.action == MessageAction.MESSAGE_CREATE +ASSERT msg.timestamp IS NOT null +``` + +--- + +## RSL15 — updateMessage updates a published message + +**Test ID**: `rest/integration/RSL15/update-message-0` + +**Spec requirement:** RSL15 — `updateMessage()` sends a PATCH that updates a message. + +Tests that a published message can be updated and the update is visible via `getMessage()`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) +channel_name = "mutable:test-RSL15-update-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish original message +publish_result = AWAIT channel.publish(name: "original", data: "original-data") +serial = publish_result.serials[0] + +# Update the message +update_result = AWAIT channel.updateMessage( + Message(serial: serial, name: "updated", data: "updated-data"), + operation: MessageOperation(description: "edited content") +) +``` + +### Assertions +```pseudo +# Update returns a version serial +ASSERT update_result IS UpdateDeleteResult +ASSERT update_result.versionSerial IS String +ASSERT update_result.versionSerial.length > 0 + +# Verify via getMessage — poll until the update is visible +updated_msg = poll_until( + condition: FUNCTION() => + msg = AWAIT channel.getMessage(serial) + RETURN msg.action == MessageAction.MESSAGE_UPDATE, + interval: 500ms, + timeout: 10s +) +ASSERT updated_msg.name == "updated" +ASSERT updated_msg.data == "updated-data" +ASSERT updated_msg.action == MessageAction.MESSAGE_UPDATE +ASSERT updated_msg.version.description == "edited content" +``` + +--- + +## RSL15 — deleteMessage deletes a published message + +**Test ID**: `rest/integration/RSL15/delete-message-1` + +**Spec requirement:** RSL15 — `deleteMessage()` sends a PATCH that marks a message as deleted. + +Tests that a published message can be deleted. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) +channel_name = "mutable:test-RSL15-delete-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish original message +publish_result = AWAIT channel.publish(name: "to-delete", data: "delete-me") +serial = publish_result.serials[0] + +# Delete the message +delete_result = AWAIT channel.deleteMessage( + Message(serial: serial) +) +``` + +### Assertions +```pseudo +ASSERT delete_result IS UpdateDeleteResult +ASSERT delete_result.versionSerial IS String +ASSERT delete_result.versionSerial.length > 0 + +# Verify via getMessage — poll until the delete is visible +deleted_msg = poll_until( + condition: FUNCTION() => + msg = AWAIT channel.getMessage(serial) + RETURN msg.action == MessageAction.MESSAGE_DELETE, + interval: 500ms, + timeout: 10s +) +ASSERT deleted_msg.action == MessageAction.MESSAGE_DELETE +``` + +--- + +## RSL14 — getMessageVersions returns version history + +**Test ID**: `rest/integration/RSL14/get-message-versions-0` + +**Spec requirement:** RSL14 — `getMessageVersions()` retrieves all versions of a message. + +Tests that version history contains the original and all updates. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) +channel_name = "mutable:test-RSL14-versions-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish original +publish_result = AWAIT channel.publish(name: "versioned", data: "v1") +serial = publish_result.serials[0] + +# Update twice +AWAIT channel.updateMessage( + Message(serial: serial, data: "v2"), + operation: MessageOperation(description: "first edit") +) +AWAIT channel.updateMessage( + Message(serial: serial, data: "v3"), + operation: MessageOperation(description: "second edit") +) + +# Poll version history until all versions appear +versions = poll_until( + condition: FUNCTION() => + result = AWAIT channel.getMessageVersions(serial) + RETURN result.items.length >= 3, + interval: 500ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +ASSERT versions IS PaginatedResult +ASSERT versions.items.length >= 3 # Original + 2 updates + +# All items should be Messages with the same serial +FOR item IN versions.items: + ASSERT item IS Message + ASSERT item.serial == serial +``` + +--- + +## RSL15 — appendMessage appends to a published message + +**Test ID**: `rest/integration/RSL15/append-message-2` + +**Spec requirement:** RSL15 — `appendMessage()` sends a PATCH with `MESSAGE_APPEND` action. + +Tests that a message can be appended to. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) +channel_name = "mutable:test-RSL15-append-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish original +publish_result = AWAIT channel.publish(name: "appendable", data: "original") +serial = publish_result.serials[0] + +# Append to the message +append_result = AWAIT channel.appendMessage( + Message(serial: serial, data: "appended-data"), + operation: MessageOperation(description: "appended content") +) +``` + +### Assertions +```pseudo +ASSERT append_result IS UpdateDeleteResult +ASSERT append_result.versionSerial IS String +ASSERT append_result.versionSerial.length > 0 +``` + +--- + +## RSAN1, RSAN2 — publish and delete annotations on a message + +**Test ID**: `rest/integration/RSAN1/annotation-lifecycle-0` + +| Spec | Requirement | +|------|-------------| +| RSAN1 | `RestAnnotations#publish` creates an annotation on a message | +| RSAN2 | `RestAnnotations#delete` deletes an annotation from a message | +| RSAN3 | `RestAnnotations#get` retrieves annotations for a message | + +Tests the full annotation lifecycle: create, verify, delete. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) +channel_name = "mutable:test-RSAN-lifecycle-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish a message to annotate +publish_result = AWAIT channel.publish(name: "annotatable", data: "content") +serial = publish_result.serials[0] + +# Create an annotation +AWAIT channel.annotations.publish(serial, Annotation( + type: "com.ably.reactions", + name: "like" +)) + +# Verify annotation exists — poll until it appears +annotations = poll_until( + condition: FUNCTION() => + result = AWAIT channel.annotations.get(serial) + RETURN result.items.length >= 1, + interval: 500ms, + timeout: 10s +) +ASSERT annotations.items.length >= 1 + +found = false +FOR ann IN annotations.items: + IF ann.type == "com.ably.reactions" AND ann.name == "like": + found = true + ASSERT ann.action == AnnotationAction.ANNOTATION_CREATE + ASSERT ann.messageSerial == serial +ASSERT found == true + +# Delete the annotation +AWAIT channel.annotations.delete(serial, Annotation( + type: "com.ably.reactions", + name: "like" +)) +``` + +--- + +## RSAN3 — get annotations returns PaginatedResult + +**Test ID**: `rest/integration/RSAN3/get-annotations-paginated-0` + +**Spec requirement:** RSAN3c — Returns a `PaginatedResult` containing decoded annotations. + +Tests that multiple annotations can be retrieved as a paginated result. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) +channel_name = "mutable:test-RSAN3-paginated-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish a message +publish_result = AWAIT channel.publish(name: "multi-annotated", data: "content") +serial = publish_result.serials[0] + +# Publish multiple annotations +AWAIT channel.annotations.publish(serial, Annotation( + type: "com.ably.reactions", + name: "like" +)) +AWAIT channel.annotations.publish(serial, Annotation( + type: "com.ably.reactions", + name: "heart" +)) + +# Retrieve annotations — poll until both appear +result = poll_until( + condition: FUNCTION() => + r = AWAIT channel.annotations.get(serial) + RETURN r.items.length >= 2, + interval: 500ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +ASSERT result IS PaginatedResult +ASSERT result.items.length >= 2 + +FOR ann IN result.items: + ASSERT ann IS Annotation + ASSERT ann.messageSerial == serial + ASSERT ann.type == "com.ably.reactions" + ASSERT ann.timestamp IS NOT null +``` diff --git a/uts/rest/integration/pagination.md b/uts/rest/integration/pagination.md new file mode 100644 index 000000000..112f10170 --- /dev/null +++ b/uts/rest/integration/pagination.md @@ -0,0 +1,290 @@ +# Pagination Integration Tests + +Spec points: `TG1`, `TG2`, `TG3`, `TG4`, `TG5` + +## Test Type +Integration test against Ably sandbox + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## TG1, TG2 - PaginatedResult items and navigation + +**Test ID**: `rest/integration/TG1/items-and-navigation-0` + +| Spec ID | Requirement | +|---------|-------------| +| TG1 | `items` property contains array of results for current page | +| TG2 | `hasNext()` and `isLast()` indicate availability of more pages | + +Tests that `PaginatedResult` contains items and provides navigation methods. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) +channel_name = "pagination-basic-" + random_id() +channel = client.channels.get(channel_name) + +# Publish enough messages to require pagination +FOR i IN 1..15: + AWAIT channel.publish(name: "event-" + str(i), data: str(i)) + +# Poll until all messages are persisted +poll_until( + condition: FUNCTION() => + result = AWAIT channel.history() + RETURN result.items.length == 15, + interval: 500ms, + timeout: 15s +) +``` + +### Test Steps +```pseudo +# Request with small limit to force pagination +page1 = AWAIT channel.history(limit: 5) +``` + +### Assertions +```pseudo +# TG1 - items contains array of results +ASSERT page1.items IS List +ASSERT page1.items.length == 5 + +# TG2 - hasNext/isLast indicate more pages +ASSERT page1.hasNext() == true +ASSERT page1.isLast() == false +``` + +--- + +## TG3 - next() retrieves subsequent page + +**Test ID**: `rest/integration/TG3/next-retrieves-page-0` + +**Spec requirement:** TG3 - `next()` returns a new `PaginatedResult` for the next page of results. + +Tests that `next()` retrieves the next page of results. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) +channel_name = "pagination-next-" + random_id() +channel = client.channels.get(channel_name) + +# Publish messages +FOR i IN 1..12: + AWAIT channel.publish(name: "event-" + str(i), data: str(i)) + +# Poll until all messages are persisted +poll_until( + condition: FUNCTION() => + result = AWAIT channel.history() + RETURN result.items.length == 12, + interval: 500ms, + timeout: 15s +) +``` + +### Test Steps +```pseudo +page1 = AWAIT channel.history(limit: 5) +page2 = AWAIT page1.next() +page3 = AWAIT page2.next() +``` + +### Assertions +```pseudo +ASSERT page1.items.length == 5 +ASSERT page2.items.length == 5 +ASSERT page3.items.length == 2 # Remaining messages + +# Verify no duplicate messages across pages +all_ids = [] +FOR page IN [page1, page2, page3]: + FOR item IN page.items: + ASSERT item.id NOT IN all_ids + all_ids.append(item.id) + +ASSERT all_ids.length == 12 +``` + +--- + +## TG4 - first() retrieves first page + +**Test ID**: `rest/integration/TG4/first-retrieves-page-0` + +**Spec requirement:** TG4 - `first()` returns a new `PaginatedResult` for the first page of results. + +Tests that `first()` returns to the first page of results. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) +channel_name = "pagination-first-" + random_id() +channel = client.channels.get(channel_name) + +FOR i IN 1..10: + AWAIT channel.publish(name: "event-" + str(i), data: str(i)) + +# Poll until all messages are persisted +poll_until( + condition: FUNCTION() => + result = AWAIT channel.history() + RETURN result.items.length == 10, + interval: 500ms, + timeout: 15s +) +``` + +### Test Steps +```pseudo +page1 = AWAIT channel.history(limit: 3) +page2 = AWAIT page1.next() +first_page = AWAIT page2.first() +``` + +### Assertions +```pseudo +# first_page should have same items as page1 +ASSERT first_page.items.length == page1.items.length + +FOR i IN 0..first_page.items.length: + ASSERT first_page.items[i].id == page1.items[i].id +``` + +--- + +## TG5 - Iterate through all pages + +**Test ID**: `rest/integration/TG5/iterate-all-pages-0` + +**Spec requirement:** TG5 - Pagination methods enable iteration through complete result set. + +Tests iteration through entire result set using pagination. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) +channel_name = "pagination-iterate-" + random_id() +channel = client.channels.get(channel_name) + +# Publish known set of messages +message_count = 25 +FOR i IN 1..message_count: + AWAIT channel.publish(name: "event-" + str(i), data: str(i)) + +# Poll until all messages are persisted +poll_until( + condition: FUNCTION() => + result = AWAIT channel.history() + RETURN result.items.length == message_count, + interval: 500ms, + timeout: 30s +) +``` + +### Test Steps +```pseudo +all_messages = [] +page = AWAIT channel.history(limit: 7) + +WHILE true: + all_messages.extend(page.items) + + IF NOT page.hasNext(): + BREAK + + page = AWAIT page.next() +``` + +### Assertions +```pseudo +ASSERT all_messages.length == message_count + +# Verify all messages retrieved +event_names = [msg.name FOR msg IN all_messages] +FOR i IN 1..message_count: + ASSERT "event-" + str(i) IN event_names +``` + +--- + +## TG - next() on last page returns null + +**Test ID**: `rest/integration/TG3/next-last-page-null-1` + +**Spec requirement:** TG3 - `next()` returns null when called on the last page. + +Tests behavior when calling `next()` on the last page. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) +channel_name = "pagination-lastnext-" + random_id() +channel = client.channels.get(channel_name) + +# Publish just a few messages +FOR i IN 1..3: + AWAIT channel.publish(name: "event-" + str(i), data: str(i)) + +# Poll until messages are persisted +poll_until( + condition: FUNCTION() => + result = AWAIT channel.history() + RETURN result.items.length == 3, + interval: 500ms, + timeout: 10s +) +``` + +### Test Steps +```pseudo +page = AWAIT channel.history(limit: 10) # Larger than message count +``` + +### Assertions +```pseudo +ASSERT page.items.length == 3 +ASSERT page.hasNext() == false +ASSERT page.isLast() == true + +# Calling next() should return null (or empty result) +next_page = AWAIT page.next() +ASSERT next_page IS null OR next_page.items.length == 0 +``` diff --git a/uts/rest/integration/presence.md b/uts/rest/integration/presence.md new file mode 100644 index 000000000..590430393 --- /dev/null +++ b/uts/rest/integration/presence.md @@ -0,0 +1,604 @@ +# REST Presence Integration Tests + +Spec points: `RSP1`, `RSP3`, `RSP3a`, `RSP4`, `RSP4b`, `RSP5` + +## Test Type +Integration test against Ably sandbox + +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. + +### App Provisioning + +Uses `ably-common/test-resources/test-app-setup.json` which provides: +- `keys[0]` — full access (default capability `{"*":["*"]}`) +- `keys[3]` — subscribe-only (`{"*":["subscribe"]}`) +- Pre-populated presence fixtures on `persisted:presence_fixtures` channel +- Cipher configuration for encrypted fixture data + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Presence Fixtures + +The `ably-common/test-resources/test-app-setup.json` includes pre-populated presence members on the channel `persisted:presence_fixtures`: + +| clientId | data | encoding | +|----------|------|----------| +| `client_bool` | `"true"` | none | +| `client_int` | `"24"` | none | +| `client_string` | `"This is a string clientData payload"` | none | +| `client_json` | `"{ \"test\": \"This is a JSONObject clientData payload\"}"` (string) | none | +| `client_decoded` | `{"example":{"json":"Object"}}` | `json` | +| `client_encoded` | (encrypted) | `json/utf-8/cipher+aes-128-cbc/base64` | + +**Cipher configuration** for `client_encoded` (from `test-app-setup.json` `cipher` section): +- Algorithm: `aes` +- Mode: `cbc` +- Key length: 128 +- Key (base64): `WUP6u0K7MXI5Zeo0VppPwg==` +- IV (base64): `HO4cYSP8LybPYBPZPHQOtg==` + +--- + +## RSP1 - RestPresence accessible via channel + +### RSP1_Integration - Access presence from channel + +**Test ID**: `rest/integration/RSP1/access-presence-from-channel-0` + +**Spec requirement:** RSP1 - `RestPresence` object is accessible via `channel.presence`. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) + +channel = client.channels.get("persisted:presence_fixtures") +presence = channel.presence + +ASSERT presence IS NOT null +ASSERT presence IS RestPresence +``` + +--- + +## RSP3 - RestPresence#get + +### RSP3_Integration_1 - Get presence members from fixture channel + +**Test ID**: `rest/integration/RSP3/get-presence-members-0` + +**Spec requirement:** RSP3 - `get()` returns a `PaginatedResult` containing current presence members. + +Retrieves the pre-populated presence members from the sandbox fixture channel. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) + +channel = client.channels.get("persisted:presence_fixtures") +result = AWAIT channel.presence.get() + +ASSERT result IS PaginatedResult +ASSERT result.items.length >= 5 # At least the non-encrypted fixtures + +# Verify expected clients are present +client_ids = [msg.clientId FOR msg IN result.items] +ASSERT "client_bool" IN client_ids +ASSERT "client_string" IN client_ids +ASSERT "client_json" IN client_ids +``` + +### RSP3_Integration_2 - Get returns PresenceMessage with correct fields + +**Test ID**: `rest/integration/RSP3/presence-message-fields-1` + +**Spec requirement:** RSP3 - Each item in the result is a `PresenceMessage` with action, clientId, data, and connectionId. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) + +channel = client.channels.get("persisted:presence_fixtures") +result = AWAIT channel.presence.get() + +# Find client_string member +member = FIND msg IN result.items WHERE msg.clientId == "client_string" + +ASSERT member IS NOT null +ASSERT member IS PresenceMessage +ASSERT member.action == PresenceAction.present +ASSERT member.clientId == "client_string" +ASSERT member.data == "This is a string clientData payload" +ASSERT member.connectionId IS NOT null +``` + +### RSP3a1_Integration - Get with limit parameter + +**Test ID**: `rest/integration/RSP3a1/get-with-limit-0` + +**Spec requirement:** RSP3a1 - `limit` param restricts the number of presence members returned. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) + +channel = client.channels.get("persisted:presence_fixtures") + +# Request with small limit +result = AWAIT channel.presence.get(limit: 2) + +ASSERT result.items.length <= 2 +# If more members exist, pagination should be available +IF result.hasNext(): + ASSERT result.items.length == 2 +``` + +### RSP3a2_Integration - Get with clientId filter + +**Test ID**: `rest/integration/RSP3a2/get-with-clientid-filter-0` + +**Spec requirement:** RSP3a2 - `clientId` param filters results to specified client. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) + +channel = client.channels.get("persisted:presence_fixtures") +result = AWAIT channel.presence.get(clientId: "client_json") + +ASSERT result.items.length == 1 +ASSERT result.items[0].clientId == "client_json" +# The fixture has no encoding field, so data is returned as a raw string +ASSERT result.items[0].data IS String +ASSERT result.items[0].data == "{ \"test\": \"This is a JSONObject clientData payload\"}" +``` + +### RSP3_Integration_Empty - Get on channel with no presence + +**Test ID**: `rest/integration/RSP3/get-empty-channel-2` + +**Spec requirement:** RSP3 - `get()` returns empty `PaginatedResult` when no members are present. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) + +# Use a unique channel name that has no presence members +channel_name = "presence-empty-" + random_id() +channel = client.channels.get(channel_name) + +result = AWAIT channel.presence.get() + +ASSERT result.items IS List +ASSERT result.items.length == 0 +ASSERT result.hasNext() == false +``` + +--- + +## RSP4 - RestPresence#history + +### RSP4_Integration_1 - History returns presence events + +**Test ID**: `rest/integration/RSP4/history-returns-events-0` + +**Spec requirement:** RSP4 - `history()` returns a `PaginatedResult` containing presence event history. + +This test creates presence history by entering and leaving a channel. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) + +channel_name = "presence-history-" + random_id() + +# Use realtime client to generate presence history +realtime = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + clientId: "test-client" +)) + +realtime_channel = realtime.channels.get(channel_name) +AWAIT realtime_channel.presence.enter(data: "entered") +AWAIT realtime_channel.presence.update(data: "updated") +AWAIT realtime_channel.presence.leave(data: "left") +AWAIT realtime.close() + +# Poll REST history until events appear +rest_channel = client.channels.get(channel_name) + +history = poll_until( + condition: FUNCTION() => + result = AWAIT rest_channel.presence.history() + RETURN result.items.length >= 3, + interval: 500ms, + timeout: 10s +) + +ASSERT history.items.length >= 3 + +# Check for expected actions (order depends on direction) +actions = [msg.action FOR msg IN history.items] +ASSERT PresenceAction.enter IN actions +ASSERT PresenceAction.update IN actions +ASSERT PresenceAction.leave IN actions +``` + +### RSP4b1_Integration - History with start/end time range + +**Test ID**: `rest/integration/RSP4b1/history-time-range-0` + +**Spec requirement:** RSP4b1 - `start` and `end` params filter history by timestamp range. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + clientId: "test-client" +)) + +channel_name = "presence-history-time-" + random_id() + +# Record time before any presence events +time_before = now_millis() + +# Generate presence events via realtime +realtime = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + clientId: "time-test-client" +)) + +realtime_channel = realtime.channels.get(channel_name) +AWAIT realtime_channel.presence.enter(data: "test") +AWAIT realtime_channel.presence.leave() +AWAIT realtime.close() + +time_after = now_millis() + +# Poll until events appear +rest_channel = client.channels.get(channel_name) +poll_until( + condition: FUNCTION() => + result = AWAIT rest_channel.presence.history() + RETURN result.items.length >= 2, + interval: 500ms, + timeout: 10s +) + +# Query with time range +history = AWAIT rest_channel.presence.history( + start: time_before, + end: time_after +) + +ASSERT history.items.length >= 2 +``` + +### RSP4b2_Integration - History direction forwards + +**Test ID**: `rest/integration/RSP4b2/history-direction-forwards-0` + +**Spec requirement:** RSP4b2 - `direction` param controls event ordering (forwards = oldest first). + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) + +channel_name = "presence-direction-" + random_id() + +# Generate ordered presence events +realtime = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + clientId: "direction-client" +)) + +realtime_channel = realtime.channels.get(channel_name) +AWAIT realtime_channel.presence.enter(data: "first") +AWAIT realtime_channel.presence.update(data: "second") +AWAIT realtime_channel.presence.update(data: "third") +AWAIT realtime.close() + +# Poll until events appear +rest_channel = client.channels.get(channel_name) +poll_until( + condition: FUNCTION() => + result = AWAIT rest_channel.presence.history() + RETURN result.items.length >= 3, + interval: 500ms, + timeout: 10s +) + +# Get history forwards (oldest first) +history_forwards = AWAIT rest_channel.presence.history(direction: "forwards") + +ASSERT history_forwards.items.length >= 3 +ASSERT history_forwards.items[0].data == "first" + +# Get history backwards (newest first) - default +history_backwards = AWAIT rest_channel.presence.history(direction: "backwards") + +ASSERT history_backwards.items[0].data == "third" +``` + +### RSP4b3_Integration - History with limit and pagination + +**Test ID**: `rest/integration/RSP4b3/history-limit-pagination-0` + +**Spec requirement:** RSP4b3 - `limit` param restricts history results and enables pagination. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) + +channel_name = "presence-limit-" + random_id() + +# Generate multiple presence events +realtime = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + clientId: "limit-client" +)) + +realtime_channel = realtime.channels.get(channel_name) +FOR i IN 1..5: + AWAIT realtime_channel.presence.update(data: "update-" + str(i)) +AWAIT realtime.close() + +# Poll until all events appear +rest_channel = client.channels.get(channel_name) +poll_until( + condition: FUNCTION() => + result = AWAIT rest_channel.presence.history() + RETURN result.items.length >= 5, + interval: 500ms, + timeout: 10s +) + +# Request with small limit +page1 = AWAIT rest_channel.presence.history(limit: 2) + +ASSERT page1.items.length == 2 +ASSERT page1.hasNext() == true + +# Get next page +page2 = AWAIT page1.next() + +ASSERT page2 IS NOT null +ASSERT page2.items.length >= 1 +``` + +--- + +## RSP5 - Presence message decoding + +### RSP5_Integration_1 - String data decoded correctly + +**Test ID**: `rest/integration/RSP5/decode-string-data-0` + +**Spec requirement:** RSP5 - Presence message `data` is decoded according to its encoding. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) + +channel = client.channels.get("persisted:presence_fixtures") +result = AWAIT channel.presence.get(clientId: "client_string") + +ASSERT result.items.length == 1 +ASSERT result.items[0].data IS String +ASSERT result.items[0].data == "This is a string clientData payload" +``` + +### RSP5_Integration_2 - JSON data decoded to object + +**Test ID**: `rest/integration/RSP5/decode-json-data-1` + +**Spec requirement:** RSP5 - JSON-encoded presence data is decoded to native objects. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) + +channel = client.channels.get("persisted:presence_fixtures") +result = AWAIT channel.presence.get(clientId: "client_decoded") + +ASSERT result.items.length == 1 +ASSERT result.items[0].data IS Object/Map +ASSERT result.items[0].data["example"]["json"] == "Object" +``` + +### RSP5_Integration_3 - Encrypted data decoded with cipher + +**Test ID**: `rest/integration/RSP5/decode-encrypted-data-2` + +**Spec requirement:** RSP5 - Encrypted presence data is automatically decrypted when cipher is configured. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) + +cipher_key = base64_decode("WUP6u0K7MXI5Zeo0VppPwg==") + +channel = client.channels.get("persisted:presence_fixtures", options: RestChannelOptions( + cipher: CipherParams( + key: cipher_key, + algorithm: "aes", + mode: "cbc", + keyLength: 128 + ) +)) + +result = AWAIT channel.presence.get(clientId: "client_encoded") + +# The encrypted fixture should be decrypted +ASSERT result.items.length == 1 +ASSERT result.items[0].data IS NOT null +# Actual decrypted value depends on fixture content +``` + +### RSP5_Integration_4 - History messages also decoded + +**Test ID**: `rest/integration/RSP5/decode-history-messages-3` + +**Spec requirement:** RSP5 - Presence history messages are decoded the same way as current presence. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) + +channel_name = "presence-decode-history-" + random_id() + +# Generate presence event with JSON data +realtime = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + clientId: "decode-client" +)) + +json_data = { "key": "value", "number": 123 } +realtime_channel = realtime.channels.get(channel_name) +AWAIT realtime_channel.presence.enter(data: json_data) +AWAIT realtime.close() + +# Poll and retrieve history +rest_channel = client.channels.get(channel_name) +history = poll_until( + condition: FUNCTION() => + result = AWAIT rest_channel.presence.history() + RETURN result.items.length >= 1, + interval: 500ms, + timeout: 10s +) + +ASSERT history.items[0].data IS Object/Map +ASSERT history.items[0].data["key"] == "value" +ASSERT history.items[0].data["number"] == 123 +``` + +--- + +## Pagination + +### RSP_Pagination_Integration - Full pagination through presence members + +**Test ID**: `rest/integration/RSP3/full-pagination-3` + +**Spec requirement:** RSP3 - Presence `get()` supports pagination through all members. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) + +# The fixture channel has multiple members +channel = client.channels.get("persisted:presence_fixtures") + +# Request with small limit to force pagination +page1 = AWAIT channel.presence.get(limit: 2) + +all_members = [] +all_members.extend(page1.items) + +current_page = page1 +WHILE current_page.hasNext(): + current_page = AWAIT current_page.next() + all_members.extend(current_page.items) + +# Should have retrieved all fixture members +ASSERT all_members.length >= 5 + +# Verify no duplicates +client_ids = [m.clientId FOR m IN all_members] +ASSERT len(set(client_ids)) == len(client_ids) +``` + +--- + +## Error Handling + +### RSP_Error_Integration_1 - Invalid credentials rejected + +**Test ID**: `rest/integration/RSP3/invalid-credentials-rejected-4` + +**Spec requirement:** RSP3 - Presence operations with invalid credentials return authentication errors. + +```pseudo +client = Rest(options: ClientOptions( + key: "invalid.key:secret", + endpoint: "nonprod:sandbox" +)) + +AWAIT client.channels.get("test").presence.get() FAILS WITH error +ASSERT error.statusCode == 401 +ASSERT error.code >= 40100 AND error.code < 40200 +``` + +### RSP_Error_Integration_2 - Insufficient permissions rejected + +**Test ID**: `rest/integration/RSP3/subscribe-capability-sufficient-5` + +**Spec requirement:** RSP3 - Presence operations succeed with appropriate capabilities. + +```pseudo +# Use key with limited capabilities (keys[3] has subscribe only) +restricted_key = app_config.keys[3].key_str + +client = Rest(options: ClientOptions( + key: restricted_key, + endpoint: "nonprod:sandbox" +)) + +# This should work - subscribe capability is sufficient for presence.get +result = AWAIT client.channels.get("persisted:presence_fixtures").presence.get() +ASSERT result IS NOT null +``` diff --git a/uts/rest/integration/proxy/rest_fallback.md b/uts/rest/integration/proxy/rest_fallback.md new file mode 100644 index 000000000..51ffc5261 --- /dev/null +++ b/uts/rest/integration/proxy/rest_fallback.md @@ -0,0 +1,556 @@ +# REST Fallback Proxy Integration Tests + +Spec points: `RSC15l`, `RSC15l2`, `RSC15l4`, `RSL1k4` + +## Test Type + +Proxy integration test against Ably Sandbox endpoint + +## Proxy Infrastructure + +See `uts/realtime/integration/helpers/proxy.md` for the full proxy infrastructure specification. + +## Corresponding Unit Tests + +- `uts/rest/unit/fallback.md` -- RSC15l/RSC15l4 (unit test verifies fallback logic with mocked HTTP) +- `uts/rest/unit/publish.md` -- RSL1k (unit test verifies idempotent publish logic with mocked HTTP) + +## Purpose + +These tests verify fallback host retry behaviour and HTTP error handling that +cannot be fully tested with mocked HTTP because the `shouldFallback` +classification and error surfacing vary by platform. By exercising the SDK's +real HTTP client through the proxy (or directly against an unreachable +endpoint), we confirm the actual retry and error-parsing behaviour end-to-end. + +## Sandbox Setup + +Tests run against the Ably Sandbox via a programmable proxy. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Common Cleanup + +```pseudo +AFTER EACH TEST: + IF session IS NOT null: + session.close() +``` + +### Token Auth Helper + +```pseudo +function token_auth_callback(api_key): + RETURN (params, cb) => { + # Create a temporary Rest client pointed directly at the sandbox (bypassing the proxy) + # and use it to obtain a TokenDetails object + inner_rest = Rest(options: ClientOptions( + key: api_key, + endpoint: SANDBOX_ENDPOINT + )) + inner_rest.auth.requestToken().then( + (token) => cb(null, token), + (err) => cb(err, null) + ) + } +``` + +Note: The sandbox endpoint is used directly (not through the proxy) so that token requests are never intercepted by proxy fault-injection rules. + +### Fallback Host Configuration + +These tests need fallback hosts enabled. `endpoint: "localhost"` would normally +disable automatic fallback host selection (REC2c2), but explicitly providing +`fallbackHosts: ["localhost"]` overrides this. Both the primary and fallback +requests route through the same proxy, with `times: 1` rules ensuring only the +first request is faulted. + +--- + +## RSC15l2 - Request timeout triggers fallback via proxy + +**Test ID**: `rest/proxy/RSC15l2/timeout-triggers-fallback-0` + +| Spec | Requirement | +|------|-------------| +| RSC15l | Errors that necessitate use of an alternative host | +| RSC15l2 | Request timeout triggers fallback | + +Tests that when an HTTP request times out after the connection is established, +the SDK retries on a fallback host. The proxy delays the first HTTP response +beyond the SDK's `httpRequestTimeout`, causing a timeout. The retry goes to +a fallback host (also routed through the proxy) and succeeds. + +### Setup + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "http_request", "pathContains": "/time" }, + "action": { + "type": "http_delay", + "delayMs": 20000 + }, + "times": 1, + "comment": "RSC15l2: Delay first /time request beyond httpRequestTimeout" + }] +) + +client = Rest(options: ClientOptions( + authCallback: token_auth_callback(api_key), + endpoint: "localhost", + fallbackHosts: ["localhost"], + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + httpRequestTimeout: 3000 +)) +``` + +### Test Steps + +```pseudo +result = AWAIT client.time() +``` + +### Assertions + +```pseudo +# The request should succeed (retried on fallback after timeout) +ASSERT result IS number + +# Proxy event log shows at least two HTTP requests to /time +log = session.get_log() +http_requests = log.filter(e => e.type == "http_request" AND e.path CONTAINS "/time") +ASSERT http_requests.length >= 2 +``` + +--- + +## RSC15l4 - CloudFront Server header triggers fallback via proxy + +**Test ID**: `rest/proxy/RSC15l4/cloudfront-header-fallback-0` + +| Spec | Requirement | +|------|-------------| +| RSC15l4 | A response with a `Server: CloudFront` header and HTTP status >= 400 should trigger fallback | + +Tests that when the proxy returns an HTTP 403 with a `Server: CloudFront` +header, the SDK treats it as a retryable server error and retries on a +fallback host. + +### Setup + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "http_request", "pathContains": "/time" }, + "action": { + "type": "http_respond", + "status": 403, + "body": { "error": { "message": "Forbidden", "code": 40300, "statusCode": 403 } }, + "headers": { "Server": "CloudFront" } + }, + "times": 1, + "comment": "RSC15l4: CloudFront 403 on first /time request" + }] +) + +client = Rest(options: ClientOptions( + authCallback: token_auth_callback(api_key), + endpoint: "localhost", + fallbackHosts: ["localhost"], + port: session.proxy_port, + tls: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +result = AWAIT client.time() +``` + +### Assertions + +```pseudo +# The request should succeed (retried on fallback after CloudFront error) +ASSERT result IS number + +# Proxy event log shows at least two HTTP requests to /time +log = session.get_log() +http_requests = log.filter(e => e.type == "http_request" AND e.path CONTAINS "/time") +ASSERT http_requests.length >= 2 + +# First response was the injected 403 with CloudFront header +http_responses = log.filter(e => e.type == "http_response") +ASSERT http_responses[0].status == 403 +``` + +--- + +## Unreachable endpoint surfaces correct error (no proxy) + +**Test ID**: `rest/proxy/RSC15l/unreachable-endpoint-error-0` + +Tests that when the SDK's HTTP client cannot connect to the target host at all +(ECONNREFUSED), the error is surfaced as a usable ErrorInfo-like object with +status/code information. This test does NOT use the proxy -- it points the SDK +at a port where nothing is listening. + +### Setup + +```pseudo +# No proxy session needed for this test. + +# Pick a port that is not listening (e.g. 19999). +non_listening_port = 19999 + +# Use token auth via authCallback so the SDK can authenticate without +# contacting the dead endpoint. The inner Rest client talks directly to the +# sandbox to obtain a token. +client = Rest(options: ClientOptions( + authCallback: token_auth_callback(api_key), + endpoint: "localhost", + port: non_listening_port, + tls: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +AWAIT client.time() FAILS WITH error +``` + +### Assertions + +```pseudo +# The error is an ErrorInfo-like object with a statusCode or code +# (the exact code/statusCode depends on the SDK's HTTP layer, but it must +# be present and non-null so callers can programmatically handle it) +ASSERT error IS NOT null +ASSERT error.statusCode IS NOT null OR error.code IS NOT null +``` + +--- + +## Connection drop mid-response retried on fallback (http_drop) + +**Test ID**: `rest/proxy/RSC15l/connection-drop-fallback-1` + +| Spec | Requirement | +|------|-------------| +| RSC15l | Errors that necessitate use of an alternative host | + +Tests that when the proxy drops the TCP connection mid-request (simulating +ECONNRESET), the SDK classifies this as a retryable error and retries on a +fallback host. The proxy drops the first `/time` request, then passes through +on the retry. + +### Setup + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "http_request", "pathContains": "/time" }, + "action": { + "type": "http_drop" + }, + "times": 1, + "comment": "Drop TCP connection on first /time request (ECONNRESET)" + }] +) + +client = Rest(options: ClientOptions( + authCallback: token_auth_callback(api_key), + endpoint: "localhost", + fallbackHosts: ["localhost"], + port: session.proxy_port, + tls: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +result = AWAIT client.time() +``` + +### Assertions + +```pseudo +# The request should succeed (retried on fallback after connection drop) +ASSERT result IS number + +# Proxy event log shows at least two HTTP requests to /time +log = session.get_log() +http_requests = log.filter(e => e.type == "http_request" AND e.path CONTAINS "/time") +ASSERT http_requests.length >= 2 +``` + +--- + +## HTTP 5xx with JSON error body -- error parsed correctly (http_respond 503) + +**Test ID**: `rest/proxy/RSC15l/http-5xx-json-error-parsed-0` + +Tests that when the proxy returns an HTTP 503 with a well-formed JSON error +body (containing an `error` object with `code`, `statusCode`, and `message`), +the SDK parses the ErrorInfo fields from the response body. No fallback hosts +are configured, so the error propagates directly to the caller. + +### Setup + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "http_request", "pathContains": "/time" }, + "action": { + "type": "http_respond", + "status": 503, + "body": { "error": { "code": 50300, "statusCode": 503, "message": "Service temporarily unavailable" } } + }, + "times": 1, + "comment": "Return 503 with JSON error body on first /time request" + }] +) + +# No fallbackHosts -- endpoint="localhost" disables fallback (REC2c2) +client = Rest(options: ClientOptions( + authCallback: token_auth_callback(api_key), + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +AWAIT client.time() FAILS WITH error +``` + +### Assertions + +```pseudo +# The SDK parsed the error fields from the JSON response body +ASSERT error.code == 50300 +ASSERT error.statusCode == 503 +ASSERT error.message CONTAINS "Service temporarily unavailable" +``` + +--- + +## HTTP 5xx without JSON error body -- error synthesized (http_respond 503) + +**Test ID**: `rest/proxy/RSC15l/http-5xx-no-json-synthesized-1` + +Tests that when the proxy returns an HTTP 503 with a JSON body that does NOT +contain an `error` field (e.g. `{}`), the SDK still produces a usable error +from the HTTP status code alone. This is the closest the proxy can get to a +non-parseable body while still returning valid JSON. + +### Setup + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "http_request", "pathContains": "/time" }, + "action": { + "type": "http_respond", + "status": 503, + "body": {} + }, + "times": 1, + "comment": "Return 503 with empty JSON body (no error field) on first /time request" + }] +) + +# No fallbackHosts -- endpoint="localhost" disables fallback (REC2c2) +client = Rest(options: ClientOptions( + authCallback: token_auth_callback(api_key), + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +AWAIT client.time() FAILS WITH error +``` + +### Assertions + +```pseudo +# The SDK synthesized an error from the HTTP status code +ASSERT error.statusCode == 503 +``` + +--- + +## HTTP 4xx with JSON error body -- not retried, error parsed (http_respond 403) + +**Test ID**: `rest/proxy/RSC15l/http-4xx-not-retried-0` + +Tests that when the proxy returns an HTTP 403 (a 4xx client error) with a +well-formed JSON error body, the SDK does NOT retry on fallback hosts -- even +when fallback hosts are configured -- and instead propagates the parsed error +directly to the caller. Only 5xx and certain special cases (RSC15l4 CloudFront) +should trigger fallback; 4xx errors indicate a client-side problem. + +### Setup + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "http_request", "pathContains": "/time" }, + "action": { + "type": "http_respond", + "status": 403, + "body": { "error": { "code": 40300, "statusCode": 403, "message": "Forbidden" } } + }, + "times": 1, + "comment": "Return 403 with JSON error body on first /time request" + }] +) + +# Fallback hosts ARE configured -- but 403 should NOT trigger fallback +client = Rest(options: ClientOptions( + authCallback: token_auth_callback(api_key), + endpoint: "localhost", + fallbackHosts: ["localhost"], + port: session.proxy_port, + tls: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +AWAIT client.time() FAILS WITH error +``` + +### Assertions + +```pseudo +# The SDK parsed the error fields from the JSON response body +ASSERT error.code == 40300 +ASSERT error.statusCode == 403 + +# Proxy event log shows exactly 1 HTTP request to /time (no fallback retry) +log = session.get_log() +http_requests = log.filter(e => e.type == "http_request" AND e.path CONTAINS "/time") +ASSERT http_requests.length == 1 +``` + +--- + +## RSL1k4 - Idempotent publish retry deduplication + +**Test ID**: `rest/proxy/RSL1k4/idempotent-retry-dedup-0` + +| Spec | Requirement | +|------|-------------| +| RSL1k4 | An explicit test for idempotency of publishes with library-generated ids shall exist that simulates an error response to a successful publish, expects an automatic retry by the library, and verifies that the batch is published only once | + +### Proxy Action + +This test uses the `http_replace_response` proxy action, which forwards the +request to the upstream server (so the publish actually succeeds), discards +the real response, and returns a fake 5xx error response to the client. This +causes the SDK to believe the publish failed and retry it, while the server +already persisted the message. The server then deduplicates the retry based +on the library-generated message `id`. + +#### Setup + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + + rules: [{ + "match": { "type": "http_request", "method": "POST", "pathContains": "/channels/" }, + "action": { + "type": "http_replace_response", + "status": 503, + "body": { "error": { "code": 50300, "statusCode": 503, "message": "Service temporarily unavailable" } } + }, + "times": 1, + "comment": "RSL1k4: Forward first publish to server, then return fake 503 to client" + }] +) + +client = Rest(options: ClientOptions( + authCallback: token_auth_callback(api_key), + endpoint: "localhost", + fallbackHosts: ["localhost"], + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + idempotentRestPublishing: true +)) + +channel_name = "test-RSL1k4-idempotent-" + random_string() +channel = client.channels.get(channel_name) +``` + +#### Test Steps + +```pseudo +# Publish a message -- first attempt succeeds server-side but client sees 503, +# SDK retries, server deduplicates the retry +AWAIT channel.publish("test", "data") +``` + +#### Assertions + +```pseudo +# The publish completed successfully (SDK retried after the fake 503) +# No error thrown + +# Verify via history that only one copy of the message exists +# (server deduplicated the retry based on the library-generated message id) +history = AWAIT channel.history() +matching = history.items.filter(m => m.name == "test" AND m.data == "data") +ASSERT matching.length == 1 + +# Proxy event log shows at least two POST requests to /channels/ +log = session.get_log() +http_requests = log.filter(e => e.type == "http_request" AND e.method == "POST" AND e.path CONTAINS "/channels/") +ASSERT http_requests.length >= 2 +``` diff --git a/uts/rest/integration/publish.md b/uts/rest/integration/publish.md new file mode 100644 index 000000000..612c305e2 --- /dev/null +++ b/uts/rest/integration/publish.md @@ -0,0 +1,239 @@ +# REST Channel Publish Integration Tests + +Spec points: `RSL1d`, `RSL1l1`, `RSL1m4`, `RSL1n` + +## Test Type +Integration test against Ably sandbox + +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. + +### App Provisioning + +Uses `ably-common/test-resources/test-app-setup.json` which provides: +- `keys[0]` — full access (default capability `{"*":["*"]}`) +- `keys[2]` — per-channel capabilities including `"channel2":["publish","subscribe"]` + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + full_access_key = app_config.keys[0].key_str + restricted_key = app_config.keys[2].key_str # per-channel capabilities + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {full_access_key} +``` + +--- + +## RSL1d - Error indication on publish failure + +**Test ID**: `rest/integration/RSL1d/publish-failure-error-0` + +**Spec requirement:** RSL1d - Failed publish operations must indicate the error to the caller. + +Tests that errors are properly indicated when a publish fails due to insufficient permissions. + +### Setup +```pseudo +channel_name = "forbidden-channel-" + random_id() # Not in restricted key's capability + +restricted_client = Rest(options: ClientOptions( + key: restricted_key, # Key without publish capability for this channel + endpoint: "nonprod:sandbox" +)) +restricted_channel = restricted_client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT restricted_channel.publish(name: "event", data: "data") FAILS WITH error +ASSERT error.code == 40160 # Not permitted +ASSERT error.statusCode == 401 +``` + +--- + +## RSL1n - PublishResult contains serials + +**Test ID**: `rest/integration/RSL1n/publish-result-serials-0` + +**Spec requirement:** RSL1n - Successful publish returns a `PublishResult` containing message serials. + +Tests that successful publish returns a result with message serials. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox" +)) +channel_name = "test-serials-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Single message +result1 = AWAIT channel.publish(name: "event1", data: "data1") + +ASSERT result1.serials IS List +ASSERT result1.serials.length == 1 +ASSERT result1.serials[0] IS String +ASSERT result1.serials[0].length > 0 + + +# Multiple messages +result2 = AWAIT channel.publish(messages: [ + Message(name: "event2", data: "data2"), + Message(name: "event3", data: "data3"), + Message(name: "event4", data: "data4") +]) + +ASSERT result2.serials.length == 3 +ASSERT ALL serial IN result2.serials: serial IS String AND serial.length > 0 +ASSERT result2.serials ARE all unique +``` + +--- + +## RSL1k5 - Idempotent publish with client-supplied IDs + +**Test ID**: `rest/integration/RSL1k5/idempotent-client-ids-0` + +**Spec requirement:** RSL1k5 - Messages with client-supplied IDs are idempotent (duplicate IDs don't create duplicate messages). + +Tests that multiple publishes with the same client-supplied ID result in single message. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox" +)) +channel_name = "idempotent-explicit-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +fixed_id = "client-supplied-id-" + random_id() + +# Publish same message ID multiple times +FOR i IN 1..3: + AWAIT channel.publish( + message: Message(id: fixed_id, name: "event", data: "data-" + str(i)) + ) + +# Poll history until message appears (avoid fixed wait) +history = poll_until( + condition: FUNCTION() => + result = AWAIT channel.history() + RETURN result.items.length > 0, + interval: 500ms, + timeout: 10s +) + +# Verify only one message in history +ASSERT history.items.length == 1 +ASSERT history.items[0].id == fixed_id +# The data should be from the first publish (subsequent ones are no-ops) +ASSERT history.items[0].data == "data-1" +``` + +--- + +## RSL1l1 - Publish params with _forceNack + +**Test ID**: `rest/integration/RSL1l1/publish-params-force-nack-0` + +**Spec requirement:** RSL1l1 - Additional publish params can be supplied and are transmitted to the server. + +Tests that publish params are correctly transmitted by using the `_forceNack` test param. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox" +)) +channel_name = "force-nack-test-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish( + message: Message(name: "event", data: "data"), + params: { "_forceNack": "true" } +) FAILS WITH error +ASSERT error.code == 40099 # Specific code for forced nack +``` + +--- + +## RSL1m4 - ClientId mismatch rejection + +**Test ID**: `rest/integration/RSL1m4/clientid-mismatch-rejected-0` + +**Spec requirement:** RSL1m4 - Server rejects messages where clientId doesn't match the authenticated client. + +Tests that server rejects message with clientId different from authenticated client. + +### Setup +```pseudo +# Create a token with a specific clientId +key_client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox" +)) + +token_details = AWAIT key_client.auth.requestToken( + tokenParams: TokenParams(clientId: "authenticated-client-id") +) + +# Client using token with clientId +token_client = Rest(options: ClientOptions( + token: token_details.token, + endpoint: "nonprod:sandbox" +)) + +channel_name = "clientid-mismatch-" + random_id() +channel = token_client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish( + message: Message( + name: "event", + data: "data", + clientId: "different-client-id" # Doesn't match authenticated clientId + ) +) FAILS WITH error +ASSERT error.code == 40012 # Incompatible clientId +ASSERT error.statusCode == 400 +``` + +--- + +## Notes + +### Tests moved to unit tests + +The following functionality is better tested via unit tests with a mocked HTTP client: + +- **RSL1k4 - Idempotent retry verification**: Testing that automatic retry after failure doesn't duplicate messages requires HTTP-level interception. This is better done with a mock that can fail the first request and allow the retry. See `unit/channel/idempotency.md`. diff --git a/uts/rest/integration/push_admin.md b/uts/rest/integration/push_admin.md new file mode 100644 index 000000000..c4e3db4ad --- /dev/null +++ b/uts/rest/integration/push_admin.md @@ -0,0 +1,716 @@ +# Push Admin Integration Tests + +Spec points: `RSH1`, `RSH1a`, `RSH1b1`, `RSH1b2`, `RSH1b3`, `RSH1b4`, `RSH1b5`, `RSH1c1`, `RSH1c2`, `RSH1c3`, `RSH1c4`, `RSH1c5` + +## Test Type +Integration test against Ably sandbox + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. + +### App Provisioning + +Uses `ably-common/test-resources/test-app-setup.json` which provides: +- `keys[0]` — full access (default capability `{"*":["*"]}`) +- `keys[1]` — includes `pushenabled:admin:*` with `push-admin` capability + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + full_access_key = app_config.keys[0].key_str + push_admin_key = app_config.keys[1].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {full_access_key} +``` + +### Notes +- All clients use `useBinaryProtocol: false` (SDK does not implement msgpack) +- All clients use `endpoint: "nonprod:sandbox"` +- Push admin operations require the `push-admin` capability — use `push_admin_key` or `full_access_key` +- Device registrations created during tests must be cleaned up to avoid polluting the sandbox + +--- + +## RSH1a — publish sends push notification to clientId + +**Test ID**: `rest/integration/RSH1a/push-publish-clientid-0` + +**Spec requirement:** RSH1a — `publish(recipient, data)` performs an HTTP request to `/push/publish`. + +Tests that a push notification can be published to a `clientId` recipient. The sandbox accepts the request even though no real device receives it. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) +``` + +### Test Steps and Assertions +```pseudo +# Publish with clientId recipient — should not throw +AWAIT client.push.admin.publish( + recipient: { "clientId": "test-client-push" }, + data: { + "notification": { + "title": "Integration Test", + "body": "Hello from push admin" + } + } +) +``` + +--- + +## RSH1a — publish rejects invalid recipient + +**Test ID**: `rest/integration/RSH1a/push-publish-invalid-recipient-1` + +**Spec requirement:** RSH1a — Tests should exist with invalid recipient details. + +Tests that the sandbox returns an error for an empty recipient. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.admin.publish( + recipient: {}, + data: { "notification": { "title": "Test" } } +) FAILS WITH error +ASSERT error.code IS NOT null +``` + +--- + +## RSH1b3, RSH1b1 — save and get device registration + +**Test ID**: `rest/integration/RSH1b3/save-and-get-device-0` + +| Spec | Requirement | +|------|-------------| +| RSH1b3 | `#save(device)` issues a PUT to register a device | +| RSH1b1 | `#get(deviceId)` retrieves a registered device | + +Tests the full device registration lifecycle: save, then retrieve. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) +device_id = "test-device-" + random_id() +``` + +### Test Steps +```pseudo +# Save a device registration +saved = AWAIT client.push.admin.deviceRegistrations.save(DeviceDetails( + id: device_id, + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": "test-token-" + random_id() } + ) +)) +``` + +### Assertions +```pseudo +ASSERT saved IS DeviceDetails +ASSERT saved.id == device_id +ASSERT saved.platform == "ios" +ASSERT saved.formFactor == "phone" +ASSERT saved.push.recipient["transportType"] == "apns" + +# Retrieve the same device +retrieved = AWAIT client.push.admin.deviceRegistrations.get(device_id) +ASSERT retrieved IS DeviceDetails +ASSERT retrieved.id == device_id +ASSERT retrieved.platform == "ios" +``` + +### Cleanup +```pseudo +AWAIT client.push.admin.deviceRegistrations.remove(device_id) +``` + +--- + +## RSH1b3 — save updates existing device registration + +**Test ID**: `rest/integration/RSH1b3/update-device-registration-1` + +**Spec requirement:** RSH1b3 — A test should exist for a successful subsequent save with an update. + +Tests that saving a device with the same ID updates the existing registration. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) +device_id = "test-device-update-" + random_id() +``` + +### Test Steps +```pseudo +# Initial save +AWAIT client.push.admin.deviceRegistrations.save(DeviceDetails( + id: device_id, + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": "token-v1" } + ) +)) + +# Update with new token +updated = AWAIT client.push.admin.deviceRegistrations.save(DeviceDetails( + id: device_id, + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": "token-v2" } + ) +)) +``` + +### Assertions +```pseudo +ASSERT updated.id == device_id +ASSERT updated.push.recipient["deviceToken"] == "token-v2" + +# Verify via get +retrieved = AWAIT client.push.admin.deviceRegistrations.get(device_id) +ASSERT retrieved.push.recipient["deviceToken"] == "token-v2" +``` + +### Cleanup +```pseudo +AWAIT client.push.admin.deviceRegistrations.remove(device_id) +``` + +--- + +## RSH1b1 — get returns error for unknown device + +**Test ID**: `rest/integration/RSH1b1/get-unknown-device-error-0` + +**Spec requirement:** RSH1b1 — Results in a not found error if the device cannot be found. + +Tests that retrieving a nonexistent device returns a not-found error. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.admin.deviceRegistrations.get("nonexistent-device-" + random_id()) FAILS WITH error +ASSERT error.statusCode == 404 +``` + +--- + +## RSH1b2 — list device registrations with filters + +**Test ID**: `rest/integration/RSH1b2/list-devices-filtered-0` + +**Spec requirement:** RSH1b2 — `#list(params)` returns a paginated result with `DeviceDetails` filtered by params. + +Tests listing device registrations filtered by `deviceId`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) +device_id = "test-device-list-" + random_id() + +# Register a device first +AWAIT client.push.admin.deviceRegistrations.save(DeviceDetails( + id: device_id, + platform: "android", + formFactor: "tablet", + push: DevicePushDetails( + recipient: { "transportType": "gcm", "registrationToken": "test-token" } + ) +)) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.deviceRegistrations.list({"deviceId": device_id}) +``` + +### Assertions +```pseudo +ASSERT result IS PaginatedResult +ASSERT result.items.length == 1 +ASSERT result.items[0].id == device_id +ASSERT result.items[0].platform == "android" +``` + +### Cleanup +```pseudo +AWAIT client.push.admin.deviceRegistrations.remove(device_id) +``` + +--- + +## RSH1b2 — list supports pagination with limit + +**Test ID**: `rest/integration/RSH1b2/list-devices-pagination-1` + +**Spec requirement:** RSH1b2 — A test should exist controlling the pagination with the `limit` attribute. + +Tests that the `limit` parameter restricts the number of results. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) +client_id = "test-client-list-" + random_id() +device_ids = [] + +# Register multiple devices with the same clientId +FOR i IN [1, 2, 3]: + device_id = "test-device-limit-" + i + "-" + random_id() + device_ids.append(device_id) + AWAIT client.push.admin.deviceRegistrations.save(DeviceDetails( + id: device_id, + clientId: client_id, + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": "token-" + i } + ) + )) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.deviceRegistrations.list({ + "clientId": client_id, + "limit": "2" +}) +``` + +### Assertions +```pseudo +ASSERT result.items.length <= 2 +ASSERT result.hasNext == true +``` + +### Cleanup +```pseudo +FOR device_id IN device_ids: + AWAIT client.push.admin.deviceRegistrations.remove(device_id) +``` + +--- + +## RSH1b4 — remove deletes device registration + +**Test ID**: `rest/integration/RSH1b4/remove-device-0` + +**Spec requirement:** RSH1b4 — `#remove(deviceId)` deletes the registered device. + +Tests that a registered device can be removed and is no longer retrievable. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) +device_id = "test-device-remove-" + random_id() + +# Register a device +AWAIT client.push.admin.deviceRegistrations.save(DeviceDetails( + id: device_id, + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": "test-token" } + ) +)) +``` + +### Test Steps +```pseudo +# Remove the device +AWAIT client.push.admin.deviceRegistrations.remove(device_id) + +# Verify it's gone +AWAIT client.push.admin.deviceRegistrations.get(device_id) FAILS WITH error +ASSERT error.statusCode == 404 +``` + +--- + +## RSH1b4 — remove succeeds for nonexistent device + +**Test ID**: `rest/integration/RSH1b4/remove-nonexistent-device-1` + +**Spec requirement:** RSH1b4 — Deleting a device that does not exist still succeeds. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) +``` + +### Test Steps and Assertions +```pseudo +# Should not throw +AWAIT client.push.admin.deviceRegistrations.remove("nonexistent-device-" + random_id()) +``` + +--- + +## RSH1b5 — removeWhere deletes devices by clientId + +**Test ID**: `rest/integration/RSH1b5/remove-where-clientid-0` + +**Spec requirement:** RSH1b5 — `#removeWhere(params)` deletes registered devices matching params. + +Tests that devices can be bulk-removed by `clientId`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) +client_id = "test-client-removeWhere-" + random_id() +device_ids = [] + +# Register two devices with the same clientId +FOR i IN [1, 2]: + device_id = "test-device-rw-" + i + "-" + random_id() + device_ids.append(device_id) + AWAIT client.push.admin.deviceRegistrations.save(DeviceDetails( + id: device_id, + clientId: client_id, + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": "token-" + i } + ) + )) +``` + +### Test Steps +```pseudo +# Remove all devices for this clientId +AWAIT client.push.admin.deviceRegistrations.removeWhere({"clientId": client_id}) + +# Verify both are gone +result = AWAIT client.push.admin.deviceRegistrations.list({"clientId": client_id}) +ASSERT result.items.length == 0 +``` + +--- + +## RSH1c3, RSH1c1 — save and list channel subscriptions + +**Test ID**: `rest/integration/RSH1c3/save-and-list-subscriptions-0` + +| Spec | Requirement | +|------|-------------| +| RSH1c3 | `#save(subscription)` creates a channel subscription | +| RSH1c1 | `#list(params)` returns paginated subscriptions | + +Tests the channel subscription lifecycle: save then list. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) +device_id = "test-device-sub-" + random_id() +channel_name = "pushenabled:test-sub-" + random_id() + +# Register a device first (required for deviceId subscriptions) +AWAIT client.push.admin.deviceRegistrations.save(DeviceDetails( + id: device_id, + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": "test-token" } + ) +)) +``` + +### Test Steps +```pseudo +# Save a channel subscription +saved = AWAIT client.push.admin.channelSubscriptions.save(PushChannelSubscription( + channel: channel_name, + deviceId: device_id +)) +``` + +### Assertions +```pseudo +ASSERT saved IS PushChannelSubscription +ASSERT saved.channel == channel_name +ASSERT saved.deviceId == device_id + +# List subscriptions for this channel +result = AWAIT client.push.admin.channelSubscriptions.list({"channel": channel_name}) +ASSERT result IS PaginatedResult +ASSERT result.items.length >= 1 + +found = false +FOR sub IN result.items: + IF sub.deviceId == device_id: + found = true + ASSERT sub.channel == channel_name +ASSERT found == true +``` + +### Cleanup +```pseudo +AWAIT client.push.admin.channelSubscriptions.remove(PushChannelSubscription( + channel: channel_name, + deviceId: device_id +)) +AWAIT client.push.admin.deviceRegistrations.remove(device_id) +``` + +--- + +## RSH1c3 — save channel subscription with clientId + +**Test ID**: `rest/integration/RSH1c3/save-subscription-clientid-1` + +**Spec requirement:** RSH1c3 — A test should exist for saving a `clientId`-based subscription. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) +client_id = "test-client-sub-" + random_id() +channel_name = "pushenabled:test-clientsub-" + random_id() +``` + +### Test Steps +```pseudo +saved = AWAIT client.push.admin.channelSubscriptions.save(PushChannelSubscription( + channel: channel_name, + clientId: client_id +)) +``` + +### Assertions +```pseudo +ASSERT saved.channel == channel_name +ASSERT saved.clientId == client_id +``` + +### Cleanup +```pseudo +AWAIT client.push.admin.channelSubscriptions.remove(PushChannelSubscription( + channel: channel_name, + clientId: client_id +)) +``` + +--- + +## RSH1c2 — listChannels returns channel names with subscriptions + +**Test ID**: `rest/integration/RSH1c2/list-channels-with-subscriptions-0` + +**Spec requirement:** RSH1c2 — `#listChannels(params)` returns a paginated result with `String` objects. + +Tests that channels with active subscriptions appear in listChannels. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) +client_id = "test-client-lc-" + random_id() +channel_name = "pushenabled:test-listchannels-" + random_id() + +# Create a subscription to ensure the channel appears +AWAIT client.push.admin.channelSubscriptions.save(PushChannelSubscription( + channel: channel_name, + clientId: client_id +)) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.channelSubscriptions.listChannels({}) +``` + +### Assertions +```pseudo +ASSERT result IS PaginatedResult +# The channel we subscribed to should appear in the list +ASSERT channel_name IN result.items +``` + +### Cleanup +```pseudo +AWAIT client.push.admin.channelSubscriptions.remove(PushChannelSubscription( + channel: channel_name, + clientId: client_id +)) +``` + +--- + +## RSH1c4 — remove deletes channel subscription + +**Test ID**: `rest/integration/RSH1c4/remove-channel-subscription-0` + +**Spec requirement:** RSH1c4 — `#remove(subscription)` deletes a channel subscription using subscription attributes as params. + +Tests that a subscription can be removed and no longer appears in list results. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) +client_id = "test-client-rm-" + random_id() +channel_name = "pushenabled:test-remove-" + random_id() + +# Create a subscription +AWAIT client.push.admin.channelSubscriptions.save(PushChannelSubscription( + channel: channel_name, + clientId: client_id +)) +``` + +### Test Steps +```pseudo +# Remove the subscription +AWAIT client.push.admin.channelSubscriptions.remove(PushChannelSubscription( + channel: channel_name, + clientId: client_id +)) + +# Verify it's gone +result = AWAIT client.push.admin.channelSubscriptions.list({ + "channel": channel_name, + "clientId": client_id +}) +ASSERT result.items.length == 0 +``` + +--- + +## RSH1c4 — remove succeeds for nonexistent subscription + +**Test ID**: `rest/integration/RSH1c4/remove-nonexistent-subscription-1` + +**Spec requirement:** RSH1c4 — Deleting a subscription that does not exist still succeeds. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) +``` + +### Test Steps and Assertions +```pseudo +# Should not throw +AWAIT client.push.admin.channelSubscriptions.remove(PushChannelSubscription( + channel: "pushenabled:nonexistent-" + random_id(), + clientId: "nonexistent-client" +)) +``` + +--- + +## RSH1c5 — removeWhere deletes subscriptions by clientId + +**Test ID**: `rest/integration/RSH1c5/remove-where-subscriptions-0` + +**Spec requirement:** RSH1c5 — `#removeWhere(params)` deletes matching channel subscriptions. + +Tests that subscriptions can be bulk-removed by `clientId`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) +client_id = "test-client-rwsub-" + random_id() +channel_names = [] + +# Create subscriptions on two channels for the same clientId +FOR i IN [1, 2]: + ch = "pushenabled:test-rwsub-" + i + "-" + random_id() + channel_names.append(ch) + AWAIT client.push.admin.channelSubscriptions.save(PushChannelSubscription( + channel: ch, + clientId: client_id + )) +``` + +### Test Steps +```pseudo +# Remove all subscriptions for this clientId +AWAIT client.push.admin.channelSubscriptions.removeWhere({"clientId": client_id}) + +# Verify they're all gone +result = AWAIT client.push.admin.channelSubscriptions.list({"clientId": client_id}) +ASSERT result.items.length == 0 +``` diff --git a/uts/rest/integration/push_channels.md b/uts/rest/integration/push_channels.md new file mode 100644 index 000000000..52a152a66 --- /dev/null +++ b/uts/rest/integration/push_channels.md @@ -0,0 +1,187 @@ +# PushChannel Integration Tests + +Spec points: `RSH7a`, `RSH7b`, `RSH7c`, `RSH7d` + +## Test Type +Integration test against Ably sandbox + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. + +### App Provisioning + +Uses `ably-common/test-resources/test-app-setup.json` which provides: +- `keys[0]` — full access (default capability `{"*":["*"]}`) + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + full_access_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {full_access_key} +``` + +### Notes +- All clients use `useBinaryProtocol: false` (SDK does not implement msgpack) +- All clients use `endpoint: "nonprod:sandbox"` +- These tests require the platform to support push notifications and the local device to be configurable for push registration. If the sandbox or platform does not support push device registration, these tests should be skipped. +- A device must be registered (via `push.admin.deviceRegistrations.save`) before device-based channel subscriptions can be created +- The `PushChannel` methods operate on behalf of the local device — the `LocalDevice` must be configured to simulate a registered push target device + +--- + +## RSH7a, RSH7c — subscribeDevice and unsubscribeDevice round-trip + +**Test ID**: `rest/integration/RSH7a/subscribe-unsubscribe-device-0` + +| Spec | Requirement | +|------|-------------| +| RSH7a | subscribeDevice() subscribes the local device to push on a channel | +| RSH7a2 | Performs a POST to /push/channelSubscriptions with device id and channel name | +| RSH7c | unsubscribeDevice() unsubscribes the local device from push on a channel | +| RSH7c2 | Performs a DELETE to /push/channelSubscriptions with device id and channel name | + +Tests the full device subscription lifecycle: register a device, subscribe it to a channel via `PushChannel.subscribeDevice()`, verify the subscription exists, then unsubscribe via `PushChannel.unsubscribeDevice()` and verify it is removed. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) + +device_id = "test-device-pushchan-" + random_id() +channel_name = "pushenabled:test-rsh7a-" + random_id() +device_token = "test-apns-token-" + random_id() + +# Register a device via admin API (required before device subscriptions work) +AWAIT client.push.admin.deviceRegistrations.save(DeviceDetails( + id: device_id, + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": device_token } + ) +)) + +# Configure the local device to match the registered device +# The deviceIdentityToken is obtained from the registration response +# For integration testing, we use the admin API to register and then +# configure the LocalDevice with values that allow push device auth +client.device = LocalDevice( + id: device_id, + deviceIdentityToken: "test-device-identity-token", + clientId: null +) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Subscribe the device to push on this channel +AWAIT channel.push.subscribeDevice() + +# Verify subscription exists via admin API +result = AWAIT client.push.admin.channelSubscriptions.list({ + "channel": channel_name, + "deviceId": device_id +}) +ASSERT result.items.length >= 1 + +found = false +FOR sub IN result.items: + IF sub.deviceId == device_id AND sub.channel == channel_name: + found = true +ASSERT found == true + +# Unsubscribe the device +AWAIT channel.push.unsubscribeDevice() + +# Verify subscription is removed +result_after = AWAIT client.push.admin.channelSubscriptions.list({ + "channel": channel_name, + "deviceId": device_id +}) +ASSERT result_after.items.length == 0 +``` + +### Cleanup +```pseudo +AWAIT client.push.admin.deviceRegistrations.remove(device_id) +``` + +--- + +## RSH7b, RSH7d — subscribeClient and unsubscribeClient round-trip + +**Test ID**: `rest/integration/RSH7b/subscribe-unsubscribe-client-0` + +| Spec | Requirement | +|------|-------------| +| RSH7b | subscribeClient() subscribes the local device's clientId to push on a channel | +| RSH7b2 | Performs a POST to /push/channelSubscriptions with device clientId and channel name | +| RSH7d | unsubscribeClient() unsubscribes the local device's clientId from push on a channel | +| RSH7d2 | Performs a DELETE to /push/channelSubscriptions with device clientId and channel name | + +Tests the full client subscription lifecycle: configure a local device with a `clientId`, subscribe via `PushChannel.subscribeClient()`, verify the subscription exists, then unsubscribe via `PushChannel.unsubscribeClient()` and verify it is removed. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) + +client_id = "test-client-pushchan-" + random_id() +channel_name = "pushenabled:test-rsh7b-" + random_id() + +# Configure the local device with a clientId +# subscribeClient does not require device registration — it subscribes +# by clientId, not by deviceId +client.device = LocalDevice( + id: "test-device-" + random_id(), + deviceIdentityToken: "test-device-identity-token", + clientId: client_id +) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Subscribe the client to push on this channel +AWAIT channel.push.subscribeClient() + +# Verify subscription exists via admin API +result = AWAIT client.push.admin.channelSubscriptions.list({ + "channel": channel_name, + "clientId": client_id +}) +ASSERT result.items.length >= 1 + +found = false +FOR sub IN result.items: + IF sub.clientId == client_id AND sub.channel == channel_name: + found = true +ASSERT found == true + +# Unsubscribe the client +AWAIT channel.push.unsubscribeClient() + +# Verify subscription is removed +result_after = AWAIT client.push.admin.channelSubscriptions.list({ + "channel": channel_name, + "clientId": client_id +}) +ASSERT result_after.items.length == 0 +``` diff --git a/uts/rest/integration/revoke_tokens.md b/uts/rest/integration/revoke_tokens.md new file mode 100644 index 000000000..fcb574965 --- /dev/null +++ b/uts/rest/integration/revoke_tokens.md @@ -0,0 +1,303 @@ +# Revoke Tokens Integration Tests + +Spec points: `RSA17`, `RSA17b`, `RSA17c`, `RSA17d`, `RSA17e`, `RSA17f`, `RSA17g`, `TRS2`, `TRF2` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +End-to-end verification of `Auth#revokeTokens` against the Ably sandbox. +These tests verify that token revocation actually prevents subsequent use +of the revoked token, in addition to confirming the response format. + +## Verification Strategy + +Revocation is verified using **Realtime connections** rather than REST requests. +After revoking a token, the server pushes a disconnect to any connected Realtime +client using that token. This is more reliable than polling with REST requests, +because token revocation may take a small delay to become active on the REST +path. The Realtime disconnect is immediate and carries the `40141` error code. + +The test sets up `disconnected` listeners **before** performing the revocation, +to avoid missing the state change. + +## Server Response Format + +With `X-Ably-Version >= 3` (sent by all current SDKs), the Ably server returns a +`BatchResult` envelope for all token revocation responses: + +```json +{ + "successCount": 1, + "failureCount": 1, + "results": [ + {"target": "clientId:xxx", "appliesAt": 1234567890, "issuedBefore": 1234567890}, + {"target": "invalidType:abc", "error": {"code": 40000, "statusCode": 400, "message": "..."}} + ] +} +``` + +Both all-success and mixed success/failure responses return HTTP 201 with this +format. The `successCount`, `failureCount`, and `results` fields are provided by +the server — no client-side computation is needed. + +**Legacy format (no version header):** Without `X-Ably-Version`, the server +returns a plain array for all-success and `{error, batchResponse}` for mixed +results (HTTP 400). This format is not used by current SDKs. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. + +### App Provisioning + +Uses `ably-common/test-resources/test-app-setup.json` which provides: +- `keys[0]` — full access (default capability `{"*":["*"]}`) +- `keys[4]` — `revocableTokens: true` (required for the revokeTokens endpoint) + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + full_access_key = app_config.keys[0].key_str + revocable_key = app_config.keys[4].key_str # revocableTokens: true + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {full_access_key} +``` + +--- + +## RSA17g, RSA17b, RSA17c, TRS2 - Token revocation prevents subsequent use + +**Test ID**: `rest/integration/RSA17g/revoke-token-prevents-use-0` + +**Spec requirement:** `Auth#revokeTokens` sends a POST to +`/keys/{keyName}/revokeTokens` with `targets` as `type:value` strings, and +returns a result containing per-target success information. After revocation, +the token must be rejected by the server. + +| Spec | Requirement | +|------|-------------| +| RSA17g | POST to `/keys/{keyName}/revokeTokens` | +| RSA17b | Targets mapped to `type:value` strings | +| RSA17c | Returns per-target results; SDK computes `successCount`, `failureCount` client-side | +| TRS2a | Success result contains `target` string | +| TRS2b | Success result contains `appliesAt` timestamp | +| TRS2c | Success result contains `issuedBefore` timestamp | + +### Setup +```pseudo +client_id = "revoke-client-" + random_id() + +# Create a key-auth REST client (using the revocable key) for revoking and token issuance +key_client = Rest(options: ClientOptions( + key: revocable_key, + endpoint: "nonprod:sandbox" +)) + +# Request a native token for the clientId +token_details = AWAIT key_client.auth.requestToken(clientId: client_id) + +# Create a Realtime client using the token, and wait for it to connect +realtime_client = Realtime(options: ClientOptions( + token: token_details, + endpoint: "nonprod:sandbox" +)) +AWAIT realtime_client.connection.once("connected") +``` + +### Test Steps +```pseudo +# Step 1: Set up a disconnected listener BEFORE revoking (to not miss the event) +disconnected_promise = realtime_client.connection.once("disconnected") + +# Step 2: Revoke the token by clientId +revoke_result = AWAIT key_client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: client_id) +]) + +# Step 3: Verify the revokeTokens response structure (RSA17c, TRS2) +ASSERT revoke_result.successCount == 1 +ASSERT revoke_result.failureCount == 0 +ASSERT revoke_result.results.length == 1 + +success = revoke_result.results[0] +ASSERT success IS TokenRevocationSuccessResult +ASSERT success.target == "clientId:" + client_id +ASSERT success.issuedBefore IS number +ASSERT success.appliesAt IS number + +# Step 4: Verify the Realtime client is disconnected with 40141 (token revoked) +state_change = AWAIT disconnected_promise +ASSERT state_change.reason.code == 40141 +``` + +--- + +## RSA17d - Token auth client rejected + +**Test ID**: `rest/integration/RSA17d/token-auth-revoke-rejected-0` + +**Spec requirement:** If called from a client using token authentication, +should raise an error with code `40162` and status code `401`. This is a +client-side check — no HTTP request is made to the server. + +### Setup +```pseudo +# Generate a JWT using the revocable key +jwt = generate_jwt( + key_name: extract_key_name(revocable_key), + key_secret: extract_key_secret(revocable_key), + ttl: 3600000 +) + +# Create a client using token auth (JWT) +token_rest = Rest(options: ClientOptions( + token: jwt, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) +``` + +### Test Steps +```pseudo +AWAIT token_rest.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "anyone") +]) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40162 +ASSERT error.statusCode == 401 +``` + +--- + +## RSA17e, RSA17f - issuedBefore and allowReauthMargin + +**Test ID**: `rest/integration/RSA17e/issued-before-reauth-margin-0` + +| Spec | Requirement | +|------|-------------| +| RSA17e | Optional `issuedBefore` timestamp in milliseconds | +| RSA17f | Optional `allowReauthMargin` boolean delays revocation by ~30 seconds | + +**Spec requirement:** When `issuedBefore` is provided, only tokens issued before +that timestamp are revoked. When `allowReauthMargin` is true, the revocation is +delayed by approximately 30 seconds to allow token renewal. + +### Setup +```pseudo +client_id = "revoke-margin-client-" + random_id() + +key_client = Rest(options: ClientOptions( + key: revocable_key, + endpoint: "nonprod:sandbox" +)) +``` + +### Test Steps +```pseudo +# Step 1: Revoke with issuedBefore and allowReauthMargin +server_time = AWAIT key_client.time() + +# Use an issuedBefore in the past to avoid affecting any active tokens +issued_before = server_time - (20 * 60 * 1000) + +revoke_result = AWAIT key_client.auth.revokeTokens( + [TokenRevocationTargetSpecifier(type: "clientId", value: client_id)], + options: { issuedBefore: issued_before, allowReauthMargin: true } +) + +ASSERT revoke_result.successCount == 1 +ASSERT revoke_result.results.length == 1 + +# RSA17e: issuedBefore should reflect what we sent +ASSERT revoke_result.results[0].issuedBefore == issued_before + +# RSA17f: allowReauthMargin delays appliesAt by ~30 seconds +ASSERT revoke_result.results[0].appliesAt > server_time + (30 * 1000) +``` + +--- + +## RSA17c, TRF2 - Mixed success and failure (invalid specifier type) + +**Test ID**: `rest/integration/RSA17c/mixed-success-failure-0` + +**Spec requirement:** The response can contain both successful and failed +per-target results. An invalid target type produces a failure result with +an `ErrorInfo`. + +| Spec | Requirement | +|------|-------------| +| RSA17c | `BatchResult` with `successCount` and `failureCount` | +| TRF2a | Failure result contains `target` string | +| TRF2b | Failure result contains `error` ErrorInfo | + +This test includes an invalid specifier type alongside a valid one, to +verify the server returns per-target error information. The valid revocation +is also verified by confirming the Realtime client is disconnected. + +### Setup +```pseudo +client_id = "revoke-mixed-client-" + random_id() + +# Create a key-auth REST client for revoking and token issuance +key_client = Rest(options: ClientOptions( + key: revocable_key, + endpoint: "nonprod:sandbox" +)) + +# Request a native token for the clientId +token_details = AWAIT key_client.auth.requestToken(clientId: client_id) + +# Create a Realtime client using the token, and wait for it to connect +realtime_client = Realtime(options: ClientOptions( + token: token_details, + endpoint: "nonprod:sandbox" +)) +AWAIT realtime_client.connection.once("connected") +``` + +### Test Steps +```pseudo +# Step 1: Set up a disconnected listener BEFORE revoking +disconnected_promise = realtime_client.connection.once("disconnected") + +# Step 2: Revoke with one valid and one invalid specifier +revoke_result = AWAIT key_client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: client_id), + TokenRevocationTargetSpecifier(type: "invalidType", value: "abc") +]) + +# Step 3: Verify the response contains both success and failure +ASSERT revoke_result.successCount == 1 +ASSERT revoke_result.failureCount == 1 +ASSERT revoke_result.results.length == 2 + +# Valid specifier succeeds +success = revoke_result.results[0] +ASSERT success IS TokenRevocationSuccessResult +ASSERT success.target == "clientId:" + client_id +ASSERT success.issuedBefore IS number +ASSERT success.appliesAt IS number + +# Invalid specifier fails +failure = revoke_result.results[1] +ASSERT failure IS TokenRevocationFailureResult +ASSERT failure.target == "invalidType:abc" +ASSERT failure.error IS ErrorInfo +ASSERT failure.error.statusCode == 400 + +# Step 4: Verify the Realtime client is disconnected with 40141 (token revoked) +state_change = AWAIT disconnected_promise +ASSERT state_change.reason.code == 40141 +``` diff --git a/uts/rest/integration/time_stats.md b/uts/rest/integration/time_stats.md new file mode 100644 index 000000000..aa48d5518 --- /dev/null +++ b/uts/rest/integration/time_stats.md @@ -0,0 +1,133 @@ +# Time and Stats Integration Tests + +Spec points: `RSC16`, `RSC6` + +## Test Type +Integration test against Ably sandbox + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RSC16 - time() returns server time + +**Test ID**: `rest/integration/RSC16/time-returns-server-time-0` + +**Spec requirement:** RSC16 - `time()` obtains the current server time. + +Tests that `time()` returns the current server time. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) +``` + +### Test Steps +```pseudo +before_request = now() +server_time = AWAIT client.time() +after_request = now() +``` + +### Assertions +```pseudo +# Server time should be a DateTime +ASSERT server_time IS DateTime + +# Server time should be reasonably close to client time +# (allowing for network latency and minor clock differences) +ASSERT server_time >= before_request - 5000ms +ASSERT server_time <= after_request + 5000ms +``` + +--- + +## RSC6 - stats() returns application statistics + +**Test ID**: `rest/integration/RSC6/stats-returns-result-0` + +**Spec requirement:** RSC6 - `stats()` returns a `PaginatedResult` containing application statistics. + +Tests that `stats()` returns stats for the application. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) +``` + +### Test Steps +```pseudo +# Stats may be empty for a new sandbox app, but the call should succeed +result = AWAIT client.stats() +``` + +### Assertions +```pseudo +# Result should be a PaginatedResult (may be empty) +ASSERT result IS PaginatedResult +ASSERT result.items IS List + +# If there are items, they should have expected structure +IF result.items.length > 0: + ASSERT result.items[0].intervalId IS String + ASSERT result.items[0].unit IN ["minute", "hour", "day", "month"] +``` + +--- + +## RSC6 - stats() with parameters + +**Test ID**: `rest/integration/RSC6/stats-with-parameters-1` + +**Spec requirement:** RSC6 - `stats()` supports `limit`, `direction`, and `unit` parameters. + +Tests that `stats()` correctly applies query parameters. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox" +)) +``` + +### Test Steps +```pseudo +# Request stats with specific parameters +result = AWAIT client.stats( + limit: 5, + direction: "forwards", + unit: "hour" +) +``` + +### Assertions +```pseudo +# Should succeed with parameters applied +ASSERT result IS PaginatedResult +ASSERT result.items.length <= 5 +``` diff --git a/uts/rest/unit/auth/auth_callback.md b/uts/rest/unit/auth/auth_callback.md new file mode 100644 index 000000000..470411a72 --- /dev/null +++ b/uts/rest/unit/auth/auth_callback.md @@ -0,0 +1,583 @@ +# Auth Callback Tests + +Spec points: `RSA8c`, `RSA8d` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/rest_client.md` for the full Mock HTTP Infrastructure specification. These tests use the same `MockHttpClient` interface with `PendingConnection` and `PendingRequest`. + +## Purpose + +These tests verify that the library correctly invokes `authCallback` and `authUrl` to obtain tokens for authentication. The authCallback/authUrl can return: +- A `TokenDetails` object (containing token, expires, etc.) +- A `TokenRequest` object (which the library exchanges for a token) +- A JWT string (raw token string) + +--- + +## RSA8d - authCallback invoked for authentication + +**Test ID**: `rest/unit/RSA8d/callback-invoked-for-auth-0` + +**Spec requirement:** When `authCallback` is configured, it is invoked to obtain a token for authentication. + +Tests that when `authCallback` is configured, it is invoked to obtain a token. + +### Setup +```pseudo +callback_invoked = false +callback_params = null +captured_requests = [] + +auth_callback = FUNCTION(params): + callback_invoked = true + callback_params = params + RETURN TokenDetails( + token: "callback-token", + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +# Make a request that requires authentication +result = AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +# authCallback was invoked +ASSERT callback_invoked == true + +# Request used the token from authCallback +ASSERT captured_requests.length == 1 +ASSERT captured_requests[0].headers["Authorization"] == "Bearer callback-token" +``` + +--- + +## RSA8d - authCallback returning JWT string + +**Test ID**: `rest/unit/RSA8d/callback-returns-jwt-1` + +**Spec requirement:** authCallback can return a raw JWT string (not wrapped in TokenDetails). + +Tests that authCallback can return a raw JWT string (not wrapped in TokenDetails). + +### Setup +```pseudo +captured_requests = [] + +auth_callback = FUNCTION(params): + # Return raw JWT string instead of TokenDetails + RETURN "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test-jwt-payload" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +# Request used the JWT from authCallback +ASSERT captured_requests[0].headers["Authorization"] == "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test-jwt-payload" +``` + +--- + +## RSA8d - authCallback returning TokenRequest + +**Test ID**: `rest/unit/RSA8d/callback-returns-token-request-2` + +**Spec requirement:** When authCallback returns a TokenRequest, the library must exchange it for a token via the requestToken endpoint. + +Tests that when authCallback returns a TokenRequest, the library exchanges it for a token. + +### Setup +```pseudo +captured_requests = [] + +auth_callback = FUNCTION(params): + # Return a TokenRequest (to be exchanged for token) + RETURN TokenRequest( + keyName: "app.key", + ttl: 3600000, + timestamp: now(), + nonce: "unique-nonce", + mac: "computed-mac" + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.path matches "/keys/.*/requestToken": + # First request exchanges TokenRequest for TokenDetails + req.respond_with(200, { + "token": "exchanged-token", + "expires": now() + 3600000 + }) + ELSE: + # Second request is the actual API call + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +# Two HTTP requests: token exchange + API call +ASSERT captured_requests.length == 2 + +# First request was POST to /keys/{keyName}/requestToken +first_request = captured_requests[0] +ASSERT first_request.method == "POST" +ASSERT first_request.path matches "/keys/.*/requestToken" + +# Second request used the exchanged token +second_request = captured_requests[1] +ASSERT second_request.headers["Authorization"] == "Bearer exchanged-token" +``` + +--- + +## RSA8d - authCallback receives TokenParams + +**Test ID**: `rest/unit/RSA8d/callback-receives-token-params-3` + +**Spec requirement:** authCallback receives TokenParams when provided to authorize(). + +Tests that authCallback receives TokenParams when provided to authorize(). + +### Setup +```pseudo +received_params = null + +auth_callback = FUNCTION(params): + received_params = params + RETURN TokenDetails( + token: "test-token", + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +AWAIT client.auth.authorize( + tokenParams: TokenParams( + clientId: "requested-client-id", + ttl: 7200000, + capability: {"channel1": ["publish"]} + ) +) +``` + +### Assertions +```pseudo +# authCallback received the TokenParams +ASSERT received_params.clientId == "requested-client-id" +ASSERT received_params.ttl == 7200000 +ASSERT received_params.capability == {"channel1": ["publish"]} +``` + +--- + +## RSA8c - authUrl invoked for authentication + +**Test ID**: `rest/unit/RSA8c/authurl-invoked-for-auth-0` + +**Spec requirement:** When `authUrl` is configured, the library must fetch a token from it before making API requests. + +Tests that when `authUrl` is configured, the library fetches a token from it. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.url.host == "auth.example.com": + # authUrl returns TokenDetails + req.respond_with(200, { + "token": "authurl-token", + "expires": now() + 3600000 + }) + ELSE: + # Actual API request + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + authUrl: "https://auth.example.com/token" + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +# First request was to authUrl +auth_request = captured_requests[0] +ASSERT auth_request.url.host == "auth.example.com" +ASSERT auth_request.url.path == "/token" +ASSERT auth_request.method == "GET" + +# Second request used the token from authUrl +api_request = captured_requests[1] +ASSERT api_request.headers["Authorization"] == "Bearer authurl-token" +``` + +--- + +## RSA8c - authUrl with POST method + +**Test ID**: `rest/unit/RSA8c/authurl-post-method-1` + +**Spec requirement:** authMethod can be set to POST for authUrl requests. + +Tests that authMethod can be set to POST for authUrl. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.url.host == "auth.example.com": + req.respond_with(200, { + "token": "authurl-token", + "expires": now() + 3600000 + }) + ELSE: + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + authUrl: "https://auth.example.com/token", + authMethod: "POST" + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +# authUrl request used POST method +auth_request = captured_requests[0] +ASSERT auth_request.method == "POST" +``` + +--- + +## RSA8c - authUrl with custom headers + +**Test ID**: `rest/unit/RSA8c/authurl-custom-headers-2` + +**Spec requirement:** authHeaders are sent with authUrl requests. + +Tests that authHeaders are sent with authUrl requests. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.url.host == "auth.example.com": + req.respond_with(200, { + "token": "authurl-token", + "expires": now() + 3600000 + }) + ELSE: + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + authUrl: "https://auth.example.com/token", + authHeaders: { + "X-Custom-Header": "custom-value", + "X-API-Key": "my-api-key" + } + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +auth_request = captured_requests[0] +ASSERT auth_request.headers["X-Custom-Header"] == "custom-value" +ASSERT auth_request.headers["X-API-Key"] == "my-api-key" +``` + +--- + +## RSA8c - authUrl with query params + +**Test ID**: `rest/unit/RSA8c/authurl-query-params-3` + +**Spec requirement:** authParams are sent as query parameters with authUrl GET requests. + +Tests that authParams are sent as query parameters with authUrl GET requests. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.url.host == "auth.example.com": + req.respond_with(200, { + "token": "authurl-token", + "expires": now() + 3600000 + }) + ELSE: + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + authUrl: "https://auth.example.com/token", + authParams: { + "client_id": "my-client", + "scope": "publish:*" + } + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +auth_request = captured_requests[0] +ASSERT auth_request.url.query_params["client_id"] == "my-client" +ASSERT auth_request.url.query_params["scope"] == "publish:*" +``` + +--- + +## RSA8c - authUrl returning JWT string + +**Test ID**: `rest/unit/RSA8c/authurl-returns-jwt-4` + +**Spec requirement:** authUrl can return a raw JWT string (not JSON). + +Tests that authUrl can return a raw JWT string. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.url.host == "auth.example.com": + # authUrl returns plain text JWT (not JSON) + req.respond_with(200, + body: "eyJhbGciOiJIUzI1NiJ9.jwt-body.signature", + headers: {"Content-Type": "text/plain"} + ) + ELSE: + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + authUrl: "https://auth.example.com/jwt" + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +api_request = captured_requests[1] +ASSERT api_request.headers["Authorization"] == "Bearer eyJhbGciOiJIUzI1NiJ9.jwt-body.signature" +``` + +--- + +## RSA8d - authCallback error propagated + +**Test ID**: `rest/unit/RSA8d/callback-error-propagated-4` + +**Spec requirement:** Errors from authCallback are properly propagated to the caller. + +Tests that errors from authCallback are properly propagated. + +### Setup +```pseudo +captured_requests = [] + +auth_callback = FUNCTION(params): + THROW Error("Authentication server unavailable") + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") FAILS WITH error +# Error should indicate auth failure +ASSERT error.message CONTAINS "Authentication server unavailable" +``` + +### Assertions +```pseudo +# No HTTP requests should have been made +ASSERT captured_requests.length == 0 +``` + +--- + +## RSA8c - authUrl error propagated + +**Test ID**: `rest/unit/RSA8c/authurl-error-propagated-5` + +**Spec requirement:** HTTP errors from authUrl are properly propagated to the caller. + +Tests that HTTP errors from authUrl are properly propagated. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.url.host == "auth.example.com": + # authUrl returns error + req.respond_with(500, { + "error": "Internal server error" + }) + ELSE: + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + authUrl: "https://auth.example.com/token" + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") FAILS WITH error +ASSERT error.statusCode == 500 OR error.message CONTAINS "auth" +``` + +### Assertions +```pseudo +# Only authUrl request was made, not the API request +ASSERT captured_requests.length == 1 +ASSERT captured_requests[0].url.host == "auth.example.com" +``` diff --git a/uts/rest/unit/auth/auth_scheme.md b/uts/rest/unit/auth/auth_scheme.md new file mode 100644 index 000000000..1e444902f --- /dev/null +++ b/uts/rest/unit/auth/auth_scheme.md @@ -0,0 +1,516 @@ +# Auth Scheme Selection Tests + +Spec points: `RSA1`, `RSA2`, `RSA3`, `RSA4`, `RSA4a2`, `RSA11`, `RSC1b` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/rest_client.md` for the full Mock HTTP Infrastructure specification. These tests use the same `MockHttpClient` interface with `PendingConnection` and `PendingRequest`. + +## Purpose + +These tests verify that the library correctly selects between Basic authentication (API key) and Token authentication based on ClientOptions configuration. + +### Key Rules + +- **Basic auth**: Uses `Authorization: Basic {base64(key)}` header +- **Token auth**: Uses `Authorization: Bearer {token}` header + +--- + +## RSA4 - Basic auth with API key only + +**Test ID**: `rest/unit/RSA4/basic-auth-key-only-0` + +**Spec requirement:** When only an API key is provided (no clientId), Basic auth is used. + +Tests that when only an API key is provided (no clientId), Basic auth is used. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(key: "appId.keyId:keySecret") +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +request = captured_requests[0] + +# Basic auth header uses base64-encoded key +expected_auth = "Basic " + base64("appId.keyId:keySecret") +ASSERT request.headers["Authorization"] == expected_auth +``` + +--- + +## RSA3 - Token auth with explicit token + +**Test ID**: `rest/unit/RSA3/token-auth-explicit-token-0` + +**Spec requirement:** When an explicit token is provided, it is used for Bearer auth. + +Tests that when an explicit token is provided, it is used for Bearer auth. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(token: "explicit-token-string") +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.headers["Authorization"] == "Bearer explicit-token-string" +``` + +--- + +## RSA3 - Token auth with TokenDetails + +**Test ID**: `rest/unit/RSA3/token-auth-token-details-1` + +**Spec requirement:** When TokenDetails is provided, the token string is extracted and used for Bearer auth. + +Tests that when TokenDetails is provided, the token string is extracted and used. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + tokenDetails: TokenDetails( + token: "token-from-details", + expires: now() + 3600000 + ) + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.headers["Authorization"] == "Bearer token-from-details" +``` + +--- + +## RSA4 - useTokenAuth forces token auth + +**Test ID**: `rest/unit/RSA4/use-token-auth-forced-1` + +**Spec requirement:** `useTokenAuth: true` forces token auth even with just an API key. + +Tests that `useTokenAuth: true` forces token auth even with just an API key. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.path matches "/keys/.*/requestToken": + # Token request + req.respond_with(200, { + "token": "obtained-token", + "expires": now() + 3600000 + }) + ELSE: + # API request + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + key: "appId.keyId:keySecret", + useTokenAuth: true + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +# Should obtain token rather than use Basic auth +api_request = captured_requests[1] +ASSERT api_request.headers["Authorization"] == "Bearer obtained-token" +``` + +--- + +## RSA4 - authCallback triggers token auth + +**Test ID**: `rest/unit/RSA4/auth-callback-triggers-token-2` + +**Spec requirement:** Presence of authCallback triggers token auth. + +Tests that presence of authCallback triggers token auth. + +### Setup +```pseudo +captured_requests = [] + +auth_callback = FUNCTION(params): + RETURN TokenDetails( + token: "callback-token", + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.headers["Authorization"] == "Bearer callback-token" +``` + +--- + +## RSA4 - authUrl triggers token auth + +**Test ID**: `rest/unit/RSA4/authurl-triggers-token-3` + +**Spec requirement:** Presence of authUrl triggers token auth. + +Tests that presence of authUrl triggers token auth. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.url.host == "auth.example.com": + # authUrl response + req.respond_with(200, { + "token": "authurl-token", + "expires": now() + 3600000 + }) + ELSE: + # API request + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + authUrl: "https://auth.example.com/token" + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +api_request = captured_requests[1] +ASSERT api_request.headers["Authorization"] == "Bearer authurl-token" +``` + +--- + +## RSC1b - Error when no auth method available + +**Test ID**: `rest/unit/RSC1b/no-auth-method-error-0` + +**Spec requirement:** An error is raised when no authentication method is configured (code 40106). + +Tests that an error is raised when no authentication method is configured. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions() # No key, token, or auth callback +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") FAILS WITH error +ASSERT error.code == 40106 # No authentication method +``` + +### Assertions +```pseudo +# No HTTP request should have been made +ASSERT captured_requests.length == 0 +``` + +--- + +## RSA4a2 - Error when token expired and no renewal method + +**Test ID**: `rest/unit/RSA4a2/expired-token-no-renewal-0` + +**Spec requirement:** An error is raised when a static token has expired and there's no way to renew it (code 40171). + +Tests that an appropriate error is raised when a static token has expired and there's no way to renew it. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + tokenDetails: TokenDetails( + token: "expired-token", + expires: now() - 1000 # Already expired + ) + # No key, authCallback, or authUrl for renewal + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") FAILS WITH error +ASSERT error.code == 40171 # Token expired with no means of renewal +``` + +### Assertions +```pseudo +# No HTTP request should have been made +ASSERT captured_requests.length == 0 +``` + +--- + +## RSA1 - Auth method priority + +**Test ID**: `rest/unit/RSA1/token-auth-takes-precedence-0` + +**Spec requirement:** When multiple auth options are provided, token-based auth takes precedence over basic auth. + +Tests the priority order when multiple auth options are provided. + +### Setup +```pseudo +captured_requests = [] + +auth_callback = FUNCTION(params): + RETURN TokenDetails( + token: "callback-token", + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +# Both key and authCallback provided +client = Rest( + options: ClientOptions( + key: "appId.keyId:keySecret", + authCallback: auth_callback + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +# authCallback takes precedence, so Bearer auth is used +request = captured_requests[0] +ASSERT request.headers["Authorization"] == "Bearer callback-token" +``` + +--- + +## RSA2, RSA11 - Basic auth header format + +**Test ID**: `rest/unit/RSA2/basic-auth-header-format-0` + +**Spec requirement:** Basic auth uses the format `Authorization: Basic {base64(key)}` (RSA2). The API key is Base64-encoded per RFC 7235, with the key name as username and key secret as password (RSA11). + +Tests the exact format of Basic auth header. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(key: "app123.key456:secretXYZ") +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +request = captured_requests[0] + +# Verify exact Base64 encoding +# "app123.key456:secretXYZ" base64 encoded +expected = "Basic " + base64("app123.key456:secretXYZ") +ASSERT request.headers["Authorization"] == expected + +# The Base64 should NOT have URL-safe encoding (+ and / are valid) +ASSERT request.headers["Authorization"] CONTAINS "Basic " +``` + +--- + +## RSC18 - Token auth allowed over non-TLS + +**Test ID**: `rest/unit/RSC18/token-auth-over-non-tls-0` + +**Spec requirement:** Token auth is allowed over non-TLS connections. + +Tests that token auth is allowed over non-TLS connections. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + token: "explicit-token", + tls: false # Non-TLS allowed for token auth + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get("test").status() +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.headers["Authorization"] == "Bearer explicit-token" + +# Request should use http:// (non-TLS) +ASSERT request.url.scheme == "http" +``` diff --git a/uts/rest/unit/auth/authorize.md b/uts/rest/unit/auth/authorize.md new file mode 100644 index 000000000..26693d45a --- /dev/null +++ b/uts/rest/unit/auth/authorize.md @@ -0,0 +1,440 @@ +# Auth.authorize() Tests + +Spec points: `RSA10`, `RSA10a`, `RSA10b`, `RSA10e`, `RSA10g`, `RSA10h`, `RSA10i`, `RSA10j`, `RSA10k`, `RSA10l` + +## Test Type +Unit test with mocked HTTP client and/or mocked authCallback + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/rest_client.md` for the full Mock HTTP Infrastructure specification. These tests use the same `MockHttpClient` interface with `PendingConnection` and `PendingRequest`. + +--- + +## RSA10a - authorize() with default tokenParams + +**Test ID**: `rest/unit/RSA10a/authorize-default-params-0` + +**Spec requirement:** `authorize()` obtains a token using configured defaults. + +Tests that `authorize()` obtains a token using configured defaults. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.path matches "/keys/.*/requestToken": + # Token request + req.respond_with(200, { + "token": "obtained-token", + "expires": now() + 3600000, + "keyName": "appId.keyId" + }) + ELSE: + # Subsequent request to verify token is used + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +token_details = AWAIT client.auth.authorize() +``` + +### Assertions +```pseudo +ASSERT token_details IS TokenDetails +ASSERT token_details.token == "obtained-token" + +# Verify token is now used for requests +AWAIT client.channels.get("test").status() +ASSERT captured_requests.last.headers["Authorization"] == "Bearer obtained-token" +``` + +--- + +## RSA10b - authorize() with explicit tokenParams + +**Test ID**: `rest/unit/RSA10b/authorize-explicit-params-0` + +**Spec requirement:** Provided `tokenParams` override defaults in authorize(). + +Tests that provided `tokenParams` override defaults. + +### Setup +```pseudo +callback_params = [] + +mock_auth_callback = (params) => { + callback_params.append(params) + RETURN TokenDetails(token: "callback-token", expires: now() + 3600000) +} + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authCallback: mock_auth_callback, + clientId: "default-client" # Default TokenParams +)) +``` + +### Test Steps +```pseudo +AWAIT client.auth.authorize( + tokenParams: TokenParams( + clientId: "override-client", + ttl: 7200000 + ) +) +``` + +### Assertions +```pseudo +params = callback_params[0] +ASSERT params.clientId == "override-client" # Overridden +ASSERT params.ttl == 7200000 +``` + +--- + +## RSA10e - authorize() saves tokenParams for reuse + +**Test ID**: `rest/unit/RSA10e/authorize-saves-params-0` + +**Spec requirement:** `tokenParams` provided to `authorize()` are saved and reused on subsequent token requests. + +Tests that `tokenParams` provided to `authorize()` are saved and reused. + +### Setup +```pseudo +callback_invocations = [] + +mock_auth_callback = (params) => { + callback_invocations.append(params) + RETURN TokenDetails( + token: "token-" + str(callback_invocations.length), + expires: now() + 1000 # Very short expiry for testing + ) +} + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(authCallback: mock_auth_callback)) +``` + +### Test Steps +```pseudo +# First authorize with custom params +AWAIT client.auth.authorize( + tokenParams: TokenParams(clientId: "saved-client", ttl: 3600000) +) + +# Wait for token to expire +WAIT 1500 milliseconds + +# Force re-auth via request - should reuse saved params +AWAIT client.channels.get("test").status() +``` + +### Assertions +```pseudo +# Second callback should have received the saved params +ASSERT callback_invocations[1].clientId == "saved-client" +ASSERT callback_invocations[1].ttl == 3600000 +``` + +--- + +## RSA10g - authorize() updates Auth.tokenDetails + +**Test ID**: `rest/unit/RSA10g/authorize-updates-token-details-0` + +**Spec requirement:** After `authorize()`, `auth.tokenDetails` reflects the new token. + +Tests that after `authorize()`, `auth.tokenDetails` reflects the new token. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { + "token": "new-token", + "expires": now() + 3600000, + "keyName": "appId.keyId", + "clientId": "token-client" + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +ASSERT client.auth.tokenDetails IS null # Before authorize + +result = AWAIT client.auth.authorize() +``` + +### Assertions +```pseudo +ASSERT client.auth.tokenDetails IS NOT null +ASSERT client.auth.tokenDetails.token == "new-token" +ASSERT client.auth.tokenDetails.clientId == "token-client" +ASSERT client.auth.tokenDetails == result # Same object +``` + +--- + +## RSA10h - authorize() with authOptions replaces defaults + +**Test ID**: `rest/unit/RSA10h/authorize-replaces-auth-options-0` + +**Spec requirement:** `authOptions` in `authorize()` replaces stored auth options. + +Tests that `authOptions` in `authorize()` replaces stored auth options. + +### Setup +```pseudo +original_callback_called = false +new_callback_called = false + +original_callback = (params) => { + original_callback_called = true + RETURN TokenDetails(token: "original", expires: now() + 3600000) +} + +new_callback = (params) => { + new_callback_called = true + RETURN TokenDetails(token: "new", expires: now() + 3600000) +} + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(authCallback: original_callback)) +``` + +### Test Steps +```pseudo +AWAIT client.auth.authorize( + authOptions: AuthOptions(authCallback: new_callback) +) +``` + +### Assertions +```pseudo +ASSERT original_callback_called == false +ASSERT new_callback_called == true +``` + +--- + +## RSA10i - authorize() preserves key from constructor + +**Test ID**: `rest/unit/RSA10i/authorize-preserves-key-0` + +**Spec requirement:** The API key from `ClientOptions` is preserved even when `authOptions` are provided. + +Tests that the API key from `ClientOptions` is preserved even when `authOptions` are provided. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.path matches "/keys/.*/requestToken": + # Initial token request using key + req.respond_with(200, { + "token": "token-via-key", + "expires": now() + 3600000, + "keyName": "appId.keyId" + }) + ELSE: + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Call authorize with new authUrl but no key +AWAIT client.auth.authorize( + authOptions: AuthOptions( + authUrl: "https://new-auth.example.com/token" + ) +) + +# The key should still be available for signing +# Implementation can still use key for requestToken +``` + +### Assertions +```pseudo +# Key from constructor should be preserved (not cleared) +# Exact assertion depends on whether auth.key is exposed +# Verify by checking that key-based operations still work +``` + +--- + +## RSA10j - authorize() when already authorized + +**Test ID**: `rest/unit/RSA10j/authorize-replaces-existing-token-0` + +**Spec requirement:** Calling `authorize()` when a valid token exists obtains a new token. + +Tests that calling `authorize()` when a valid token exists obtains a new token. + +### Setup +```pseudo +token_count = 0 + +mock_auth_callback = (params) => { + token_count = token_count + 1 + RETURN TokenDetails( + token: "token-" + str(token_count), + expires: now() + 3600000 + ) +} + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(authCallback: mock_auth_callback)) +``` + +### Test Steps +```pseudo +result1 = AWAIT client.auth.authorize() +result2 = AWAIT client.auth.authorize() +``` + +### Assertions +```pseudo +ASSERT result1.token == "token-1" +ASSERT result2.token == "token-2" +ASSERT client.auth.tokenDetails.token == "token-2" +``` + +--- + +## RSA10k - authorize() with queryTime option + +**Test ID**: `rest/unit/RSA10k/authorize-query-time-0` + +**Spec requirement:** `queryTime: true` causes time to be queried from server before requesting token. + +Tests that `queryTime: true` causes time to be queried from server. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.url.path == "/time": + # Time query + req.respond_with(200, { "time": 1234567890000 }) + ELSE: + # Token request + req.respond_with(200, { + "token": "time-synced-token", + "expires": 1234567890000 + 3600000 + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.authorize( + authOptions: AuthOptions(queryTime: true) +) +``` + +### Assertions +```pseudo +# Should have made two requests: time query + token request +time_request = captured_requests.find(r => r.url.path == "/time") +ASSERT time_request IS NOT null +``` + +--- + +## RSA10l - authorize() error handling + +**Test ID**: `rest/unit/RSA10l/authorize-error-propagated-0` + +**Spec requirement:** Errors during authorization are properly propagated to the caller. + +Tests that errors during authorization are properly propagated. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(401, { + "error": { + "code": 40100, + "statusCode": 401, + "message": "Unauthorized" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "invalid.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.authorize() FAILS WITH error +ASSERT error.code == 40100 +ASSERT error.statusCode == 401 +``` diff --git a/uts/rest/unit/auth/client_id.md b/uts/rest/unit/auth/client_id.md new file mode 100644 index 000000000..5a832c3f4 --- /dev/null +++ b/uts/rest/unit/auth/client_id.md @@ -0,0 +1,492 @@ +# Client ID Tests + +Spec points: `RSA7`, `RSA7a`, `RSA7b`, `RSA7c`, `RSA12`, `RSA12a`, `RSA12b`, `RSA15`, `RSA15a`, `RSA15b`, `RSA15c` + +## Test Type +Unit test with mocked HTTP client and/or authCallback + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/rest_client.md` for the full Mock HTTP Infrastructure specification. These tests use the same `MockHttpClient` interface with `PendingConnection` and `PendingRequest`. + +--- + +## RSA7a - clientId from ClientOptions + +**Test ID**: `rest/unit/RSA7a/clientid-from-options-0` + +**Spec requirement:** `clientId` from `ClientOptions` is accessible via `auth.clientId`. + +Tests that `clientId` from `ClientOptions` is accessible via `auth.clientId`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + clientId: "my-client-id" +)) +``` + +### Assertions +```pseudo +ASSERT client.auth.clientId == "my-client-id" +``` + +--- + +## RSA7b - clientId from TokenDetails + +**Test ID**: `rest/unit/RSA7b/clientid-from-token-details-0` + +**Spec requirement:** `clientId` is derived from `TokenDetails` when token auth is used. + +Tests that `clientId` is derived from `TokenDetails` when token auth is used. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + tokenDetails: TokenDetails( + token: "token-with-clientId", + expires: now() + 3600000, + clientId: "token-client-id" + ) +)) +``` + +### Assertions +```pseudo +ASSERT client.auth.clientId == "token-client-id" +``` + +--- + +## RSA7b - clientId from authCallback TokenDetails + +**Test ID**: `rest/unit/RSA7b/clientid-from-callback-token-1` + +**Spec requirement:** `clientId` is extracted from `TokenDetails` returned by `authCallback`. + +Tests that `clientId` is extracted from `TokenDetails` returned by `authCallback`. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authCallback: (params) => TokenDetails( + token: "callback-token", + expires: now() + 3600000, + clientId: "callback-client-id" + ) +)) +``` + +### Test Steps +```pseudo +# Trigger auth by making a request +AWAIT client.channels.get("test").status() +``` + +### Assertions +```pseudo +ASSERT client.auth.clientId == "callback-client-id" +``` + +--- + +## RSA7c - clientId null when unidentified + +**Test ID**: `rest/unit/RSA7c/clientid-null-unidentified-0` + +**Spec requirement:** `auth.clientId` is null when no client identity is established. + +Tests that `auth.clientId` is null when no client identity is established. + +### Setup +```pseudo +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +# No clientId specified +``` + +### Assertions +```pseudo +ASSERT client.auth.clientId IS null +``` + +--- + +## RSA7c - clientId null with unidentified token + +**Test ID**: `rest/unit/RSA7c/clientid-null-unidentified-token-1` + +**Spec requirement:** `auth.clientId` is null when token has no `clientId`. + +Tests that `auth.clientId` is null when token has no `clientId`. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + tokenDetails: TokenDetails( + token: "token-without-clientId", + expires: now() + 3600000 + # No clientId in token + ) +)) +``` + +### Assertions +```pseudo +ASSERT client.auth.clientId IS null +``` + +--- + +## RSA12a - clientId passed to authCallback in TokenParams + +**Test ID**: `rest/unit/RSA12a/clientid-passed-to-callback-0` + +**Spec requirement:** `clientId` is passed to `authCallback` via `TokenParams`. + +Tests that `clientId` is passed to `authCallback` via `TokenParams`. + +### Setup +```pseudo +received_params = [] + +mock_auth_callback = (params) => { + received_params.append(params) + RETURN TokenDetails(token: "tok", expires: now() + 3600000) +} + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authCallback: mock_auth_callback, + clientId: "library-client-id" +)) +``` + +### Test Steps +```pseudo +# Trigger auth +AWAIT client.channels.get("test").status() +``` + +### Assertions +```pseudo +ASSERT received_params.length >= 1 +ASSERT received_params[0].clientId == "library-client-id" +``` + +--- + +## RSA12b - clientId sent to authUrl + +**Test ID**: `rest/unit/RSA12b/clientid-sent-to-authurl-0` + +**Spec requirement:** `clientId` is sent as a parameter when using `authUrl`. + +Tests that `clientId` is sent as a parameter when using `authUrl`. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.url.host == "auth.example.com": + req.respond_with(200, + body: { "token": "url-token", "expires": now() + 3600000 }, + headers: { "Content-Type": "application/json" } + ) + ELSE: + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authUrl: "https://auth.example.com/token", + clientId: "url-client-id" +)) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get("test").status() +``` + +### Assertions +```pseudo +auth_request = captured_requests[0] +ASSERT auth_request.url.host == "auth.example.com" + +# clientId should be in query params (GET) or body (POST) +IF auth_request.method == "GET": + ASSERT auth_request.url.query_params["clientId"] == "url-client-id" +ELSE: + body_params = parse_form_urlencoded(auth_request.body) + ASSERT body_params["clientId"] == "url-client-id" +``` + +--- + +## RSA7 - clientId updated after authorize() + +**Test ID**: `rest/unit/RSA7/clientid-updated-after-authorize-0` + +**Spec requirement:** `auth.clientId` is updated when `authorize()` returns a new token with different `clientId`. + +Tests that `auth.clientId` is updated when `authorize()` returns a new token with different `clientId`. + +### Setup +```pseudo +token_count = 0 + +mock_auth_callback = (params) => { + token_count = token_count + 1 + RETURN TokenDetails( + token: "token-" + str(token_count), + expires: now() + 3600000, + clientId: "client-" + str(token_count) + ) +} + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(authCallback: mock_auth_callback)) +``` + +### Test Steps +```pseudo +# First auth +AWAIT client.channels.get("test").status() + +ASSERT client.auth.clientId == "client-1" + +# Second auth with explicit authorize +AWAIT client.auth.authorize() + +ASSERT client.auth.clientId == "client-2" +``` + +--- + +## RSA12 - Wildcard clientId + +**Test ID**: `rest/unit/RSA12/wildcard-clientid-0` + +**Spec requirement:** Wildcard `*` clientId allows the token to be used with any client identity. + +Tests handling of wildcard `*` clientId. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + tokenDetails: TokenDetails( + token: "wildcard-token", + expires: now() + 3600000, + clientId: "*" # Wildcard + ) +)) +``` + +### Assertions +```pseudo +# Wildcard clientId should be preserved +ASSERT client.auth.clientId == "*" +``` + +### Note +The wildcard `*` clientId allows the token to be used with any client identity. This is a special case where `clientId` on individual operations can vary. + +--- + +## RSA7 - clientId consistency between ClientOptions and token + +**Test ID**: `rest/unit/RSA7/clientid-mismatch-error-1` + +**Spec requirement:** `clientId` in `ClientOptions` must be consistent with token's `clientId` (mismatch is an error). + +Tests that `clientId` in `ClientOptions` is consistent with token's `clientId`. + +### Test Cases + +| ID | ClientOptions clientId | Token clientId | Expected | +|----|----------------------|----------------|----------| +| 1 | `"client-a"` | `"client-a"` | Success | +| 2 | `"client-a"` | `"client-b"` | Error | +| 3 | `"client-a"` | `null` | Success (client keeps explicit) | +| 4 | `"client-a"` | `"*"` | Success (wildcard allows any) | +| 5 | `null` | `"client-b"` | Success (inherit from token) | + +### Setup (Case 2 - Mismatch should error) +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + clientId: "client-a", + tokenDetails: TokenDetails( + token: "mismatched-token", + expires: now() + 3600000, + clientId: "client-b" # Different from ClientOptions + ) +)) +``` + +### Test Steps (Case 2) +```pseudo +AWAIT client.channels.get("test").status() FAILS WITH error # Or any operation requiring auth +ASSERT error.message CONTAINS "clientId" OR error.message CONTAINS "mismatch" +``` + +### Note +The exact timing of mismatch detection (constructor vs first use) may vary by implementation. The key requirement is that the mismatch is detected and reported as an error. + +--- + +## RSA15a - Token clientId must match ClientOptions clientId + +**Test ID**: `rest/unit/RSA15a/token-clientid-must-match-0` + +**Spec requirement:** Any `clientId` provided in `ClientOptions` must match any non-wildcard `clientId` value in `TokenDetails`. + +This is tested by the RSA7 consistency test above (cases 1 and 2). When Token Auth is used and both `ClientOptions.clientId` and `TokenDetails.clientId` are set to non-wildcard values, they must match. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +# Matching case — should succeed +client_match = Rest(options: ClientOptions( + clientId: "my-client", + tokenDetails: TokenDetails( + token: "matching-token", + expires: now() + 3600000, + clientId: "my-client" + ) +)) + +# Mismatching case — should error +ASSERT Rest(options: ClientOptions( + clientId: "my-client", + tokenDetails: TokenDetails( + token: "mismatched-token", + expires: now() + 3600000, + clientId: "other-client" + ) +)) THROWS error +``` + +### Assertions +```pseudo +ASSERT client_match.auth.clientId == "my-client" +ASSERT error.code == 40102 +``` + +--- + +## RSA15b - Wildcard token clientId permits any ClientOptions clientId + +**Test ID**: `rest/unit/RSA15b/wildcard-token-permits-any-0` + +**Spec requirement:** If the `clientId` from `TokenDetails` is a wildcard string `'*'`, then the client is permitted to be either unidentified or identified by providing a `clientId`. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +# Wildcard token with explicit clientId — should succeed +client = Rest(options: ClientOptions( + clientId: "any-client", + tokenDetails: TokenDetails( + token: "wildcard-token", + expires: now() + 3600000, + clientId: "*" + ) +)) +``` + +### Assertions +```pseudo +# No error thrown — wildcard allows any clientId +ASSERT client.auth.clientId == "any-client" +``` + +--- + +## RSA15c - Incompatible clientId results in error (REST) or FAILED (Realtime) + +**Test ID**: `rest/unit/RSA15c/incompatible-clientid-error-0` + +**Spec requirement:** Following an auth request which uses a `TokenDetails` that contains an incompatible `clientId`, the library should in the case of REST result in an appropriate error response, and in the case of Realtime transition the connection state to `FAILED`. + +### REST case + +See RSA15a mismatch case above — the REST client raises an error with code 40102. + +### Realtime case + +See `realtime/integration/auth.md` RSA7 test — the Realtime client transitions to FAILED state when a token with a mismatched clientId is used. diff --git a/uts/rest/unit/auth/revoke_tokens.md b/uts/rest/unit/auth/revoke_tokens.md new file mode 100644 index 000000000..39d718f60 --- /dev/null +++ b/uts/rest/unit/auth/revoke_tokens.md @@ -0,0 +1,740 @@ +# Revoke Tokens Tests + +Spec points: `RSA17`, `RSA17b`, `RSA17c`, `RSA17d`, `RSA17e`, `RSA17f`, `RSA17g`, `BAR2`, `TRS2`, `TRF2` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. These tests use the same `MockHttpClient` interface with `PendingConnection` and `PendingRequest`. + +## Server Response Format + +With `X-Ably-Version >= 3` (sent by all current SDKs), the server returns a +`BatchResult` envelope for all batch responses: + +```json +{ + "successCount": 1, + "failureCount": 1, + "results": [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 }, + { "target": "invalidType:abc", "error": { "code": 40000, "statusCode": 400, "message": "..." } } + ] +} +``` + +- **All success** returns HTTP 201 with this format. +- **Mixed success/failure** returns HTTP 201 with this format (not HTTP 400). +- **Server-level errors** (HTTP 500, 401, etc.) return an error object: `{"error": {...}}`. + +The SDK passes through this response directly — no client-side normalisation +is needed because the server provides `successCount`, `failureCount`, and `results`. + +**Legacy format (no version header):** Without `X-Ably-Version`, the server +returns a plain array for all-success and `{error, batchResponse}` for mixed +results (HTTP 400). This format is not used by current SDKs. + +--- + +## RSA17g - revokeTokens sends POST to /keys/{keyName}/revokeTokens + +**Spec requirement:** `Auth#revokeTokens` takes a `TokenRevocationTargetSpecifier` or +an array of `TokenRevocationTargetSpecifier`s and sends them in a POST request to +`/keys/{API_KEY_NAME}/revokeTokens`, where `API_KEY_NAME` is the API key name +obtained by reading `AuthOptions#key` up until the first `:` character. + +### RSA17g_1 - Sends POST request to correct path + +**Test ID**: `rest/unit/RSA17g/sends-post-correct-path-0` + +**Spec requirement:** revokeTokens sends a POST request to `/keys/{keyName}/revokeTokens`. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice") +]) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +ASSERT captured_requests[0].method == "POST" +ASSERT captured_requests[0].url.path == "/keys/appId.keyName/revokeTokens" +``` + +--- + +## RSA17b - Target specifiers mapped to type:value strings + +**Spec requirement:** The `TokenRevocationTargetSpecifier`s should be mapped to +strings by joining the `type` and `value` with a `:` character and sent in the +`targets` field of the request body. + +### RSA17b_1 - Single specifier sent as targets array + +**Test ID**: `rest/unit/RSA17b/single-specifier-targets-0` + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice") +]) +``` + +### Assertions +```pseudo +request_body = JSON_PARSE(captured_requests[0].body) +ASSERT request_body["targets"] == ["clientId:alice"] +``` + +### RSA17b_2 - Multiple specifiers with different types + +**Test ID**: `rest/unit/RSA17b/multiple-specifier-types-1` + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 }, + { "target": "revocationKey:group-1", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 }, + { "target": "channel:secret", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice"), + TokenRevocationTargetSpecifier(type: "revocationKey", value: "group-1"), + TokenRevocationTargetSpecifier(type: "channel", value: "secret") +]) +``` + +### Assertions +```pseudo +request_body = JSON_PARSE(captured_requests[0].body) +ASSERT request_body["targets"] == ["clientId:alice", "revocationKey:group-1", "channel:secret"] +``` + +--- + +## RSA17c - Returns BatchResult + +| Spec | Requirement | +|------|-------------| +| RSA17c | Returns a `BatchResult` | +| BAR2a | `successCount` - the number of successful operations | +| BAR2b | `failureCount` - the number of unsuccessful operations | +| BAR2c | `results` - an array of results | + +### RSA17c_1 - All success result + +**Test ID**: `rest/unit/RSA17c/all-success-result-0` + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 }, + { "target": "clientId:bob", "issuedBefore": 1700000000000, "appliesAt": 1700000002000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice"), + TokenRevocationTargetSpecifier(type: "clientId", value: "bob") +]) +``` + +### Assertions +```pseudo +ASSERT result.successCount == 2 +ASSERT result.failureCount == 0 +ASSERT result.results.length == 2 +``` + +### RSA17c_2 - Mixed success and failure result + +**Test ID**: `rest/unit/RSA17c/mixed-success-failure-1` + +**Spec requirement:** When the server returns a mix of successes and failures, +the response is HTTP 200 with a `BatchResult` envelope. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { + "successCount": 1, + "failureCount": 1, + "results": [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 }, + { "target": "invalidType:abc", "error": { "code": 40000, "statusCode": 400, "message": "Invalid target type" } } + ] + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice"), + TokenRevocationTargetSpecifier(type: "invalidType", value: "abc") +]) +``` + +### Assertions +```pseudo +ASSERT result.successCount == 1 +ASSERT result.failureCount == 1 +ASSERT result.results.length == 2 +``` + +### RSA17c_3 - All failure result + +**Test ID**: `rest/unit/RSA17c/all-failure-result-2` + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { + "successCount": 0, + "failureCount": 2, + "results": [ + { "target": "invalidType:foo", "error": { "code": 40000, "statusCode": 400, "message": "Invalid target type" } }, + { "target": "invalidType:bar", "error": { "code": 40000, "statusCode": 400, "message": "Invalid target type" } } + ] + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "invalidType", value: "foo"), + TokenRevocationTargetSpecifier(type: "invalidType", value: "bar") +]) +``` + +### Assertions +```pseudo +ASSERT result.successCount == 0 +ASSERT result.failureCount == 2 +ASSERT result.results.length == 2 +``` + +--- + +## TRS2 - TokenRevocationSuccessResult attributes + +| Spec | Requirement | +|------|-------------| +| TRS2a | `target` string - the target specifier | +| TRS2b | `appliesAt` Time - timestamp at which the revocation takes effect | +| TRS2c | `issuedBefore` Time - timestamp for which previously issued tokens are revoked | + +### TRS2_1 - Success result contains target, appliesAt, and issuedBefore + +**Test ID**: `rest/unit/TRS2/success-result-attributes-0` + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice") +]) +``` + +### Assertions +```pseudo +success = result.results[0] +ASSERT success IS TokenRevocationSuccessResult +ASSERT success.target == "clientId:alice" +ASSERT success.issuedBefore == 1700000000000 +ASSERT success.appliesAt == 1700000001000 +``` + +--- + +## TRF2 - TokenRevocationFailureResult attributes + +| Spec | Requirement | +|------|-------------| +| TRF2a | `target` string - the target specifier | +| TRF2b | `error` ErrorInfo - reason the revocation failed | + +### TRF2_1 - Failure result contains target and error + +**Test ID**: `rest/unit/TRF2/failure-result-attributes-0` + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { + "successCount": 0, + "failureCount": 1, + "results": [ + { "target": "invalidType:abc", "error": { "code": 40000, "statusCode": 400, "message": "Invalid target type" } } + ] + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "invalidType", value: "abc") +]) +``` + +### Assertions +```pseudo +failure = result.results[0] +ASSERT failure IS TokenRevocationFailureResult +ASSERT failure.target == "invalidType:abc" +ASSERT failure.error IS ErrorInfo +ASSERT failure.error.code == 40000 +ASSERT failure.error.statusCode == 400 +ASSERT failure.error.message CONTAINS "Invalid target type" +``` + +--- + +## RSA17d - Token auth clients cannot revoke tokens + +**Spec requirement:** If called from a client using token authentication, should +raise an `ErrorInfo` with a `40162` error code and `401` status code. This is a +client-side check — no HTTP request is made. + +### RSA17d_1 - Token auth client fails with 40162 + +**Test ID**: `rest/unit/RSA17d/token-auth-revoke-rejected-0` + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(token: "a.token.string")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice") +]) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40162 +ASSERT error.statusCode == 401 + +# No HTTP request should have been made +ASSERT captured_requests.length == 0 +``` + +### RSA17d_2 - Token auth via useTokenAuth flag fails with 40162 + +**Test ID**: `rest/unit/RSA17d/use-token-auth-revoke-rejected-1` + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret", useTokenAuth: true)) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice") +]) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40162 +ASSERT error.statusCode == 401 +ASSERT captured_requests.length == 0 +``` + +--- + +## RSA17e - Optional issuedBefore parameter + +**Spec requirement:** Accepts an optional `issuedBefore` timestamp, represented as +milliseconds since the epoch, which is included in the request body. + +### RSA17e_1 - issuedBefore included in request body + +**Test ID**: `rest/unit/RSA17e/issued-before-included-0` + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1699999000000, "appliesAt": 1700000001000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens( + [TokenRevocationTargetSpecifier(type: "clientId", value: "alice")], + options: { issuedBefore: 1699999000000 } +) +``` + +### Assertions +```pseudo +request_body = JSON_PARSE(captured_requests[0].body) +ASSERT request_body["issuedBefore"] == 1699999000000 +``` + +### RSA17e_2 - issuedBefore omitted when not provided + +**Test ID**: `rest/unit/RSA17e/issued-before-omitted-1` + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice") +]) +``` + +### Assertions +```pseudo +request_body = JSON_PARSE(captured_requests[0].body) +ASSERT "issuedBefore" NOT IN request_body +``` + +--- + +## RSA17f - Optional allowReauthMargin parameter + +**Spec requirement:** If an `allowReauthMargin` boolean is supplied, it should be +included in the `allowReauthMargin` field of the request body. + +### RSA17f_1 - allowReauthMargin included when true + +**Test ID**: `rest/unit/RSA17f/reauth-margin-included-0` + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000030000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens( + [TokenRevocationTargetSpecifier(type: "clientId", value: "alice")], + options: { allowReauthMargin: true } +) +``` + +### Assertions +```pseudo +request_body = JSON_PARSE(captured_requests[0].body) +ASSERT request_body["allowReauthMargin"] == true +``` + +### RSA17f_2 - allowReauthMargin omitted when not provided + +**Test ID**: `rest/unit/RSA17f/reauth-margin-omitted-1` + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice") +]) +``` + +### Assertions +```pseudo +request_body = JSON_PARSE(captured_requests[0].body) +ASSERT "allowReauthMargin" NOT IN request_body +``` + +### RSA17f_3 - Both issuedBefore and allowReauthMargin together + +**Test ID**: `rest/unit/RSA17f/both-options-together-2` + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1699999000000, "appliesAt": 1700000030000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens( + [TokenRevocationTargetSpecifier(type: "clientId", value: "alice")], + options: { issuedBefore: 1699999000000, allowReauthMargin: true } +) +``` + +### Assertions +```pseudo +request_body = JSON_PARSE(captured_requests[0].body) +ASSERT request_body["targets"] == ["clientId:alice"] +ASSERT request_body["issuedBefore"] == 1699999000000 +ASSERT request_body["allowReauthMargin"] == true +``` + +--- + +## Error handling + +### RSA17_Error_1 - Server error is propagated as an error + +**Test ID**: `rest/unit/RSA17/server-error-propagated-0` + +**Spec requirement:** A server-level error (e.g. 500) for the entire request +is propagated as an error, not a per-target failure. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(500, { + "error": { "code": 50000, "statusCode": 500, "message": "Internal error" } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice") +]) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 50000 +ASSERT error.statusCode == 500 +``` + +--- + +## Request authentication + +### RSA17_Auth_1 - Request uses Basic authentication + +**Test ID**: `rest/unit/RSA17/request-uses-basic-auth-0` + +**Spec requirement:** revokeTokens requires key-based auth (RSA17d rejects token +auth). The POST request uses the client's configured Basic authentication. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice") +]) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].headers["Authorization"] STARTS WITH "Basic " +``` diff --git a/uts/rest/unit/auth/token_details.md b/uts/rest/unit/auth/token_details.md new file mode 100644 index 000000000..74add4919 --- /dev/null +++ b/uts/rest/unit/auth/token_details.md @@ -0,0 +1,621 @@ +# Auth.tokenDetails Tests + +Spec points: `RSA16`, `RSA16a`, `RSA16b`, `RSA16c`, `RSA16d` + +## Test Type +Unit test with mocked HTTP client and/or mocked authCallback + +## Overview + +`Auth#tokenDetails` is a property that holds the `TokenDetails` representing the token currently in use by the library. These tests verify: +- It holds the current token when using token auth +- It handles tokens provided as strings (without full TokenDetails) +- It is updated on authorize() and library-initiated renewals +- It is null when using basic auth or when no valid token exists + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/rest_client.md` for the full Mock HTTP Infrastructure specification. + +--- + +## RSA16a - tokenDetails holds current token + +**Spec requirement:** `Auth#tokenDetails` holds a `TokenDetails` representing the token currently in use by the library, if any. + +### Test: tokenDetails reflects token from authCallback + +**Test ID**: `rest/unit/RSA16a/token-from-callback-0` + +#### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authCallback: (params) => TokenDetails( + token: "callback-token-abc", + expires: now() + 3600000, + issued: now(), + clientId: "my-client" + ) +)) +``` + +#### Test Steps +```pseudo +# Force token acquisition by making a request +AWAIT client.channels.get("test").status() +``` + +#### Assertions +```pseudo +ASSERT client.auth.tokenDetails IS NOT null +ASSERT client.auth.tokenDetails.token == "callback-token-abc" +ASSERT client.auth.tokenDetails.clientId == "my-client" +ASSERT client.auth.tokenDetails.expires IS NOT null +ASSERT client.auth.tokenDetails.issued IS NOT null +``` + +--- + +### Test: tokenDetails reflects token from requestToken + +**Test ID**: `rest/unit/RSA16a/token-from-request-token-1` + +#### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + IF req.path matches "/keys/.*/requestToken": + req.respond_with(200, { + "token": "requested-token-xyz", + "expires": now() + 3600000, + "issued": now(), + "keyName": "appId.keyId", + "clientId": "token-client" + }) + ELSE: + req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +#### Test Steps +```pseudo +# Explicitly authorize to get a token +AWAIT client.auth.authorize() +``` + +#### Assertions +```pseudo +ASSERT client.auth.tokenDetails IS NOT null +ASSERT client.auth.tokenDetails.token == "requested-token-xyz" +ASSERT client.auth.tokenDetails.clientId == "token-client" +``` + +--- + +## RSA16b - tokenDetails with token string only + +**Spec requirement:** If the library is provided with a token without the corresponding `TokenDetails`, then `tokenDetails` holds a `TokenDetails` instance in which only the `token` attribute is populated with that token string. + +### Test: tokenDetails created from token string in ClientOptions + +**Test ID**: `rest/unit/RSA16b/token-string-in-options-0` + +#### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) +) +install_mock(mock_http) + +# Provide only a token string, not full TokenDetails +client = Rest(options: ClientOptions(token: "standalone-token-string")) +``` + +#### Test Steps +```pseudo +# Access tokenDetails immediately after construction +token_details = client.auth.tokenDetails +``` + +#### Assertions +```pseudo +ASSERT token_details IS NOT null +ASSERT token_details.token == "standalone-token-string" +# Other fields should be null since we only had the token string +ASSERT token_details.expires IS null +ASSERT token_details.issued IS null +ASSERT token_details.clientId IS null +ASSERT token_details.capability IS null +``` + +--- + +### Test: tokenDetails created from token string in authCallback + +**Test ID**: `rest/unit/RSA16b/token-string-from-callback-1` + +#### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) +) +install_mock(mock_http) + +# authCallback returns just a token string, not TokenDetails +client = Rest(options: ClientOptions( + authCallback: (params) => "just-a-token-string" +)) +``` + +#### Test Steps +```pseudo +# Force token acquisition +AWAIT client.channels.get("test").status() +``` + +#### Assertions +```pseudo +ASSERT client.auth.tokenDetails IS NOT null +ASSERT client.auth.tokenDetails.token == "just-a-token-string" +# Other fields should be null +ASSERT client.auth.tokenDetails.expires IS null +ASSERT client.auth.tokenDetails.issued IS null +``` + +--- + +## RSA16c - tokenDetails updated on token changes + +**Spec requirement:** `tokenDetails` is set with the current token (if applicable) on instantiation and each time it is replaced, whether the result of an explicit `Auth#authorize` operation, or a library-initiated renewal resulting from expiry or a token error response. + +### Test: tokenDetails set on instantiation with tokenDetails option + +**Test ID**: `rest/unit/RSA16c/set-on-instantiation-0` + +#### Setup +```pseudo +initial_token = TokenDetails( + token: "initial-token", + expires: now() + 3600000, + issued: now(), + clientId: "initial-client" +) + +client = Rest(options: ClientOptions(tokenDetails: initial_token)) +``` + +#### Test Steps +```pseudo +# Access tokenDetails immediately after construction +token_details = client.auth.tokenDetails +``` + +#### Assertions +```pseudo +ASSERT token_details IS NOT null +ASSERT token_details.token == "initial-token" +ASSERT token_details.clientId == "initial-client" +``` + +--- + +### Test: tokenDetails updated after explicit authorize() + +**Test ID**: `rest/unit/RSA16c/updated-after-authorize-1` + +#### Setup +```pseudo +token_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authCallback: (params) => { + token_count = token_count + 1 + RETURN TokenDetails( + token: "token-v" + str(token_count), + expires: now() + 3600000, + clientId: "client-v" + str(token_count) + ) + } +)) +``` + +#### Test Steps +```pseudo +# First authorize +AWAIT client.auth.authorize() +first_token = client.auth.tokenDetails + +# Second authorize +AWAIT client.auth.authorize() +second_token = client.auth.tokenDetails +``` + +#### Assertions +```pseudo +ASSERT first_token.token == "token-v1" +ASSERT first_token.clientId == "client-v1" + +ASSERT second_token.token == "token-v2" +ASSERT second_token.clientId == "client-v2" + +# Verify it's actually updated, not the same object +ASSERT first_token.token != second_token.token +``` + +--- + +### Test: tokenDetails updated after library-initiated renewal on expiry + +**Test ID**: `rest/unit/RSA16c/updated-after-expiry-renewal-2` + +#### Setup +```pseudo +test_clock = TestClock() +token_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) +) +install_mock(mock_http) + +WITH_CLOCK(test_clock): + client = Rest(options: ClientOptions( + authCallback: (params) => { + token_count = token_count + 1 + RETURN TokenDetails( + token: "token-v" + str(token_count), + expires: test_clock.now() + 1000, # Expires in 1 second + clientId: "client-v" + str(token_count) + ) + } + )) +``` + +#### Test Steps +```pseudo +WITH_CLOCK(test_clock): + # First request - gets initial token + AWAIT client.channels.get("test").status() + first_token = client.auth.tokenDetails + + # Advance time past token expiry + test_clock.advance(2000 milliseconds) + + # Second request - should trigger renewal + AWAIT client.channels.get("test").status() + second_token = client.auth.tokenDetails +``` + +#### Assertions +```pseudo +ASSERT first_token.token == "token-v1" +ASSERT second_token.token == "token-v2" +``` + +--- + +### Test: tokenDetails updated after library-initiated renewal on 40142 error + +**Test ID**: `rest/unit/RSA16c/updated-after-40142-renewal-3` + +#### Setup +```pseudo +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count = request_count + 1 + IF request_count == 1: + # First request fails with token expired error + req.respond_with(401, { + "error": { + "code": 40142, + "statusCode": 401, + "message": "Token expired" + } + }) + ELSE: + # Subsequent requests succeed + req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) + } +) +install_mock(mock_http) + +token_count = 0 + +client = Rest(options: ClientOptions( + authCallback: (params) => { + token_count = token_count + 1 + RETURN TokenDetails( + token: "token-v" + str(token_count), + expires: now() + 3600000, + clientId: "client-v" + str(token_count) + ) + } +)) +``` + +#### Test Steps +```pseudo +# First get a token +AWAIT client.auth.authorize() +first_token = client.auth.tokenDetails + +# Make a request that will fail with 40142, triggering renewal +AWAIT client.channels.get("test").status() +second_token = client.auth.tokenDetails +``` + +#### Assertions +```pseudo +ASSERT first_token.token == "token-v1" +ASSERT second_token.token == "token-v2" +``` + +--- + +## RSA16d - tokenDetails is null when appropriate + +**Spec requirement:** `tokenDetails` is `null` if there is no current token, including after a previous token has been determined to be invalid or expired, or if the library is using basic auth. + +### Test: tokenDetails is null when using basic auth + +**Test ID**: `rest/unit/RSA16d/null-with-basic-auth-0` + +#### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) +) +install_mock(mock_http) + +# Client with only API key - uses basic auth +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +#### Test Steps +```pseudo +# Make a request using basic auth (no token) +AWAIT client.channels.get("test").status() +``` + +#### Assertions +```pseudo +# Should be null because we're using basic auth, not token auth +ASSERT client.auth.tokenDetails IS null +``` + +--- + +### Test: tokenDetails is null before any token is obtained + +**Test ID**: `rest/unit/RSA16d/null-before-token-obtained-1` + +#### Setup +```pseudo +# Client configured for token auth but no request made yet +client = Rest(options: ClientOptions( + authCallback: (params) => TokenDetails( + token: "my-token", + expires: now() + 3600000 + ) +)) +``` + +#### Test Steps +```pseudo +# Don't make any requests - just check tokenDetails +token_details = client.auth.tokenDetails +``` + +#### Assertions +```pseudo +# Should be null because no token has been obtained yet +ASSERT token_details IS null +``` + +--- + +### Test: tokenDetails is null after token invalidation + +**Test ID**: `rest/unit/RSA16d/null-after-invalidation-2` + +**Note:** This test verifies behavior when a token error occurs and cannot be renewed (e.g., authCallback fails). + +#### Setup +```pseudo +callback_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + # Always fail with token error + req.respond_with(401, { + "error": { + "code": 40142, + "statusCode": 401, + "message": "Token expired" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authCallback: (params) => { + callback_count = callback_count + 1 + IF callback_count == 1: + RETURN TokenDetails(token: "first-token", expires: now() + 3600000) + ELSE: + # Second callback fails - cannot renew + THROW AblyException("Cannot obtain new token") + } +)) +``` + +#### Test Steps +```pseudo +# First authorize succeeds +AWAIT client.auth.authorize() +ASSERT client.auth.tokenDetails IS NOT null +ASSERT client.auth.tokenDetails.token == "first-token" + +# Make a request that fails with 40142 +# Renewal will be attempted but will fail +AWAIT client.channels.get("test").status() FAILS WITH error +# Expected to fail - error is expected +``` + +#### Assertions +```pseudo +# After failed renewal, tokenDetails should be null +# (the old token is invalid and we couldn't get a new one) +ASSERT client.auth.tokenDetails IS null +``` + +--- + +### Test: tokenDetails is null after switching from token to basic auth + +**Test ID**: `rest/unit/RSA16d/null-after-switch-to-basic-3` + +**Note:** This tests the case where a client is reconfigured to use basic auth after having used token auth. + +#### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authCallback: (params) => TokenDetails( + token: "my-token", + expires: now() + 3600000 + ) +)) +``` + +#### Test Steps +```pseudo +# First use token auth +AWAIT client.auth.authorize() +ASSERT client.auth.tokenDetails IS NOT null + +# Now authorize with basic auth (providing key in authOptions) +AWAIT client.auth.authorize( + authOptions: AuthOptions( + key: "appId.keyId:keySecret", + useTokenAuth: false + ) +) +``` + +#### Assertions +```pseudo +# After switching to basic auth, tokenDetails should be null +ASSERT client.auth.tokenDetails IS null +``` + +--- + +## Edge Cases + +### Test: tokenDetails preserved across multiple successful requests + +**Test ID**: `rest/unit/RSA16a/preserved-across-requests-0` + +#### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authCallback: (params) => TokenDetails( + token: "stable-token", + expires: now() + 3600000, + clientId: "stable-client" + ) +)) +``` + +#### Test Steps +```pseudo +# Make multiple requests +AWAIT client.channels.get("test").status() +first_check = client.auth.tokenDetails + +AWAIT client.channels.get("test").status() +second_check = client.auth.tokenDetails + +AWAIT client.channels.get("test").status() +third_check = client.auth.tokenDetails +``` + +#### Assertions +```pseudo +# Token should remain the same across requests (not re-fetched) +ASSERT first_check.token == "stable-token" +ASSERT second_check.token == "stable-token" +ASSERT third_check.token == "stable-token" +``` + +--- + +### Test: tokenDetails reflects capability from token + +**Test ID**: `rest/unit/RSA16a/reflects-capability-1` + +#### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authCallback: (params) => TokenDetails( + token: "capable-token", + expires: now() + 3600000, + capability: '{"channel1":["publish","subscribe"],"channel2":["subscribe"]}' + ) +)) +``` + +#### Test Steps +```pseudo +AWAIT client.channels.get("test").status() +``` + +#### Assertions +```pseudo +ASSERT client.auth.tokenDetails IS NOT null +ASSERT client.auth.tokenDetails.capability == '{"channel1":["publish","subscribe"],"channel2":["subscribe"]}' +``` diff --git a/uts/rest/unit/auth/token_renewal.md b/uts/rest/unit/auth/token_renewal.md new file mode 100644 index 000000000..996a8b297 --- /dev/null +++ b/uts/rest/unit/auth/token_renewal.md @@ -0,0 +1,597 @@ +# Token Renewal Tests + +Spec points: `RSA4a2`, `RSA4b`, `RSA4b1`, `RSC10` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/rest_client.md` for the full Mock HTTP Infrastructure specification. These tests use the same `MockHttpClient` interface with `PendingConnection` and `PendingRequest`. + +## Purpose + +These tests verify that the library correctly handles token expiry and triggers renewal when: +1. A token is known to be expired before a request +2. A request is rejected by the server due to token expiry + +--- + +## RSA4b - Token renewal on expiry rejection + +**Test ID**: `rest/unit/RSA4b/renewal-on-40142-0` + +**Spec requirement:** When a request is rejected with error code 40142 (token expired), the library must obtain a new token via the auth callback and retry the request automatically. + +Tests that when a request is rejected with a token expiry error, the library obtains a new token and retries. + +### Setup +```pseudo +callback_count = 0 +tokens = ["first-token", "second-token"] +captured_requests = [] +request_count = 0 + +auth_callback = FUNCTION(params): + token = tokens[callback_count] + callback_count = callback_count + 1 + RETURN TokenDetails( + token: token, + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + request_count++ + IF request_count == 1: + # First request fails with token expired + req.respond_with(401, { + "error": { + "code": 40142, + "statusCode": 401, + "message": "Token expired" + } + }) + ELSE: + # Second request (after renewal) succeeds + req.respond_with(200, [{"channel": "test"}]) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get("test").history() +``` + +### Assertions +```pseudo +# authCallback was called twice (initial + renewal) +ASSERT callback_count == 2 + +# Two HTTP requests were made +ASSERT request_count == 2 + +# First request used first token +ASSERT captured_requests[0].headers["Authorization"] == "Bearer first-token" + +# Second request used renewed token +ASSERT captured_requests[1].headers["Authorization"] == "Bearer second-token" + +# Final result is successful +ASSERT result.items IS List +``` + +--- + +## RSA4b - Token renewal on 40140 error + +**Test ID**: `rest/unit/RSA4b/renewal-on-40140-1` + +**Spec requirement:** Token renewal must also be triggered for error code 40140 (token error), not just 40142 (token expired). + +Tests renewal is triggered for error code 40140 (token error). + +### Setup +```pseudo +callback_count = 0 +request_count = 0 + +auth_callback = FUNCTION(params): + callback_count = callback_count + 1 + RETURN TokenDetails( + token: "token-" + callback_count, + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count++ + IF request_count == 1: + # First attempt fails with 40140 + req.respond_with(401, { + "error": { + "code": 40140, + "statusCode": 401, + "message": "Token error" + } + }) + ELSE: + # Retry succeeds + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get("test").history() +``` + +### Assertions +```pseudo +ASSERT callback_count == 2 +ASSERT request_count == 2 +``` + +--- + +## RSA4b1 - Pre-emptive token renewal + +**Test ID**: `rest/unit/RSA4b1/preemptive-renewal-0` + +**Spec requirement:** If a token is known to be expired before making a request, renewal must happen pre-emptively without first making a failing request. + +Tests that if a token is known to be expired before making a request, renewal happens without first making a failing request. + +### Setup +```pseudo +callback_count = 0 +captured_requests = [] + +auth_callback = FUNCTION(params): + callback_count = callback_count + 1 + IF callback_count == 1: + # First token is already expired + RETURN TokenDetails( + token: "expired-token", + expires: now() - 1000 # Already expired + ) + ELSE: + RETURN TokenDetails( + token: "fresh-token", + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + # Only success response (no 401 expected) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +# Force initial token acquisition +AWAIT client.auth.authorize() + +# This should detect expired token and renew before request +AWAIT client.channels.get("test").history() +``` + +### Assertions +```pseudo +# Callback was called twice (initial + pre-emptive renewal) +ASSERT callback_count == 2 + +# Only ONE HTTP request to the API (history) +# No failed request with expired token +requests_to_history = captured_requests.filter( + r => r.path == "/channels/test/messages" +) +ASSERT requests_to_history.length == 1 +ASSERT requests_to_history[0].headers["Authorization"] == "Bearer fresh-token" +``` + +--- + +## RSA4a2 - No renewal without authCallback + +**Test ID**: `rest/unit/RSA4a2/no-renewal-without-callback-0` + +**Spec requirement:** Token renewal is not attempted if no renewal mechanism (authCallback/authUrl/key) is available. + +Tests that token renewal is not attempted if no renewal mechanism is available. + +### Setup +```pseudo +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count++ + req.respond_with(401, { + "error": { + "code": 40142, + "statusCode": 401, + "message": "Token expired" + } + }) + } +) +install_mock(mock_http) + +# Client with explicit token but no authCallback +client = Rest( + options: ClientOptions(token: "static-token") +) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get("test").history() FAILS WITH error +ASSERT error.code == 40171 +``` + +### Assertions +```pseudo +# Only one request was made (no retry) +ASSERT request_count == 1 +``` + +--- + +## RSA4b - Renewal with authUrl + +**Test ID**: `rest/unit/RSA4b/renewal-via-authurl-2` + +**Spec requirement:** Token renewal must work via authUrl when a request is rejected with error code 40142. + +Tests that token renewal works via authUrl. + +### Setup +```pseudo +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + request_count++ + + IF req.url.host == "example.com": + # authUrl requests - return tokens + IF request_count == 1: + req.respond_with(200, { + "token": "first-token", + "expires": now() + 3600000 + }) + ELSE: + # Second token request (renewal) + req.respond_with(200, { + "token": "second-token", + "expires": now() + 3600000 + }) + ELSE: + # API requests + IF request_count == 2: + # First API request fails + req.respond_with(401, { + "error": {"code": 40142, "message": "Token expired"} + }) + ELSE: + # Retry succeeds + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + authUrl: "https://example.com/auth" + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get("test").history() +``` + +### Assertions +```pseudo +# Two requests to authUrl +auth_requests = captured_requests.filter( + r => r.url.host == "example.com" +) +ASSERT auth_requests.length == 2 + +# Two requests to Ably API +api_requests = captured_requests.filter( + r => r.url.host != "example.com" +) +ASSERT api_requests.length == 2 + +# Second API request used renewed token +ASSERT api_requests[1].headers["Authorization"] == "Bearer second-token" +``` + +--- + +## RSA4b - Renewal limit + +**Test ID**: `rest/unit/RSA4b/renewal-limit-no-loop-3` + +**Spec requirement:** Token renewal must not loop infinitely if server keeps rejecting tokens. + +Tests that token renewal doesn't loop infinitely if server keeps rejecting. + +### Setup +```pseudo +callback_count = 0 +request_count = 0 + +auth_callback = FUNCTION(params): + callback_count = callback_count + 1 + RETURN TokenDetails( + token: "token-" + callback_count, + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count++ + # Always return token expired + req.respond_with(401, { + "error": {"code": 40142, "message": "Token expired"} + }) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get("test").history() FAILS WITH error +# Should eventually give up +ASSERT error.code == 40142 +``` + +### Assertions +```pseudo +# The library MUST retry at most once per original request (one renewal +# attempt). After the renewed token is also rejected, the error is +# propagated to the caller. +ASSERT callback_count == 2 # Initial token + one renewal +ASSERT request_count == 2 # Original request + one retry +``` + +--- + +## RSC10 - REST request retried after token renewal + +**Test ID**: `rest/unit/RSC10/request-retried-after-renewal-0` + +**Spec requirement:** If a REST request responds with a token error (401 HTTP status code and an Ably error value 40140 <= code < 40150), then the Auth class is responsible for reissuing a token and the request should be reattempted. + +This test verifies the end-to-end flow at the HTTP client level: the original REST API call is transparently retried after the token is renewed, and the caller receives the successful result without knowing a renewal occurred. + +Note: The RSA4b tests above verify the auth renewal mechanism in isolation. This RSC10 test verifies the HTTP client's retry behaviour wrapping that mechanism. + +### Setup +```pseudo +callback_count = 0 +captured_requests = [] + +auth_callback = FUNCTION(params): + callback_count = callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(callback_count), + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.headers["Authorization"] == "Bearer token-1": + # First token is rejected + req.respond_with(401, { + "error": { + "code": 40142, + "statusCode": 401, + "message": "Token expired" + } + }) + ELSE: + # Renewed token succeeds — return channel status + req.respond_with(200, { + "channelId": "test", + "status": {"isActive": true, "occupancy": {"metrics": {"connections": 0}}} + }) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +# Call channel.status() — the caller should not see the 401/renewal +result = AWAIT client.channels.get("test").status() +``` + +### Assertions +```pseudo +# The call succeeded transparently +ASSERT result IS ChannelDetails + +# Two HTTP requests were made to /channels/test (original + retry) +channel_requests = captured_requests.filter(r => r.path == "/channels/test") +ASSERT channel_requests.length == 2 + +# Auth callback was called twice (initial token + renewal) +ASSERT callback_count == 2 + +# First request used first token, second used renewed token +ASSERT channel_requests[0].headers["Authorization"] == "Bearer token-1" +ASSERT channel_requests[1].headers["Authorization"] == "Bearer token-2" +``` + +--- + +## RSC10b - Non-token 401 errors MUST NOT trigger token renewal + +**Test ID**: `rest/unit/RSC10b/non-token-401-no-renewal-0` + +**Spec requirement:** Only errors with codes in the range 40140–40149 trigger token renewal. Other 401 errors (e.g. 40100 Unauthorized) MUST be propagated immediately without any renewal or retry attempt. + +### Setup +```pseudo +callback_count = 0 +request_count = 0 + +auth_callback = FUNCTION(params): + callback_count = callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(callback_count), + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count = request_count + 1 + # Return a 401 with a non-token error code + req.respond_with(401, { + "error": { + "code": 40100, + "statusCode": 401, + "message": "Unauthorized" + } + }) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get("test").status() FAILS WITH error +ASSERT error.code == 40100 +``` + +### Assertions +```pseudo +# Only one HTTP request — no retry +ASSERT request_count == 1 + +# Auth callback was called once (initial token only, no renewal) +ASSERT callback_count == 1 +``` + +--- + +## RSA4b - Token renewal with MessagePack error response + +**Test ID**: `rest/unit/RSA4b/renewal-msgpack-response-4` + +**Spec requirement:** Token renewal must work correctly when the server returns the 401 token-error response in MessagePack format (which is the default when `useBinaryProtocol: true`). The SDK must decode the msgpack error body to extract the token-error code (40140–40149) and trigger renewal. + +### Setup +```pseudo +callback_count = 0 +request_count = 0 + +auth_callback = FUNCTION(params): + callback_count = callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(callback_count), + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count = request_count + 1 + IF request_count == 1: + # First request fails with token expired — returned as msgpack + req.respond_with(401, + body: msgpack_encode({ + "error": { + "code": 40142, + "statusCode": 401, + "message": "Token expired" + } + }), + headers: { "Content-Type": "application/x-msgpack" } + ) + ELSE: + # Retry succeeds — also returned as msgpack + req.respond_with(200, + body: msgpack_encode([1234567890000]), + headers: { "Content-Type": "application/x-msgpack" } + ) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + authCallback: auth_callback, + useBinaryProtocol: true # Default — msgpack + ) +) +``` + +### Test Steps +```pseudo +result = AWAIT client.time() +``` + +### Assertions +```pseudo +# Auth callback was called twice (initial + renewal) +ASSERT callback_count == 2 + +# Two HTTP requests were made (original + retry) +ASSERT request_count == 2 + +# Result is successful +ASSERT result == 1234567890000 +``` diff --git a/uts/rest/unit/auth/token_request_params.md b/uts/rest/unit/auth/token_request_params.md new file mode 100644 index 000000000..8116d84eb --- /dev/null +++ b/uts/rest/unit/auth/token_request_params.md @@ -0,0 +1,234 @@ +# Token Request Parameter Defaults + +Spec points: `RSA5`, `RSA6`, `RSA9` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/rest_client.md` for the full Mock HTTP Infrastructure specification. These tests use the same `MockHttpClient` interface with `PendingConnection` and `PendingRequest`. + +## Purpose + +Tests the handling of `ttl` and `capability` parameters in `createTokenRequest()`. +The spec requires that when these values are not provided by the user, they must be +**null** in the token request rather than defaulted client-side. This allows Ably to +apply its own server-side defaults (60 minute TTL, key capabilities). + +**Portability note:** The `ttl` and `capability` fields on `TokenRequest` must be +nullable types (e.g. `int?` / `String?` in Dart, `Integer` / `String` in Java, +`*int` / `*string` in Go). This allows implementations to distinguish "not specified" +(null) from an explicit value, and to omit null fields during serialization. + +--- + +## RSA5 - TTL is null when not specified + +**Test ID**: `rest/unit/RSA5/ttl-null-when-unspecified-0` + +**Spec requirement:** TTL for new tokens is specified in milliseconds. If the user-provided `tokenParams` does not specify a TTL, the TTL field MUST be null (or the equivalent absent/unset value) in the `tokenRequest`, and Ably will supply a token with a TTL of 60 minutes. Implementations MUST NOT default this to 3600000 client-side. + +Tests that `createTokenRequest()` without explicit TTL produces a token request +with a null `ttl`, rather than a client-side default like 3600000. + +### Setup +```pseudo +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +token_request = AWAIT client.auth.createTokenRequest() +``` + +### Assertions +```pseudo +# TTL should be null (not zero, not a default like 3600000) +ASSERT token_request.ttl IS null +``` + +--- + +## RSA5b - Explicit TTL is preserved + +**Test ID**: `rest/unit/RSA5b/explicit-ttl-preserved-0` + +**Spec requirement:** When `tokenParams` specifies a TTL, it must be included in the token request. + +### Setup +```pseudo +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +token_request = AWAIT client.auth.createTokenRequest( + tokenParams: TokenParams(ttl: 7200000) # 2 hours +) +``` + +### Assertions +```pseudo +ASSERT token_request.ttl == 7200000 +``` + +--- + +## RSA5c - TTL from defaultTokenParams is used + +**Test ID**: `rest/unit/RSA5c/ttl-from-default-params-0` + +**Spec requirement:** TTL from `ClientOptions.defaultTokenParams` should be used when no explicit TTL is provided to `createTokenRequest()`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + defaultTokenParams: TokenParams(ttl: 1800000) # 30 minutes +)) +``` + +### Test Steps +```pseudo +token_request = AWAIT client.auth.createTokenRequest() +``` + +### Assertions +```pseudo +ASSERT token_request.ttl == 1800000 +``` + +--- + +## RSA5d - Explicit TTL overrides defaultTokenParams + +**Test ID**: `rest/unit/RSA5d/explicit-ttl-overrides-default-0` + +**Spec requirement:** An explicit TTL in `tokenParams` takes precedence over `defaultTokenParams`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + defaultTokenParams: TokenParams(ttl: 1800000) # 30 minutes +)) +``` + +### Test Steps +```pseudo +token_request = AWAIT client.auth.createTokenRequest( + tokenParams: TokenParams(ttl: 600000) # 10 minutes +) +``` + +### Assertions +```pseudo +ASSERT token_request.ttl == 600000 +``` + +--- + +## RSA6 - Capability is null when not specified + +**Test ID**: `rest/unit/RSA6/capability-null-when-unspecified-0` + +**Spec requirement:** The `capability` for new tokens is JSON stringified. If the user-provided `tokenParams` does not specify capabilities, the `capability` field MUST be null (or the equivalent absent/unset value) in the `tokenRequest`, and Ably will supply a token with the capabilities of the underlying key. Implementations MUST NOT default this to '{"*":["*"]}' client-side. + +Tests that `createTokenRequest()` without explicit capability produces a token +request with a null `capability`, rather than a client-side default like `{"*":["*"]}`. + +### Setup +```pseudo +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +token_request = AWAIT client.auth.createTokenRequest() +``` + +### Assertions +```pseudo +# Capability should be null (not a default like '{"*":["*"]}') +ASSERT token_request.capability IS null +``` + +--- + +## RSA6b - Explicit capability is preserved + +**Test ID**: `rest/unit/RSA6b/explicit-capability-preserved-0` + +**Spec requirement:** When `tokenParams` specifies a capability, it must be included in the token request as a JSON string. + +### Setup +```pseudo +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +token_request = AWAIT client.auth.createTokenRequest( + tokenParams: TokenParams(capability: '{"channel-a":["publish","subscribe"]}') +) +``` + +### Assertions +```pseudo +ASSERT token_request.capability == '{"channel-a":["publish","subscribe"]}' +``` + +--- + +## RSA6c - Capability from defaultTokenParams is used + +**Test ID**: `rest/unit/RSA6c/capability-from-default-params-0` + +**Spec requirement:** Capability from `ClientOptions.defaultTokenParams` should be used when no explicit capability is provided to `createTokenRequest()`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + defaultTokenParams: TokenParams(capability: '{"*":["subscribe"]}') +)) +``` + +### Test Steps +```pseudo +token_request = AWAIT client.auth.createTokenRequest() +``` + +### Assertions +```pseudo +ASSERT token_request.capability == '{"*":["subscribe"]}' +``` + +--- + +## RSA6d - Explicit capability overrides defaultTokenParams + +**Test ID**: `rest/unit/RSA6d/explicit-capability-overrides-default-0` + +**Spec requirement:** An explicit capability in `tokenParams` takes precedence over `defaultTokenParams`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + defaultTokenParams: TokenParams(capability: '{"*":["subscribe"]}') +)) +``` + +### Test Steps +```pseudo +token_request = AWAIT client.auth.createTokenRequest( + tokenParams: TokenParams(capability: '{"channel-x":["publish"]}') +) +``` + +### Assertions +```pseudo +ASSERT token_request.capability == '{"channel-x":["publish"]}' +``` diff --git a/uts/rest/unit/batch_presence.md b/uts/rest/unit/batch_presence.md new file mode 100644 index 000000000..cd089efe8 --- /dev/null +++ b/uts/rest/unit/batch_presence.md @@ -0,0 +1,518 @@ +# Batch Presence Tests + +Tests for `RestClient#batchPresence` (RSC24) and related types (BAR*, BGR*, BGF*). + +## Test Type +Unit test with mocked HTTP client + +## Mock Configuration + +These tests use the mock HTTP infrastructure defined in `rest_client.md`. The mock supports: +- Handler-based configuration with `onConnectionAttempt` and `onRequest` +- Capturing requests via `captured_requests` arrays +- Configurable responses with status codes, bodies, and headers + +See `rest_client.md` for detailed mock interface documentation. + +## Server Response Format + +With `X-Ably-Version >= 3` (sent by all current SDKs), the server returns a +`BatchResult` envelope for all batch responses: + +```json +{ + "successCount": 2, + "failureCount": 1, + "results": [ + {"channel": "ch-a", "presence": [...]}, + {"channel": "ch-b", "error": {"code": 40160, "statusCode": 401, ...}} + ] +} +``` + +- **All success, mixed, and all failure** return HTTP 200 with this format. +- **Server-level errors** (HTTP 500, 401, etc.) return an error object: `{"error": {...}}`. + +The SDK passes through this response directly — no client-side normalisation +is needed because the server provides `successCount`, `failureCount`, and `results`. + +**Legacy format (no version header):** Without `X-Ably-Version`, the server +returns a plain array for all-success (HTTP 200) and `{error, batchResponse}` +for mixed results (HTTP 400). This format is not used by current SDKs. + +--- + +## RSC24 - batchPresence sends GET to /presence + +**Spec requirement:** `RestClient#batchPresence` takes an array of channel name strings +and sends them as a comma separated string in the `channels` query parameter in a GET +request to `/presence`, returning a `BatchPresenceResponse` containing per-channel results. + +### RSC24_1 - Sends GET request to /presence with channels query param + +**Test ID**: `rest/unit/RSC24/get-presence-channels-param-0` + +**Spec requirement:** batchPresence sends a GET request to `/presence` with channel +names joined as a comma-separated `channels` query parameter. + +```pseudo +captured_requests = [] +mock_http = MockHTTP( + onRequest: (request) => { + captured_requests.append(request) + RETURN HttpResponse(status: 200, body: { + "successCount": 2, + "failureCount": 0, + "results": [ + { "channel": "channel-a", "presence": [] }, + { "channel": "channel-b", "presence": [] } + ] + }) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +result = AWAIT client.batchPresence(["channel-a", "channel-b"]) + +ASSERT captured_requests.length == 1 +ASSERT captured_requests[0].method == "GET" +ASSERT captured_requests[0].url.path == "/presence" +ASSERT captured_requests[0].url.queryParameters["channels"] == "channel-a,channel-b" +``` + +### RSC24_2 - Single channel sends GET with single channel name + +**Test ID**: `rest/unit/RSC24/single-channel-param-0` + +**Spec requirement:** batchPresence with a single channel sends the channel name in +the `channels` query parameter (no trailing comma). + +```pseudo +captured_requests = [] +mock_http = MockHTTP( + onRequest: (request) => { + captured_requests.append(request) + RETURN HttpResponse(status: 200, body: { + "successCount": 1, + "failureCount": 0, + "results": [ + { "channel": "my-channel", "presence": [] } + ] + }) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +result = AWAIT client.batchPresence(["my-channel"]) + +ASSERT captured_requests[0].url.queryParameters["channels"] == "my-channel" +``` + +### RSC24_3 - Channel names with special characters are comma-joined + +**Test ID**: `rest/unit/RSC24/special-chars-comma-joined-0` + +**Spec requirement:** Channel names containing special characters are joined with +commas as-is (the server handles parsing). + +```pseudo +captured_requests = [] +mock_http = MockHTTP( + onRequest: (request) => { + captured_requests.append(request) + RETURN HttpResponse(status: 200, body: { + "successCount": 2, + "failureCount": 0, + "results": [ + { "channel": "foo:bar", "presence": [] }, + { "channel": "baz/qux", "presence": [] } + ] + }) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +result = AWAIT client.batchPresence(["foo:bar", "baz/qux"]) + +ASSERT captured_requests[0].url.queryParameters["channels"] == "foo:bar,baz/qux" +``` + +--- + +## BAR2 - BatchPresenceResponse structure + +**Spec requirement:** The response is normalised into a `BatchPresenceResponse` with +computed `successCount`, `failureCount`, and `results` attributes (BAR2). + +### BAR2_1 - successCount and failureCount from mixed response + +**Test ID**: `rest/unit/BAR2/mixed-success-failure-counts-0` + +The server returns HTTP 200 with a `BatchResult` envelope containing per-channel +results, including both successes and failures. + +```pseudo +mock_http = MockHTTP( + onRequest: (request) => { + RETURN HttpResponse(status: 200, body: { + "successCount": 3, + "failureCount": 1, + "results": [ + { "channel": "ch-1", "presence": [] }, + { "channel": "ch-2", "presence": [] }, + { "channel": "ch-3", "presence": [] }, + { "channel": "ch-4", "error": { "code": 40160, "statusCode": 401, "message": "Not permitted" } } + ] + }) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +result = AWAIT client.batchPresence(["ch-1", "ch-2", "ch-3", "ch-4"]) + +ASSERT result.successCount == 3 +ASSERT result.failureCount == 1 +ASSERT result.results.length == 4 +``` + +### BAR2_2 - All success + +**Test ID**: `rest/unit/BAR2/all-success-counts-0` + +```pseudo +mock_http = MockHTTP( + onRequest: (request) => { + RETURN HttpResponse(status: 200, body: { + "successCount": 2, + "failureCount": 0, + "results": [ + { "channel": "ch-a", "presence": [] }, + { "channel": "ch-b", "presence": [] } + ] + }) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +result = AWAIT client.batchPresence(["ch-a", "ch-b"]) + +ASSERT result.successCount == 2 +ASSERT result.failureCount == 0 +ASSERT result.results.length == 2 +``` + +### BAR2_3 - All failure + +**Test ID**: `rest/unit/BAR2/all-failure-counts-0` + +```pseudo +mock_http = MockHTTP( + onRequest: (request) => { + RETURN HttpResponse(status: 200, body: { + "successCount": 0, + "failureCount": 2, + "results": [ + { "channel": "ch-a", "error": { "code": 40160, "statusCode": 401, "message": "Not permitted" } }, + { "channel": "ch-b", "error": { "code": 40160, "statusCode": 401, "message": "Not permitted" } } + ] + }) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +result = AWAIT client.batchPresence(["ch-a", "ch-b"]) + +ASSERT result.successCount == 0 +ASSERT result.failureCount == 2 +ASSERT result.results.length == 2 +``` + +--- + +## BGR2 - BatchPresenceSuccessResult structure + +**Spec requirement:** A successful per-channel result contains `channel` (string) and +`presence` (array of PresenceMessage). + +### BGR2_1 - Success result with members present + +**Test ID**: `rest/unit/BGR2/success-with-members-0` + +```pseudo +mock_http = MockHTTP( + onRequest: (request) => { + RETURN HttpResponse(status: 200, body: { + "successCount": 1, + "failureCount": 0, + "results": [ + { + "channel": "my-channel", + "presence": [ + { + "clientId": "client-1", + "action": 1, + "connectionId": "conn-abc", + "id": "conn-abc:0:0", + "timestamp": 1700000000000, + "data": "hello" + }, + { + "clientId": "client-2", + "action": 1, + "connectionId": "conn-def", + "id": "conn-def:0:0", + "timestamp": 1700000000000, + "data": { "key": "value" } + } + ] + } + ] + }) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +result = AWAIT client.batchPresence(["my-channel"]) + +ASSERT result.results.length == 1 + +success = result.results[0] +ASSERT success IS BatchPresenceSuccessResult +ASSERT success.channel == "my-channel" +ASSERT success.presence.length == 2 + +ASSERT success.presence[0].clientId == "client-1" +ASSERT success.presence[0].action == PRESENT +ASSERT success.presence[0].connectionId == "conn-abc" +ASSERT success.presence[0].data == "hello" + +ASSERT success.presence[1].clientId == "client-2" +ASSERT success.presence[1].data IS Object/Map +ASSERT success.presence[1].data["key"] == "value" +``` + +### BGR2_2 - Success result with empty presence (no members) + +**Test ID**: `rest/unit/BGR2/success-empty-presence-0` + +```pseudo +mock_http = MockHTTP( + onRequest: (request) => { + RETURN HttpResponse(status: 200, body: { + "successCount": 1, + "failureCount": 0, + "results": [ + { "channel": "empty-channel", "presence": [] } + ] + }) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +result = AWAIT client.batchPresence(["empty-channel"]) + +success = result.results[0] +ASSERT success IS BatchPresenceSuccessResult +ASSERT success.channel == "empty-channel" +ASSERT success.presence.length == 0 +``` + +--- + +## BGF2 - BatchPresenceFailureResult structure + +**Spec requirement:** A failed per-channel result contains `channel` (string) and +`error` (ErrorInfo). + +### BGF2_1 - Failure result with error details + +**Test ID**: `rest/unit/BGF2/failure-error-details-0` + +```pseudo +mock_http = MockHTTP( + onRequest: (request) => { + RETURN HttpResponse(status: 200, body: { + "successCount": 0, + "failureCount": 1, + "results": [ + { + "channel": "restricted-channel", + "error": { + "code": 40160, + "statusCode": 401, + "message": "Channel operation not permitted" + } + } + ] + }) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +result = AWAIT client.batchPresence(["restricted-channel"]) + +ASSERT result.results.length == 1 + +failure = result.results[0] +ASSERT failure IS BatchPresenceFailureResult +ASSERT failure.channel == "restricted-channel" +ASSERT failure.error IS ErrorInfo +ASSERT failure.error.code == 40160 +ASSERT failure.error.statusCode == 401 +ASSERT failure.error.message CONTAINS "not permitted" +``` + +--- + +## Mixed results + +### RSC24_Mixed_1 - Mixed success and failure results + +**Test ID**: `rest/unit/RSC24/mixed-success-failure-results-0` + +**Spec requirement:** A batch presence request can succeed for some channels and fail +for others. The server returns HTTP 200 with a `BatchResult` containing both +success and failure per-channel results. + +```pseudo +mock_http = MockHTTP( + onRequest: (request) => { + RETURN HttpResponse(status: 200, body: { + "successCount": 1, + "failureCount": 1, + "results": [ + { + "channel": "allowed-channel", + "presence": [ + { + "clientId": "user-1", + "action": 1, + "connectionId": "conn-1", + "id": "conn-1:0:0", + "timestamp": 1700000000000 + } + ] + }, + { + "channel": "restricted-channel", + "error": { + "code": 40160, + "statusCode": 401, + "message": "Not permitted" + } + } + ] + }) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +result = AWAIT client.batchPresence(["allowed-channel", "restricted-channel"]) + +ASSERT result.successCount == 1 +ASSERT result.failureCount == 1 +ASSERT result.results.length == 2 + +ASSERT result.results[0] IS BatchPresenceSuccessResult +ASSERT result.results[0].channel == "allowed-channel" +ASSERT result.results[0].presence.length == 1 +ASSERT result.results[0].presence[0].clientId == "user-1" + +ASSERT result.results[1] IS BatchPresenceFailureResult +ASSERT result.results[1].channel == "restricted-channel" +ASSERT result.results[1].error.code == 40160 +``` + +--- + +## Error handling + +### RSC24_Error_1 - Server error is propagated as an error + +**Test ID**: `rest/unit/RSC24/server-error-propagated-0` + +**Spec requirement:** A server-level error (e.g. 500) for the entire batch request +is propagated as an error, not a per-channel failure. The response contains only an +`error` field with no `batchResponse`. + +```pseudo +mock_http = MockHTTP( + onRequest: (request) => { + RETURN HttpResponse(status: 500, body: { + "error": { "code": 50000, "statusCode": 500, "message": "Internal error" } + }) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +AWAIT client.batchPresence(["any-channel"]) FAILS WITH error +ASSERT error.code == 50000 +ASSERT error.statusCode == 500 +``` + +### RSC24_Error_2 - Authentication error is propagated as an error + +**Test ID**: `rest/unit/RSC24/auth-error-propagated-0` + +**Spec requirement:** An authentication error (401) for the entire request is +propagated as an error. + +```pseudo +mock_http = MockHTTP( + onRequest: (request) => { + RETURN HttpResponse(status: 401, body: { + "error": { "code": 40101, "statusCode": 401, "message": "Invalid credentials" } + }) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +AWAIT client.batchPresence(["any-channel"]) FAILS WITH error +ASSERT error.code == 40101 +ASSERT error.statusCode == 401 +``` + +--- + +## Request authentication + +### RSC24_Auth_1 - Request uses configured authentication + +**Test ID**: `rest/unit/RSC24/uses-configured-auth-0` + +**Spec requirement:** batchPresence requests use the client's configured authentication +mechanism (Basic or Token auth). + +```pseudo +captured_requests = [] +mock_http = MockHTTP( + onRequest: (request) => { + captured_requests.append(request) + RETURN HttpResponse(status: 200, body: { + "successCount": 1, + "failureCount": 0, + "results": [ + { "channel": "ch", "presence": [] } + ] + }) + } +) + +# Basic auth +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) +AWAIT client.batchPresence(["ch"]) + +ASSERT captured_requests[0].headers["Authorization"] STARTS_WITH "Basic " +``` diff --git a/uts/rest/unit/batch_publish.md b/uts/rest/unit/batch_publish.md new file mode 100644 index 000000000..a0b86701b --- /dev/null +++ b/uts/rest/unit/batch_publish.md @@ -0,0 +1,560 @@ +# Batch Publish Tests + +Tests for `RestClient#batchPublish` (RSC22) and related types (BSP*, BPR*, BPF*). + +## Test Type +Unit test with mocked HTTP client + +## Mock Configuration + +These tests use the mock HTTP infrastructure defined in `rest_client.md`. The mock supports: +- Handler-based configuration with `onConnectionAttempt` and `onRequest` +- Capturing requests via `captured_requests` arrays +- Configurable responses with status codes, bodies, and headers + +See `rest_client.md` for detailed mock interface documentation. + +## RSC22c - batchPublish sends POST to /messages + +**Spec requirement:** The `batchPublish` method must send a POST request to the `/messages` endpoint with the batch specifications in the request body. + +### RSC22c1 - Single BatchPublishSpec sends POST to /messages + +**Test ID**: `rest/unit/RSC22c/single-spec-post-messages-0` + +**Spec requirement:** A single BatchPublishSpec is sent as a POST to `/messages` with the spec in the request body. + +```pseudo +channel_name_1 = "test-RSC22c1-a-${random_id()}" +channel_name_2 = "test-RSC22c1-b-${random_id()}" + +Given a REST client with mock HTTP +And the mock is configured to capture requests and respond with success +When batchPublish is called with a single BatchPublishSpec: + - channels: [channel_name_1, channel_name_2] + - messages: [Message(name: "event", data: "hello")] +Then a POST request is sent to "/messages" +And the captured request body contains: + - channels: [channel_name_1, channel_name_2] + - messages: [{ name: "event", data: "hello" }] +``` + +### RSC22c2 - Array of BatchPublishSpecs sends POST to /messages + +**Test ID**: `rest/unit/RSC22c/array-specs-post-messages-0` + +**Spec requirement:** An array of BatchPublishSpecs is sent as a POST to `/messages` with an array of specs in the request body. + +```pseudo +channel_name_1 = "test-RSC22c2-a-${random_id()}" +channel_name_2 = "test-RSC22c2-b-${random_id()}" + +Given a REST client with mock HTTP +And the mock is configured to capture requests and respond with success +When batchPublish is called with an array of BatchPublishSpecs: + - BatchPublishSpec(channels: [channel_name_1], messages: [Message(name: "e1", data: "d1")]) + - BatchPublishSpec(channels: [channel_name_2], messages: [Message(name: "e2", data: "d2")]) +Then a POST request is sent to "/messages" +And the captured request body is an array containing both specs +``` + +### RSC22c3 - Single spec returns single BatchResult + +**Test ID**: `rest/unit/RSC22c/single-spec-single-result-0` + +**Spec requirement:** When a single BatchPublishSpec is sent, the response is a single BatchResult (not an array). + +```pseudo +channel_name = "test-RSC22c3-${random_id()}" + +Given a REST client with mock HTTP +And the mock is configured to respond with: + { + "channel": channel_name, + "messageId": "msg123", + "serials": ["serial1"] + } +When batchPublish is called with a single BatchPublishSpec +Then a single BatchResult is returned (not an array) +And the result contains the success result for channel_name +``` + +### RSC22c4 - Array of specs returns array of BatchResults + +**Test ID**: `rest/unit/RSC22c/array-specs-array-results-0` + +**Spec requirement:** When an array of BatchPublishSpecs is sent, the response is an array of BatchResults. + +```pseudo +channel_name_1 = "test-RSC22c4-a-${random_id()}" +channel_name_2 = "test-RSC22c4-b-${random_id()}" + +Given a REST client with mock HTTP +And the mock is configured to respond with an array of results: + [ + { "channel": channel_name_1, "messageId": "msg1", "serials": ["s1"] }, + { "channel": channel_name_2, "messageId": "msg2", "serials": ["s2"] } + ] +When batchPublish is called with an array of BatchPublishSpecs +Then an array of BatchResults is returned +And each result corresponds to the respective spec +``` + +### RSC22c5 - Multiple channels in spec produces multiple results + +**Test ID**: `rest/unit/RSC22c/multiple-channels-multiple-results-0` + +**Spec requirement:** A BatchPublishSpec with multiple channels produces multiple results in the response, one per channel. + +```pseudo +channel_name_1 = "test-RSC22c5-a-${random_id()}" +channel_name_2 = "test-RSC22c5-b-${random_id()}" +channel_name_3 = "test-RSC22c5-c-${random_id()}" + +Given a REST client with mock HTTP +And a BatchPublishSpec with channels: [channel_name_1, channel_name_2, channel_name_3] +And the mock is configured to respond with results for each channel +When batchPublish is called +Then the BatchResult contains results for all three channels +``` + +### RSC22c6 - Messages are encoded according to RSL4 + +**Test ID**: `rest/unit/RSC22c/messages-encoded-per-rsl4-0` + +**Spec requirement:** Messages must be encoded according to RSL4 (String, Binary base64, JSON stringified). + +```pseudo +channel_name = "test-RSC22c6-${random_id()}" + +Given a REST client with mock HTTP +And the mock is configured to capture requests +When batchPublish is called with messages containing: + - String data + - Binary data (Uint8List/[]byte) + - JSON object data +Then the captured request shows each message is encoded per RSL4: + - String: data as-is, no encoding + - Binary: base64 encoded, encoding: "base64" + - JSON: JSON stringified, encoding: "json" +``` + +### RSC22c7 - Request uses correct authentication + +**Test ID**: `rest/unit/RSC22c/uses-configured-auth-0` + +**Spec requirement:** Batch publish requests must use the configured authentication mechanism. + +```pseudo +channel_name = "test-RSC22c7-${random_id()}" + +Given a REST client with token auth and mock HTTP +And the mock is configured to capture requests +When batchPublish is called +Then the captured POST request includes Authorization: Bearer +``` + +```pseudo +channel_name = "test-RSC22c7-basic-${random_id()}" + +Given a REST client with basic auth and mock HTTP +And the mock is configured to capture requests +When batchPublish is called +Then the captured POST request includes Authorization: Basic +``` + +## RSC22d - Idempotent publishing applies RSL1k1 + +**Spec requirement (RSC22d):** "If `idempotentRestPublishing` is enabled, then RSL1k1 should be applied (to each `BatchPublishSpec` separately)." + +### RSC22d - Idempotent IDs generated when enabled + +**Test ID**: `rest/unit/RSC22d/idempotent-ids-generated-0` + +**Spec requirement:** With idempotentRestPublishing enabled, messages without IDs get unique IDs generated in baseId:serial format per RSL1k1, applied to each BatchPublishSpec separately. + +```pseudo +Given a REST client with idempotentRestPublishing: true and mock HTTP +And the mock is configured to capture requests +When batchPublish is called with messages that have no id +Then the captured request shows each message in each BatchPublishSpec has a unique id generated +And the id format follows RSL1k1 (baseId:serial) +And each BatchPublishSpec gets a separate base ID +``` + +### RSC22d - Explicit message IDs preserved + +**Test ID**: `rest/unit/RSC22d/explicit-ids-preserved-0` + +**Spec requirement:** Per RSL1k3, messages with explicit IDs must have those IDs preserved as-is, even when idempotent publishing is enabled. + +```pseudo +Given a REST client with idempotentRestPublishing: true and mock HTTP +And the mock is configured to capture requests +When batchPublish is called with messages that have explicit ids +Then the captured request shows the explicit ids are preserved (not overwritten) +``` + +### RSC22d - Idempotent IDs not generated when disabled + +**Test ID**: `rest/unit/RSC22d/ids-not-generated-disabled-0` + +**Spec requirement:** When idempotent REST publishing is disabled, no IDs are generated for messages without IDs. + +```pseudo +Given a REST client with idempotentRestPublishing: false and mock HTTP +And the mock is configured to capture requests +When batchPublish is called with messages that have no id +Then the captured request shows messages are sent without id fields +``` + +## BSP - BatchPublishSpec Structure + +**Spec requirement:** BatchPublishSpec defines the structure for specifying channels and messages in a batch publish request (BSP2a, BSP2b). + +### BSP2a - channels is array of strings + +**Test ID**: `rest/unit/BSP2a/channels-array-strings-0` + +**Spec requirement:** The channels field must be an array of channel name strings. + +```pseudo +channel_name_1 = "test-BSP2a-a-${random_id()}" +channel_name_2 = "test-BSP2a-b-${random_id()}" +channel_name_3 = "test-BSP2a-c-${random_id()}" + +Given a BatchPublishSpec with mock HTTP +When channels is set to [channel_name_1, channel_name_2, channel_name_3] +Then the serialized spec in the captured request contains channels as a string array +``` + +### BSP2b - messages is array of Message objects + +**Test ID**: `rest/unit/BSP2b/messages-array-objects-0` + +**Spec requirement:** The messages field must be an array of Message objects, each serialized according to TM* rules. + +```pseudo +channel_name = "test-BSP2b-${random_id()}" + +Given a BatchPublishSpec with mock HTTP +And the mock is configured to capture requests +When messages contains multiple Message objects with: + - Message(name: "event1", data: "data1") + - Message(name: "event2", data: { "key": "value" }) +Then the serialized spec in the captured request contains messages as an array of message objects +And each message is serialized according to TM* rules +``` + +## BPR - BatchPublishSuccessResult Structure + +**Spec requirement:** BatchPublishSuccessResult defines the structure of successful batch publish responses (BPR2a, BPR2b, BPR2c). + +### BPR2a - channel field contains channel name + +**Test ID**: `rest/unit/BPR2a/success-channel-name-0` + +**Spec requirement:** The channel field contains the name of the channel where messages were published. + +```pseudo +channel_name = "test-BPR2a-${random_id()}" + +Given a REST client with mock HTTP +And the mock responds with: + { "channel": channel_name, "messageId": "msg123", "serials": ["s1"] } +When the response is parsed into BatchPublishSuccessResult +Then result.channel equals channel_name +``` + +### BPR2b - messageId contains the message ID prefix + +**Test ID**: `rest/unit/BPR2b/success-message-id-prefix-0` + +**Spec requirement:** The messageId field contains the unique ID prefix for the published messages. + +```pseudo +channel_name = "test-BPR2b-${random_id()}" + +Given a REST client with mock HTTP +And the mock responds with: + { "channel": channel_name, "messageId": "unique-id-prefix", "serials": ["s1", "s2"] } +When the response is parsed into BatchPublishSuccessResult +Then result.messageId equals "unique-id-prefix" +``` + +### BPR2c - serials contains array of message serials + +**Test ID**: `rest/unit/BPR2c/serials-array-0` + +**Spec requirement:** The serials field contains an array of serial numbers, one per published message. + +```pseudo +channel_name = "test-BPR2c-${random_id()}" + +Given a REST client with mock HTTP +And the mock responds with: + { "channel": channel_name, "messageId": "msg", "serials": ["serial1", "serial2", "serial3"] } +When the response is parsed into BatchPublishSuccessResult +Then result.serials equals ["serial1", "serial2", "serial3"] +And serials.length matches the number of messages published +``` + +### BPR2c1 - serials may contain null for conflated messages + +**Test ID**: `rest/unit/BPR2c/serials-null-conflated-0` + +**Spec requirement:** The serials array may contain null values for messages that were conflated (deduplicated). + +```pseudo +channel_name = "test-BPR2c1-${random_id()}" + +Given a REST client with mock HTTP +And the mock responds with a response where some messages were conflated: + { "channel": channel_name, "messageId": "msg", "serials": ["serial1", null, "serial3"] } +When the response is parsed into BatchPublishSuccessResult +Then result.serials equals ["serial1", null, "serial3"] +And the null indicates the second message was discarded due to conflation +``` + +## BPF - BatchPublishFailureResult Structure + +**Spec requirement:** BatchPublishFailureResult defines the structure of failed batch publish responses (BPF2a, BPF2b). + +### BPF2a - channel field contains failed channel name + +**Test ID**: `rest/unit/BPF2a/failure-channel-name-0` + +**Spec requirement:** The channel field contains the name of the channel that failed. + +```pseudo +channel_name = "test-BPF2a-${random_id()}" + +Given a REST client with mock HTTP +And the mock responds with a failure: + { + "channel": channel_name, + "error": { "code": 40160, "statusCode": 401, "message": "Not permitted" } + } +When the response is parsed into BatchPublishFailureResult +Then result.channel equals channel_name +``` + +### BPF2b - error contains ErrorInfo for failure reason + +**Test ID**: `rest/unit/BPF2b/failure-error-info-0` + +**Spec requirement:** The error field contains an ErrorInfo object with code, statusCode, and message. + +```pseudo +channel_name = "test-BPF2b-${random_id()}" + +Given a REST client with mock HTTP +And the mock responds with a detailed error: + { + "channel": channel_name, + "error": { + "code": 40160, + "statusCode": 401, + "message": "Channel operation not permitted", + "href": "https://help.ably.io/error/40160" + } + } +When the response is parsed into BatchPublishFailureResult +Then result.error is an ErrorInfo +And result.error.code equals 40160 +And result.error.statusCode equals 401 +And result.error.message contains "not permitted" +``` + +## BatchResult - Mixed Success and Failure + +**Spec requirement:** Batch publish responses can contain a mix of success and failure results, one per channel. + +### BatchResult1 - Partial success with mixed results + +**Test ID**: `rest/unit/RSC22c/partial-success-mixed-results-0` + +**Spec requirement:** A batch publish can succeed for some channels and fail for others. + +```pseudo +channel_name_allowed = "test-BatchResult1-allowed-${random_id()}" +channel_name_restricted = "test-BatchResult1-restricted-${random_id()}" + +Given a REST client with mock HTTP +And a BatchPublishSpec with channels: [channel_name_allowed, channel_name_restricted] +And the mock responds with mixed results: + [ + { "channel": channel_name_allowed, "messageId": "msg1", "serials": ["s1"] }, + { "channel": channel_name_restricted, "error": { "code": 40160, ... } } + ] +When batchPublish is called +Then the BatchResult contains both results +And result[0] is a BatchPublishSuccessResult +And result[1] is a BatchPublishFailureResult +``` + +### BatchResult2 - Distinguishing success from failure results + +**Test ID**: `rest/unit/RSC22c/distinguish-success-failure-0` + +**Spec requirement:** Success and failure results can be distinguished by the presence of messageId/serials vs error fields. + +```pseudo +channel_name = "test-BatchResult2-${random_id()}" + +Given a BatchResult from batchPublish with mock HTTP +When iterating through results +Then each result can be identified as success or failure: + - Success results have messageId and serials fields + - Failure results have error field +``` + +## Error Handling + +**Spec requirement:** Batch publish must validate inputs and properly propagate errors from the server. + +### RSC22_Error1 - Invalid BatchPublishSpec rejected + +**Test ID**: `rest/unit/RSC22/empty-channels-rejected-0` + +**Spec requirement:** Empty channels array must be rejected with a validation error. + +```pseudo +Given a REST client with mock HTTP +When batchPublish is called with an empty channels array +Then an error is returned +And the error indicates invalid request +``` + +### RSC22_Error2 - Empty messages array rejected + +**Test ID**: `rest/unit/RSC22/empty-messages-rejected-0` + +**Spec requirement:** Empty messages array must be rejected with a validation error. + +```pseudo +channel_name = "test-RSC22-Error2-${random_id()}" + +Given a REST client with mock HTTP +When batchPublish is called with an empty messages array +Then an error is returned +And the error indicates invalid request +``` + +### RSC22_Error3 - Server error returns AblyException + +**Test ID**: `rest/unit/RSC22/server-error-propagated-0` + +**Spec requirement:** Server errors (5xx) must be propagated as AblyException with the error code and status. + +```pseudo +channel_name = "test-RSC22-Error3-${random_id()}" + +Given a REST client with mock HTTP +And the mock responds with HTTP 500: + { "error": { "code": 50000, "statusCode": 500, "message": "Internal error" } } +When batchPublish is called +Then an AblyException is thrown +And exception.code equals 50000 +And exception.statusCode equals 500 +``` + +### RSC22_Error4 - Authentication error returns AblyException + +**Test ID**: `rest/unit/RSC22/auth-error-propagated-0` + +**Spec requirement:** Authentication errors (401) must be propagated as AblyException with the error code and status. + +```pseudo +channel_name = "test-RSC22-Error4-${random_id()}" + +Given a REST client with invalid credentials and mock HTTP +And the mock responds with HTTP 401: + { "error": { "code": 40101, "statusCode": 401, "message": "Invalid credentials" } } +When batchPublish is called +Then an AblyException is thrown +And exception.code equals 40101 +And exception.statusCode equals 401 +``` + +## Request Headers + +**Spec requirement:** Batch publish requests must include standard Ably headers (X-Ably-Version, Ably-Agent, Content-Type). + +### RSC22_Headers1 - Standard headers included + +**Test ID**: `rest/unit/RSC22/standard-headers-included-0` + +**Spec requirement:** All batch publish requests must include standard Ably protocol headers. + +```pseudo +channel_name = "test-RSC22-Headers1-${random_id()}" + +Given a REST client with mock HTTP +And the mock is configured to capture requests +When batchPublish is called +Then the captured request includes: + - X-Ably-Version: 2 + - Ably-Agent: + - Content-Type: application/json +``` + +### RSC22_Headers2 - Request ID included when enabled + +**Test ID**: `rest/unit/RSC22/request-id-included-0` + +**Spec requirement:** When addRequestIds is enabled, a unique request_id query parameter must be included. + +```pseudo +channel_name = "test-RSC22-Headers2-${random_id()}" + +Given a REST client with addRequestIds: true and mock HTTP +And the mock is configured to capture requests +When batchPublish is called +Then the captured request includes a request_id query parameter +And the request_id is a unique identifier +``` + +## Large Batch Handling + +**Spec requirement:** Batch publish must handle large batches with multiple messages and channels efficiently. + +### RSC22_Batch1 - Multiple messages per channel + +**Test ID**: `rest/unit/RSC22/multiple-messages-per-channel-0` + +**Spec requirement:** A batch can include many messages to be published to a single channel. + +```pseudo +channel_name = "test-RSC22-Batch1-${random_id()}" + +Given a REST client with mock HTTP +And the mock is configured to capture requests +And a BatchPublishSpec with: + - channels: [channel_name] + - messages: [100 Message objects] +When batchPublish is called +Then all 100 messages are included in the captured request body +And the mock response confirms all messages were processed +``` + +### RSC22_Batch2 - Multiple channels with multiple messages + +**Test ID**: `rest/unit/RSC22/multiple-channels-multiple-messages-0` + +**Spec requirement:** A batch can publish multiple messages to multiple channels (cartesian product). + +```pseudo +channel_name_1 = "test-RSC22-Batch2-a-${random_id()}" +channel_name_2 = "test-RSC22-Batch2-b-${random_id()}" +channel_name_3 = "test-RSC22-Batch2-c-${random_id()}" + +Given a REST client with mock HTTP +And the mock is configured to respond with results for each channel +And a BatchPublishSpec with: + - channels: [channel_name_1, channel_name_2, channel_name_3] + - messages: [msg1, msg2, msg3] +When batchPublish is called +Then the batch publishes all 3 messages to all 3 channels (9 total publications) +And the result contains 3 BatchPublishSuccessResult entries (one per channel) +``` diff --git a/uts/rest/unit/channel/annotations.md b/uts/rest/unit/channel/annotations.md new file mode 100644 index 000000000..a82162235 --- /dev/null +++ b/uts/rest/unit/channel/annotations.md @@ -0,0 +1,492 @@ +# REST Channel Annotations Tests + +Spec points: `RSL10`, `RSAN1`, `RSAN1a`, `RSAN1a2`, `RSAN1a3`, `RSAN1c`, `RSAN1c1`, `RSAN1c2`, `RSAN1c3`, `RSAN1c4`, `RSAN1c5`, `RSAN1c6`, `RSAN2`, `RSAN2a`, `RSAN3`, `RSAN3a`, `RSAN3b`, `RSAN3c` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure defined in `uts/test/rest/unit/helpers/mock_http.md`. + +--- + +## RSL10 — channel.annotations returns RestAnnotations + +**Test ID**: `rest/unit/RSL10/annotations-attribute-type-0` + +**Spec requirement:** RSL10 — `RestChannel#annotations` attribute contains the `RestAnnotations` object for this channel. + +Tests that the channel exposes an `annotations` attribute of type `RestAnnotations`. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {}) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test-RSL10") +``` + +### Assertions +```pseudo +ASSERT channel.annotations IS RestAnnotations +``` + +--- + +## RSAN1c6, RSAN1c1, RSAN1c2 — publish sends POST with ANNOTATION_CREATE to correct endpoint + +**Test ID**: `rest/unit/RSAN1c6/publish-post-annotation-create-0` + +| Spec | Requirement | +|------|-------------| +| RSAN1c6 | Body sent as POST to `/channels/{channelName}/messages/{messageSerial}/annotations` | +| RSAN1c1 | `Annotation.action` must be set to `ANNOTATION_CREATE` | +| RSAN1c2 | `Annotation.messageSerial` must be set to the identifier from the first argument | + +Tests that `annotations.publish()` sends a correctly formatted POST request. + +### Setup +```pseudo +channel_name = "test-RSAN1-publish-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + type: "com.example.reaction", + name: "like" +)) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "POST" +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages/msg-serial-1/annotations" + +body = parse_json(request.body) +ASSERT body IS List +ASSERT body.length == 1 + +annotation = body[0] +ASSERT annotation["action"] == 0 # ANNOTATION_CREATE numeric value +ASSERT annotation["messageSerial"] == "msg-serial-1" +ASSERT annotation["type"] == "com.example.reaction" +ASSERT annotation["name"] == "like" +``` + +--- + +## RSAN1a3 — publish validates type is required + +**Test ID**: `rest/unit/RSAN1a3/publish-type-required-0` + +**Spec requirement:** RSAN1a3 — The SDK must validate that the user supplied a `type`. All other fields are optional. + +Tests that publishing an annotation without a `type` field throws an error. + +### Setup +```pseudo +channel_name = "test-RSAN1a3-${random_id()}" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(201, {}) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps and Assertions +```pseudo +# Annotation without type +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + name: "like" +)) FAILS WITH error +ASSERT error.code == 40003 +``` + +--- + +## RSAN1c3 — annotation data encoded per RSL4 + +**Test ID**: `rest/unit/RSAN1c3/annotation-data-encoded-0` + +**Spec requirement:** RSAN1c3 — If the user has supplied an `Annotation.data`, that must be encoded (and the `encoding` set) just as it would be for a `Message`, per `RSL4`. + +Tests that JSON data in an annotation is encoded following message encoding rules. + +### Setup +```pseudo +channel_name = "test-RSAN1c3-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + type: "com.example.data", + data: { "key": "value", "nested": { "a": 1 } } +)) +``` + +### Assertions +```pseudo +body = parse_json(captured_requests[0].body) +annotation = body[0] + +# JSON data should be encoded as a string with encoding field +ASSERT annotation["data"] IS String +ASSERT annotation["encoding"] == "json" +ASSERT parse_json(annotation["data"]) == { "key": "value", "nested": { "a": 1 } } +``` + +--- + +## RSAN1c4 — idempotent ID generated when enabled + +**Test ID**: `rest/unit/RSAN1c4/idempotent-id-generated-0` + +**Spec requirement:** RSAN1c4 — If `idempotentRestPublishing` is enabled and the annotation has an empty `id`, the SDK should generate a base64-encoded random string, append `:0`, and set it as the `Annotation.id`. + +Tests that an idempotent ID is auto-generated when the option is enabled. + +### Setup +```pseudo +channel_name = "test-RSAN1c4-enabled-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + idempotentRestPublishing: true +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + type: "com.example.reaction" +)) +``` + +### Assertions +```pseudo +body = parse_json(captured_requests[0].body) +annotation = body[0] + +ASSERT "id" IN annotation +annotation_id = annotation["id"] + +# Format: :0 +parts = annotation_id.split(":") +ASSERT parts.length == 2 +ASSERT parts[0] matches pattern "[A-Za-z0-9_-]+" +ASSERT parts[0].length >= 12 # At least 9 bytes base64 encoded +ASSERT parts[1] == "0" +``` + +--- + +## RSAN1c4 — idempotent ID not generated when disabled + +**Test ID**: `rest/unit/RSAN1c4/idempotent-id-not-generated-1` + +**Spec requirement:** RSAN1c4 — The SDK should only generate idempotent IDs when `idempotentRestPublishing` is enabled. + +Tests that no ID is auto-generated when idempotent publishing is disabled. + +### Setup +```pseudo +channel_name = "test-RSAN1c4-disabled-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + idempotentRestPublishing: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + type: "com.example.reaction" +)) +``` + +### Assertions +```pseudo +body = parse_json(captured_requests[0].body) +annotation = body[0] + +ASSERT "id" NOT IN annotation +``` + +--- + +## RSAN2a — delete sends POST with ANNOTATION_DELETE + +**Spec requirement:** RSAN2a — Must be identical to RSAN1 `publish()` except that the `Annotation.action` is set to `ANNOTATION_DELETE`, not `ANNOTATION_CREATE`. + +Tests that `annotations.delete()` sends a POST with the delete action. + +### Setup +```pseudo +channel_name = "test-RSAN2-delete-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.annotations.delete("msg-serial-1", Annotation( + type: "com.example.reaction", + name: "like" +)) +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.method == "POST" +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages/msg-serial-1/annotations" + +body = parse_json(request.body) +ASSERT body IS List +ASSERT body.length == 1 + +annotation = body[0] +ASSERT annotation["action"] == 1 # ANNOTATION_DELETE numeric value +ASSERT annotation["messageSerial"] == "msg-serial-1" +ASSERT annotation["type"] == "com.example.reaction" +ASSERT annotation["name"] == "like" +``` + +--- + +## RSAN3b — get sends GET to correct endpoint + +| Spec | Requirement | +|------|-------------| +| RSAN3b | Sends a GET request to `/channels/{channelName}/messages/{messageSerial}/annotations` | + +Tests that `annotations.get()` sends a GET request to the correct URL. + +### Setup +```pseudo +channel_name = "test-RSAN3-get-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { + "id": "ann-1", + "action": 0, + "type": "com.example.reaction", + "name": "like", + "clientId": "user-1", + "serial": "ann-serial-1", + "messageSerial": "msg-serial-1", + "timestamp": 1700000000000 + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +result = AWAIT channel.annotations.get("msg-serial-1") +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages/msg-serial-1/annotations" +``` + +--- + +## RSAN3c — get returns PaginatedResult of Annotations + +**Spec requirement:** RSAN3c — Returns a `PaginatedResult` page containing the first page of decoded `Annotation` objects. + +Tests that the response is parsed into a paginated result of annotations with all fields. + +### Setup +```pseudo +channel_name = "test-RSAN3c-${random_id()}" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + "id": "ann-1", + "action": 0, + "type": "com.example.reaction", + "name": "like", + "clientId": "user-1", + "count": 1, + "data": "thumbs-up", + "serial": "ann-serial-1", + "messageSerial": "msg-serial-1", + "timestamp": 1700000000000, + "extras": { "custom": "metadata" } + }, + { + "id": "ann-2", + "action": 0, + "type": "com.example.reaction", + "name": "heart", + "clientId": "user-2", + "serial": "ann-serial-2", + "messageSerial": "msg-serial-1", + "timestamp": 1700000001000 + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +result = AWAIT channel.annotations.get("msg-serial-1") +``` + +### Assertions +```pseudo +ASSERT result IS PaginatedResult +ASSERT result.items.length == 2 + +ann1 = result.items[0] +ASSERT ann1 IS Annotation +ASSERT ann1.id == "ann-1" +ASSERT ann1.action == AnnotationAction.ANNOTATION_CREATE +ASSERT ann1.type == "com.example.reaction" +ASSERT ann1.name == "like" +ASSERT ann1.clientId == "user-1" +ASSERT ann1.count == 1 +ASSERT ann1.data == "thumbs-up" +ASSERT ann1.serial == "ann-serial-1" +ASSERT ann1.messageSerial == "msg-serial-1" +ASSERT ann1.timestamp == 1700000000000 +ASSERT ann1.extras["custom"] == "metadata" + +ann2 = result.items[1] +ASSERT ann2.name == "heart" +ASSERT ann2.clientId == "user-2" +``` + +--- + +## RSAN3b — get passes params as querystring + +**Spec requirement:** RSAN3b — Any `params` are sent in the querystring. + +Tests that optional params are sent as query parameters. + +### Setup +```pseudo +channel_name = "test-RSAN3b-params-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.annotations.get("msg-serial-1", params: { "limit": "50" }) +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.url.query_params["limit"] == "50" +``` diff --git a/uts/rest/unit/channel/get_message.md b/uts/rest/unit/channel/get_message.md new file mode 100644 index 000000000..e03aaca31 --- /dev/null +++ b/uts/rest/unit/channel/get_message.md @@ -0,0 +1,191 @@ +# REST Channel GetMessage Tests + +Spec points: `RSL11`, `RSL11a`, `RSL11a1`, `RSL11b`, `RSL11c` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure defined in `uts/test/rest/unit/helpers/mock_http.md`. + +--- + +## RSL11b — getMessage sends GET to correct endpoint + +**Test ID**: `rest/unit/RSL11b/get-correct-endpoint-0` + +**Spec requirement:** RSL11b — The SDK must send a GET request to the endpoint `/channels/{channelName}/messages/{serial}`. + +Tests that `getMessage()` sends a GET request to the correct URL. + +### Setup +```pseudo +channel_name = "test-RSL11b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { + "name": "evt", + "data": "hello", + "serial": "msg-serial-123", + "timestamp": 1700000000000 + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +result = AWAIT channel.getMessage("msg-serial-123") +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages/msg-serial-123" +ASSERT request.body IS null OR request.body IS empty +``` + +--- + +## RSL11c — getMessage returns decoded Message + +**Test ID**: `rest/unit/RSL11c/returns-decoded-message-0` + +**Spec requirement:** RSL11c — Returns the decoded `Message` object for the specified message serial. + +Tests that `getMessage()` returns a fully decoded `Message` with all fields populated. + +### Setup +```pseudo +channel_name = "test-RSL11c-${random_id()}" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { + "id": "msg-id-1", + "name": "test-event", + "data": "hello world", + "serial": "serial-xyz", + "clientId": "client-1", + "timestamp": 1700000000000, + "extras": { "push": { "notification": { "title": "Test" } } }, + "version": { + "serial": "version-serial-1", + "timestamp": 1700000000000, + "clientId": "client-1" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +msg = AWAIT channel.getMessage("serial-xyz") +``` + +### Assertions +```pseudo +ASSERT msg IS Message +ASSERT msg.id == "msg-id-1" +ASSERT msg.name == "test-event" +ASSERT msg.data == "hello world" +ASSERT msg.serial == "serial-xyz" +ASSERT msg.clientId == "client-1" +ASSERT msg.timestamp == 1700000000000 +ASSERT msg.version.serial == "version-serial-1" +``` + +--- + +## RSL11b — getMessage URL-encodes serial in path + +**Test ID**: `rest/unit/RSL11b/url-encodes-serial-1` + +**Spec requirement:** RSL11b — The serial must be URL-encoded when used in the request path. + +Tests that special characters in the serial are properly URL-encoded. + +### Setup +```pseudo +channel_name = "test-RSL11b-encode-${random_id()}" +captured_requests = [] +serial_with_special_chars = "serial/with:special+chars" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { + "name": "evt", + "data": "hello", + "serial": serial_with_special_chars + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.getMessage(serial_with_special_chars) +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages/" + encode_uri_component(serial_with_special_chars) +``` + +--- + +## RSL11a — getMessage with missing serial throws error + +**Test ID**: `rest/unit/RSL11a/missing-serial-error-0` + +**Spec requirement:** RSL11a — Takes a first argument of a `serial` string of the message to be retrieved. The serial must be present. + +Tests that calling `getMessage()` with an empty or missing serial throws an error. + +### Setup +```pseudo +channel_name = "test-RSL11a-error-${random_id()}" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps and Assertions +```pseudo +# Empty string serial +AWAIT channel.getMessage("") FAILS WITH error +ASSERT error.code == 40003 +``` diff --git a/uts/rest/unit/channel/history.md b/uts/rest/unit/channel/history.md new file mode 100644 index 000000000..e615bca4a --- /dev/null +++ b/uts/rest/unit/channel/history.md @@ -0,0 +1,341 @@ +# REST Channel History Tests + +Spec points: `RSL2`, `RSL2a`, `RSL2b`, `RSL2b1`, `RSL2b2`, `RSL2b3` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure defined in `/Users/paddy/data/worknew/dev/dart-experiments/uts/test/rest/unit/rest_client.md`. + +The mock must support: +- Handler-based configuration with `onConnectionAttempt` and `onRequest` callbacks +- Request capture via `captured_requests` arrays +- Request counting via `request_count` variables +- Response configuration with status, headers, and body + +See rest_client.md for the complete `MockHttpClient` interface specification. + +--- + +## RSL2a - History returns PaginatedResult + +**Test ID**: `rest/unit/RSL2a/returns-paginated-result-0` + +**Spec requirement:** The `history()` method must return a `PaginatedResult` object containing an array of `Message` objects. + +Tests that `history()` returns a `PaginatedResult` containing messages. + +### Setup +```pseudo +channel_name = "test-RSL2a-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "id": "msg1", "name": "event1", "data": "data1", "timestamp": 1000 }, + { "id": "msg2", "name": "event2", "data": "data2", "timestamp": 2000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +result = AWAIT channel.history() +``` + +### Assertions +```pseudo +ASSERT result IS PaginatedResult +ASSERT result.items IS List +ASSERT result.items.length == 2 + +ASSERT result.items[0] IS Message +ASSERT result.items[0].id == "msg1" +ASSERT result.items[0].name == "event1" +ASSERT result.items[0].data == "data1" +``` + +--- + +## RSL2b - History query parameters + +**Test ID**: `rest/unit/RSL2b/query-parameters-0` + +**Spec requirement:** History method parameters (start, end, direction, limit) must be encoded as query string parameters in the HTTP request. + +Tests that history parameters are correctly sent as query string. + +### Setup +```pseudo +channel_name = "test-RSL2b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Cases + +| ID | Parameter | Value | Expected Query | +|----|-----------|-------|----------------| +| 1 | start | `1234567890000` | `start=1234567890000` | +| 2 | end | `1234567899999` | `end=1234567899999` | +| 3 | direction | `"backwards"` | `direction=backwards` | +| 4 | direction | `"forwards"` | `direction=forwards` | +| 5 | limit | `50` | `limit=50` | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + captured_requests = [] + + params = {} + params[test_case.parameter] = test_case.value + + AWAIT channel.history(params) + + request = captured_requests[0] + ASSERT request.url.query_params[test_case.parameter] == str(test_case.value) +``` + +--- + +## RSL2b1 - Default direction is backwards + +**Test ID**: `rest/unit/RSL2b1/default-direction-backwards-0` + +**Spec requirement:** When the direction parameter is not specified, the default direction for history queries must be backwards (newest messages first). + +Tests that the default direction for history is backwards (newest first). + +### Setup +```pseudo +channel_name = "test-RSL2b1-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.history() # No direction specified +``` + +### Assertions +```pseudo +request = captured_requests[0] + +# Either direction param is absent (server default) or explicitly "backwards" +IF "direction" IN request.url.query_params: + ASSERT request.url.query_params["direction"] == "backwards" +# If absent, server defaults to backwards per spec +``` + +--- + +## RSL2b2 - Limit parameter + +**Test ID**: `rest/unit/RSL2b2/limit-parameter-0` + +**Spec requirement:** The limit parameter must control the maximum number of messages returned in a single history query. + +Tests that limit parameter restricts the number of returned items. + +### Setup +```pseudo +channel_name = "test-RSL2b2-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "id": "msg1", "name": "e", "data": "d", "timestamp": 1000 }, + { "id": "msg2", "name": "e", "data": "d", "timestamp": 2000 }, + { "id": "msg3", "name": "e", "data": "d", "timestamp": 3000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.history(limit: 10) + +request = captured_requests[0] +ASSERT request.url.query_params["limit"] == "10" +``` + +--- + +## RSL2b3 - Default limit is 100 + +**Test ID**: `rest/unit/RSL2b3/default-limit-hundred-0` + +**Spec requirement:** When the limit parameter is not specified, the default limit must be 100 messages. + +Tests that the default limit is 100 when not specified. + +### Setup +```pseudo +channel_name = "test-RSL2b3-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.history() # No limit specified +``` + +### Assertions +```pseudo +request = captured_requests[0] + +# Either limit param is absent (server default) or explicitly "100" +IF "limit" IN request.url.query_params: + ASSERT request.url.query_params["limit"] == "100" +# If absent, server defaults to 100 per spec +``` + +--- + +## RSL2 - History request URL format + +**Test ID**: `rest/unit/RSL2/request-url-format-0` + +**Spec requirement:** History requests must use the URL path `/channels//messages` with proper URL encoding of the channel name. + +Tests that history requests use the correct URL path. + +### Setup +```pseudo +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + request_count = request_count + 1 + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Cases + +| ID | Channel Name | Expected Path | +|----|--------------|---------------| +| 1 | `"test-RSL2-simple-${random_id()}"` | `/channels/test-RSL2-simple-.../messages` | +| 2 | `"test-RSL2-with:colon-${random_id()}"` | `/channels/test-RSL2-with%3Acolon-.../messages` | +| 3 | `"test-RSL2-with/slash-${random_id()}"` | `/channels/test-RSL2-with%2Fslash-.../messages` | +| 4 | `"test-RSL2-with space-${random_id()}"` | `/channels/test-RSL2-with%20space-.../messages` | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + captured_requests = [] + request_count = 0 + + channel = client.channels.get(test_case.channel_name) + AWAIT channel.history() + + ASSERT request_count == 1 + request = captured_requests[0] + ASSERT request.method == "GET" + ASSERT request.url.path == "/channels/${encode_uri_component(test_case.channel_name)}/messages" +``` + +--- + +## RSL2 - History with time range + +**Test ID**: `rest/unit/RSL2/history-time-range-1` + +**Spec requirement:** History queries must support start and end time parameters to retrieve messages within a specific time window. + +Tests combining start and end parameters for time-bounded queries. + +### Setup +```pseudo +channel_name = "test-RSL2-timerange-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "id": "msg1", "name": "e", "data": "d", "timestamp": 1500 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.history( + start: 1000, + end: 2000 +) +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.url.query_params["start"] == "1000" +ASSERT request.url.query_params["end"] == "2000" +``` diff --git a/uts/rest/unit/channel/idempotency.md b/uts/rest/unit/channel/idempotency.md new file mode 100644 index 000000000..9bf00c891 --- /dev/null +++ b/uts/rest/unit/channel/idempotency.md @@ -0,0 +1,410 @@ +# Idempotent Publishing Tests + +Spec points: `RSL1k`, `RSL1k1`, `RSL1k2`, `RSL1k3`, `RSL1k4`, `RSL1k5` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure defined in `/Users/paddy/data/worknew/dev/dart-experiments/uts/test/rest/unit/rest_client.md`. + +The mock must support: +- Handler-based configuration with `onConnectionAttempt` and `onRequest` callbacks +- Request capture via `captured_requests` arrays +- Request counting via `request_count` variables +- Response configuration with status, headers, and body + +See rest_client.md for the complete `MockHttpClient` interface specification. + +--- + +## RSL1k1 - idempotentRestPublishing default + +**Test ID**: `rest/unit/RSL1k1/idempotent-default-true-0` + +**Spec requirement:** The `idempotentRestPublishing` client option must default to `true` for library versions >= 1.2. + +Tests the default value of `idempotentRestPublishing` option. + +### Test Cases + +| ID | Library Version | Expected Default | +|----|-----------------|------------------| +| 1 | >= 1.2 | `true` | + +### Test Steps +```pseudo +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# Verify default value +ASSERT client.options.idempotentRestPublishing == true +``` + +--- + +## RSL1k2 - Message ID format when idempotent publishing enabled + +**Test ID**: `rest/unit/RSL1k2/message-id-format-0` + +**Spec requirement:** When `idempotentRestPublishing` is enabled, library-generated message IDs must follow the format `:` where base64 is a URL-safe base64-encoded random value and serial is a zero-based sequential integer. + +Tests that library-generated message IDs follow the `:` format. + +### Setup +```pseudo +channel_name = "test-RSL1k2-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + idempotentRestPublishing: true +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: "data") +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +ASSERT "id" IN body +message_id = body["id"] + +# Format: : +parts = message_id.split(":") +ASSERT parts.length == 2 + +# First part is base64-encoded (url-safe) +ASSERT parts[0] matches pattern "[A-Za-z0-9_-]+" +ASSERT parts[0].length >= 12 # At least 9 bytes base64 encoded + +# Second part is a serial number (starting from 0) +ASSERT parts[1] == "0" +``` + +--- + +## RSL1k2 - Serial increments for batch publish + +**Test ID**: `rest/unit/RSL1k2/serial-increments-batch-1` + +**Spec requirement:** When publishing multiple messages in a batch, all messages must share the same base ID with incrementing serial numbers starting from 0. + +Tests that serial numbers increment for each message in a batch. + +### Setup +```pseudo +channel_name = "test-RSL1k2-batch-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1", "s2", "s3"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + idempotentRestPublishing: true +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +messages = [ + Message(name: "event1", data: "data1"), + Message(name: "event2", data: "data2"), + Message(name: "event3", data: "data3") +] +AWAIT channel.publish(messages: messages) +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body) + +# All messages should share the same base but different serials +base_ids = [] +serials = [] + +FOR i, msg IN enumerate(body): + parts = msg["id"].split(":") + base_ids.append(parts[0]) + serials.append(int(parts[1])) + +# Same base for all messages in batch +ASSERT ALL base == base_ids[0] FOR base IN base_ids + +# Sequential serials starting from 0 +ASSERT serials == [0, 1, 2] +``` + +--- + +## RSL1k3 - Separate publishes get unique base IDs + +**Test ID**: `rest/unit/RSL1k3/unique-base-ids-0` + +**Spec requirement:** Each separate publish call must generate a new unique base ID, even for messages published to the same channel. + +Tests that separate publish calls generate unique base IDs. + +### Setup +```pseudo +channel_name = "test-RSL1k3-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + idempotentRestPublishing: true +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event1", data: "data1") +AWAIT channel.publish(name: "event2", data: "data2") +``` + +### Assertions +```pseudo +body1 = parse_json(captured_requests[0].body)[0] +body2 = parse_json(captured_requests[1].body)[0] + +base1 = body1["id"].split(":")[0] +base2 = body2["id"].split(":")[0] + +# Different publish calls should have different base IDs +ASSERT base1 != base2 +``` + +--- + +## RSL1k3 - No ID generated when idempotent publishing disabled + +**Test ID**: `rest/unit/RSL1k3/no-id-when-disabled-1` + +**Spec requirement:** When `idempotentRestPublishing` is false, the library must not automatically generate message IDs. + +Tests that message IDs are not automatically generated when disabled. + +### Setup +```pseudo +channel_name = "test-RSL1k3-disabled-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + idempotentRestPublishing: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: "data") +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +# No automatic ID should be added +ASSERT "id" NOT IN body +``` + +--- + +## RSL1k - Client-supplied ID preserved + +**Test ID**: `rest/unit/RSL1k/client-id-preserved-0` + +**Spec requirement:** Client-supplied message IDs must be preserved and transmitted exactly as provided, even when `idempotentRestPublishing` is enabled. + +Tests that client-supplied message IDs are not overwritten. + +### Setup +```pseudo +channel_name = "test-RSL1k-preserved-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + idempotentRestPublishing: true # Even with this enabled +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish( + message: Message(id: "my-custom-id", name: "event", data: "data") +) +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +# Client-supplied ID should be preserved exactly +ASSERT body["id"] == "my-custom-id" +``` + +--- + +## RSL1k2 - Same ID used on retry + +**Test ID**: `rest/unit/RSL1k2/same-id-on-retry-2` + +**Spec requirement:** When a publish request is retried after a failure, the same message ID(s) must be used to ensure idempotent behavior. + +Tests that the same message ID is used when retrying after failure. + +### Setup +```pseudo +channel_name = "test-RSL1k2-retry-${random_id()}" +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + request_count = request_count + 1 + + # First request fails with retryable error + IF request_count == 1: + req.respond_with(500, { "error": { "code": 50000 } }) + ELSE: + # Retry succeeds + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + idempotentRestPublishing: true +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: "data") +``` + +### Assertions +```pseudo +ASSERT request_count == 2 + +body1 = parse_json(captured_requests[0].body)[0] +body2 = parse_json(captured_requests[1].body)[0] + +# Same ID should be used for retry +ASSERT body1["id"] == body2["id"] +``` + +--- + +## RSL1k - Mixed client and library IDs in batch + +**Test ID**: `rest/unit/RSL1k/mixed-ids-in-batch-1` + +**Spec requirement:** In a batch publish, messages with client-supplied IDs must be preserved, while messages without IDs receive library-generated IDs using the standard format. + +Tests batch publishing with some messages having client IDs and some not. + +### Setup +```pseudo +channel_name = "test-RSL1k-mixed-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1", "s2", "s3"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + idempotentRestPublishing: true +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +messages = [ + Message(id: "client-id-1", name: "event1", data: "data1"), + Message(name: "event2", data: "data2"), # No ID - should be generated + Message(id: "client-id-2", name: "event3", data: "data3") +] +AWAIT channel.publish(messages: messages) +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body) + +# Client IDs preserved +ASSERT body[0]["id"] == "client-id-1" +ASSERT body[2]["id"] == "client-id-2" + +# Library-generated ID for middle message +ASSERT body[1]["id"] matches pattern "[A-Za-z0-9_-]+:[0-9]+" +``` diff --git a/uts/rest/unit/channel/message_versions.md b/uts/rest/unit/channel/message_versions.md new file mode 100644 index 000000000..6025ee72c --- /dev/null +++ b/uts/rest/unit/channel/message_versions.md @@ -0,0 +1,179 @@ +# REST Channel GetMessageVersions Tests + +Spec points: `RSL14`, `RSL14a`, `RSL14a1`, `RSL14b`, `RSL14c` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure defined in `uts/test/rest/unit/helpers/mock_http.md`. + +--- + +## RSL14b — getMessageVersions sends GET to correct endpoint + +**Test ID**: `rest/unit/RSL14b/get-correct-endpoint-0` + +**Spec requirement:** RSL14b — The SDK must send a GET request to the endpoint `/channels/{channelName}/messages/{serial}/versions`. + +Tests that `getMessageVersions()` sends a GET to the correct URL. + +### Setup +```pseudo +channel_name = "test-RSL14b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.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 } + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +result = AWAIT channel.getMessageVersions("msg-serial-1") +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages/msg-serial-1/versions" +``` + +--- + +## RSL14c — getMessageVersions returns PaginatedResult of Messages + +**Test ID**: `rest/unit/RSL14c/returns-paginated-result-0` + +**Spec requirement:** RSL14c — Returns a `PaginatedResult`. + +Tests that the response is parsed into a paginated result of decoded messages. + +### Setup +```pseudo +channel_name = "test-RSL14c-${random_id()}" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + "name": "evt", + "data": "updated-data", + "serial": "msg-serial-1", + "action": 1, + "version": { + "serial": "vs2", + "timestamp": 1700000002000, + "clientId": "user-1", + "description": "edit" + } + }, + { + "name": "evt", + "data": "original-data", + "serial": "msg-serial-1", + "action": 0, + "version": { + "serial": "vs1", + "timestamp": 1700000001000 + } + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +result = AWAIT channel.getMessageVersions("msg-serial-1") +``` + +### Assertions +```pseudo +ASSERT result IS PaginatedResult +ASSERT result.items.length == 2 + +ASSERT result.items[0] IS Message +ASSERT result.items[0].data == "updated-data" +ASSERT result.items[0].action == MessageAction.MESSAGE_UPDATE +ASSERT result.items[0].version.serial == "vs2" +ASSERT result.items[0].version.description == "edit" + +ASSERT result.items[1].data == "original-data" +ASSERT result.items[1].action == MessageAction.MESSAGE_CREATE +``` + +--- + +## RSL14a — getMessageVersions passes params as querystring + +**Test ID**: `rest/unit/RSL14a/params-as-querystring-0` + +**Spec requirement:** RSL14a — Takes an optional second argument of `Dict` params. + +Tests that optional params are sent as query parameters. + +### Setup +```pseudo +channel_name = "test-RSL14a-params-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +result = AWAIT channel.getMessageVersions("msg-serial-1", params: { + "direction": "backwards", + "limit": "10" +}) +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.url.query_params["direction"] == "backwards" +ASSERT request.url.query_params["limit"] == "10" +``` diff --git a/uts/rest/unit/channel/publish.md b/uts/rest/unit/channel/publish.md new file mode 100644 index 000000000..72017cbc4 --- /dev/null +++ b/uts/rest/unit/channel/publish.md @@ -0,0 +1,474 @@ +# REST Channel Publish Tests + +Spec points: `RSL1`, `RSL1a`, `RSL1b`, `RSL1c`, `RSL1d`, `RSL1e`, `RSL1h`, `RSL1i`, `RSL1j`, `RSL1l`, `RSL1m` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure defined in `/Users/paddy/data/worknew/dev/dart-experiments/uts/test/rest/unit/rest_client.md`. + +The mock must support: +- Handler-based configuration with `onConnectionAttempt` and `onRequest` callbacks +- Request capture via `captured_requests` arrays +- Request counting via `request_count` variables +- Response configuration with status, headers, and body + +See rest_client.md for the complete `MockHttpClient` interface specification. + +--- + +## RSL1a, RSL1b - Publish with name and data + +**Test ID**: `rest/unit/RSL1a/publish-name-and-data-0` + +| Spec | Requirement | +|------|-------------| +| RSL1a | Channel publish method must support publishing a single message with name and data | +| RSL1b | Single message publish must send the message in an array via POST to `/channels//messages` | + +Tests that `publish(name, data)` sends a single message. + +### Setup +```pseudo +channel_name = "test-RSL1a-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["serial1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "greeting", data: "hello") +``` + +### Assertions +```pseudo +request = captured_requests[0] + +# RSL1b - single message published +ASSERT request.method == "POST" +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages" + +body = parse_json(request.body) +ASSERT body IS List +# NOTE: Some SDKs send a single message as a plain JSON object rather than +# wrapping it in an array. The Ably API accepts both formats. SDKs MAY send +# a single message as either an object or a single-element array. +ASSERT body.length == 1 +ASSERT body[0]["name"] == "greeting" +ASSERT body[0]["data"] == "hello" +``` + +--- + +## RSL1a, RSL1c - Publish with Message array + +**Test ID**: `rest/unit/RSL1a/publish-message-array-1` + +| Spec | Requirement | +|------|-------------| +| RSL1a | Channel publish method must support publishing an array of Message objects | +| RSL1c | Publishing multiple messages must send all messages in a single HTTP request | + +Tests that `publish(messages: [...])` sends all messages in a single request. + +### Setup +```pseudo +channel_name = "test-RSL1c-${random_id()}" +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + request_count = request_count + 1 + req.respond_with(201, { "serials": ["s1", "s2", "s3"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +messages = [ + Message(name: "event1", data: "data1"), + Message(name: "event2", data: { "key": "value" }), + Message(name: "event3", data: bytes([0x01, 0x02, 0x03])) +] +AWAIT channel.publish(messages: messages) +``` + +### Assertions +```pseudo +# RSL1c - single request for array +ASSERT request_count == 1 + +request = captured_requests[0] +body = parse_json(request.body) + +ASSERT body.length == 3 +ASSERT body[0]["name"] == "event1" +ASSERT body[0]["data"] == "data1" +ASSERT body[1]["name"] == "event2" +ASSERT body[1]["data"] == { "key": "value" } +# Note: binary data encoding tested separately in encoding tests +``` + +--- + +## RSL1e - Null name and data + +**Test ID**: `rest/unit/RSL1e/null-name-and-data-0` + +**Spec requirement:** Null values for name and data must be omitted from the transmitted message JSON, not sent as JSON null. + +Tests that null values are omitted from the transmitted message. + +### Setup +```pseudo +channel_name = "test-RSL1e-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Cases + +| ID | name | data | Expected body | +|----|------|------|---------------| +| 1 | `null` | `"hello"` | `[{"data": "hello"}]` | +| 2 | `"event"` | `null` | `[{"name": "event"}]` | +| 3 | `null` | `null` | `[{}]` | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + captured_requests = [] + + AWAIT channel.publish(name: test_case.name, data: test_case.data) + + body = parse_json(captured_requests[0].body) + ASSERT body == [test_case.expected_body] + ASSERT "name" NOT IN body[0] IF test_case.name IS null + ASSERT "data" NOT IN body[0] IF test_case.data IS null +``` + +--- + +## RSL1h - publish(name, data) signature + +**Test ID**: `rest/unit/RSL1h/publish-signature-0` + +**Spec requirement:** The publish method must support a two-argument signature `publish(name, data)` for publishing a single message. + +Tests that the two-argument form takes no additional arguments and works correctly. + +### Setup +```pseudo +channel_name = "test-RSL1h-${random_id()}" +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + request_count = request_count + 1 + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# This is a compile-time/type-system test in strongly-typed languages +# The API should accept exactly (name, data) with no extras +AWAIT channel.publish(name: "event", data: "payload") +# If language allows, verify that extra positional args are rejected at compile time +``` + +### Assertions +```pseudo +ASSERT request_count == 1 +body = parse_json(captured_requests[0].body) +ASSERT body[0]["name"] == "event" +ASSERT body[0]["data"] == "payload" +``` + +--- + +## RSL1i - Message size limit + +**Test ID**: `rest/unit/RSL1i/message-size-limit-0` + +**Spec requirement:** Messages exceeding the `maxMessageSize` client option must be rejected before transmission with error code 40009. + +Tests that messages exceeding `maxMessageSize` are rejected with error 40009. + +### Setup +```pseudo +channel_name = "test-RSL1i-${random_id()}" +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + request_count = request_count + 1 + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + maxMessageSize: 1024 # 1KB limit for testing +)) +channel = client.channels.get(channel_name) +``` + +### Test Cases + +| ID | Message size | Expected | +|----|--------------|----------| +| 1 | 1000 bytes | Success (under limit) | +| 2 | 1024 bytes | Success (at limit) | +| 3 | 1025 bytes | Error 40009 | +| 4 | 10000 bytes | Error 40009 | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + captured_requests = [] + request_count = 0 + + large_data = "x" * test_case.size + + IF test_case.expected == "Success": + AWAIT channel.publish(name: "event", data: large_data) + ASSERT request_count == 1 + ELSE: + ASSERT channel.publish(name: "event", data: large_data) THROWS AblyException WITH: + code == 40009 + ASSERT request_count == 0 # Request never sent +``` + +--- + +## RSL1j - All Message attributes transmitted + +**Test ID**: `rest/unit/RSL1j/all-attributes-transmitted-0` + +**Spec requirement:** All valid Message attributes (name, data, id, clientId, extras) must be included in the transmitted message payload. + +Tests that all valid Message attributes are included in the encoded message. + +### Setup +```pseudo +channel_name = "test-RSL1j-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +message = Message( + name: "test-event", + data: "test-data", + clientId: "explicit-client-id", # RSL1m tests cover whether this should be sent + id: "custom-message-id", + extras: { "push": { "notification": { "title": "Test" } } } +) + +AWAIT channel.publish(message: message) +``` + +### Assertions +```pseudo +body = parse_json(captured_requests[0].body)[0] + +ASSERT body["name"] == "test-event" +ASSERT body["data"] == "test-data" +ASSERT body["id"] == "custom-message-id" +ASSERT body["extras"]["push"]["notification"]["title"] == "Test" +# clientId handling is tested separately in RSL1m tests +``` + +--- + +## RSL1l - Publish params as querystring + +**Test ID**: `rest/unit/RSL1l/params-as-querystring-0` + +**Spec requirement:** Additional params passed to the publish method must be sent as query string parameters in the HTTP request. + +Tests that additional params are sent as querystring parameters. + +### Setup +```pseudo +channel_name = "test-RSL1l-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +params = { + "customParam": "customValue", + "anotherParam": "123" +} + +AWAIT channel.publish( + message: Message(name: "event", data: "data"), + params: params +) +``` + +### Assertions +```pseudo +request = captured_requests[0] + +ASSERT request.url.query_params["customParam"] == "customValue" +ASSERT request.url.query_params["anotherParam"] == "123" +``` + +--- + +## RSL1m - ClientId not set from library clientId + +**Test ID**: `rest/unit/RSL1m/clientid-not-injected-0` + +| Spec | Requirement | +|------|-------------| +| RSL1m1 | Library must not automatically inject its clientId into messages that don't have one | +| RSL1m2 | Explicit message clientId must be preserved even if it matches library clientId | +| RSL1m3 | Unidentified clients (no library clientId) can publish messages with explicit clientId | + +Tests that the library does not automatically set `Message.clientId` from the client's configured `clientId`. + +### Setup +```pseudo +channel_name = "test-RSL1m-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + clientId: "library-client-id" +)) +channel = client.channels.get(channel_name) +``` + +### Test Cases (RSL1m1-RSL1m3) + +| ID | Spec | Message clientId | Library clientId | Expected in request | +|----|------|------------------|------------------|---------------------| +| RSL1m1 | Message with no clientId, library has clientId | `null` | `"lib-client"` | clientId absent | +| RSL1m2 | Message clientId matches library clientId | `"lib-client"` | `"lib-client"` | `"lib-client"` | +| RSL1m3 | Unidentified client, message has clientId | `"msg-client"` | `null` | `"msg-client"` | + +### Test Steps +```pseudo +channel_name_m1 = "test-RSL1m1-${random_id()}" +channel_name_m2 = "test-RSL1m2-${random_id()}" +channel_name_m3 = "test-RSL1m3-${random_id()}" + +# RSL1m1 - Message with no clientId +captured_requests = [] + +client_with_id = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + clientId: "lib-client" +)) +AWAIT client_with_id.channels.get(channel_name_m1).publish(name: "e", data: "d") + +body = parse_json(captured_requests[0].body)[0] +ASSERT "clientId" NOT IN body # Library should not inject its clientId + + +# RSL1m2 - Message clientId matches library +captured_requests = [] + +AWAIT client_with_id.channels.get(channel_name_m2).publish( + message: Message(name: "e", data: "d", clientId: "lib-client") +) + +body = parse_json(captured_requests[0].body)[0] +ASSERT body["clientId"] == "lib-client" # Explicit clientId preserved + + +# RSL1m3 - Unidentified client with message clientId +captured_requests = [] + +client_no_id = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +AWAIT client_no_id.channels.get(channel_name_m3).publish( + message: Message(name: "e", data: "d", clientId: "msg-client") +) + +body = parse_json(captured_requests[0].body)[0] +ASSERT body["clientId"] == "msg-client" +``` + +### Note +RSL1m4 (clientId mismatch rejection) requires an integration test as the server performs the validation. diff --git a/uts/rest/unit/channel/publish_result.md b/uts/rest/unit/channel/publish_result.md new file mode 100644 index 000000000..e28498b63 --- /dev/null +++ b/uts/rest/unit/channel/publish_result.md @@ -0,0 +1,145 @@ +# REST Channel Publish Result Tests + +Spec points: `RSL1n`, `RSL1n1`, `PBR1`, `PBR2a` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure defined in `uts/test/rest/unit/helpers/mock_http.md`. + +--- + +## RSL1n — publish() returns PublishResult with serials (single message) + +**Test ID**: `rest/unit/RSL1n/publish-result-single-message-0` + +| Spec | Requirement | +|------|-------------| +| RSL1n | On success, returns a `PublishResult` containing the serials of the published messages | +| PBR2a | `serials` is an array of `String?` corresponding 1:1 to the messages that were published | + +Tests that `publish()` returns a `PublishResult` with a serials array matching the published messages. + +### Setup +```pseudo +channel_name = "test-RSL1n-single-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["serial-abc"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +result = AWAIT channel.publish(name: "event", data: "hello") +``` + +### Assertions +```pseudo +ASSERT result IS PublishResult +ASSERT result.serials IS List +ASSERT result.serials.length == 1 +ASSERT result.serials[0] == "serial-abc" +``` + +--- + +## RSL1n — publish() returns PublishResult with serials (batch) + +**Test ID**: `rest/unit/RSL1n/publish-result-batch-serials-1` + +**Spec requirement:** RSL1n — When publishing multiple messages, the returned `PublishResult.serials` array has one entry per message, corresponding 1:1. + +Tests that batch publish returns serials matching each published message. + +### Setup +```pseudo +channel_name = "test-RSL1n-batch-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1", "s2", "s3"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +messages = [ + Message(name: "event1", data: "data1"), + Message(name: "event2", data: "data2"), + Message(name: "event3", data: "data3") +] +result = AWAIT channel.publish(messages: messages) +``` + +### Assertions +```pseudo +ASSERT result IS PublishResult +ASSERT result.serials.length == 3 +ASSERT result.serials[0] == "s1" +ASSERT result.serials[1] == "s2" +ASSERT result.serials[2] == "s3" +``` + +--- + +## RSL1n — publish() returns PublishResult with null serial (conflated message) + +**Test ID**: `rest/unit/RSL1n/publish-result-null-serial-2` + +| Spec | Requirement | +|------|-------------| +| PBR2a | A serial may be null if the message was discarded due to a configured conflation rule | + +Tests that null serials in the response are preserved in the `PublishResult`. + +### Setup +```pseudo +channel_name = "test-RSL1n-null-${random_id()}" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(201, { "serials": [null, "s2"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +messages = [ + Message(name: "event1", data: "data1"), + Message(name: "event2", data: "data2") +] +result = AWAIT channel.publish(messages: messages) +``` + +### Assertions +```pseudo +ASSERT result.serials.length == 2 +ASSERT result.serials[0] IS null +ASSERT result.serials[1] == "s2" +``` diff --git a/uts/rest/unit/channel/rest_channel_attributes.md b/uts/rest/unit/channel/rest_channel_attributes.md new file mode 100644 index 000000000..f7f9b8671 --- /dev/null +++ b/uts/rest/unit/channel/rest_channel_attributes.md @@ -0,0 +1,459 @@ +# REST Channel Attributes and Methods + +Spec points: `RSL7`, `RSL8`, `RSL8a`, `RSL9`, `CHD2`, `CHD2a`, `CHD2b`, `CHS2`, `CHS2a`, `CHS2b`, `CHO2`, `CHO2a`, `CHM2`, `CHM2a`, `CHM2b`, `CHM2c`, `CHM2d`, `CHM2e`, `CHM2f`, `CHM2g`, `CHM2h` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. + +--- + +## RSL9 - RestChannel name attribute + +**Test ID**: `rest/unit/RSL9/channel-name-attribute-0` + +**Spec requirement:** `RestChannel#name` attribute is a string containing the channel's name. + +Tests that the channel name attribute returns the name used when getting the channel. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret" +)) +``` + +### Assertions +```pseudo +channel = client.channels.get("my-channel") +ASSERT channel.name == "my-channel" + +# Also works with special characters +channel2 = client.channels.get("namespace:channel-name") +ASSERT channel2.name == "namespace:channel-name" +``` + +--- + +## RSL7 - setOptions updates channel options + +**Test ID**: `rest/unit/RSL7/setoptions-updates-options-0` + +**Spec requirement:** `RestChannel#setOptions` takes a `ChannelOptions` object and sets or updates the stored channel options, then indicates success. + +Tests that setOptions updates the stored channel options. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +channel = client.channels.get("test-RSL7") +``` + +### Test Steps +```pseudo +AWAIT channel.setOptions(RestChannelOptions()) +``` + +### Assertions +```pseudo +# setOptions completes without error (indicates success) +# No exception thrown +``` + +--- + +## RSL7 - setOptions stores new options + +**Test ID**: `rest/unit/RSL7/setoptions-stores-options-1` + +**Spec requirement:** `RestChannel#setOptions` sets or updates the stored channel options. + +Tests that options set via setOptions are retained and accessible. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +channel = client.channels.get("test-RSL7-store") +``` + +### Test Steps +```pseudo +# Set options — the effect of channel options is primarily on encryption +# (RSL5) which is not yet implemented. For now, verify the call succeeds +# and options are stored by observing they can be set without error. +AWAIT channel.setOptions(RestChannelOptions()) +``` + +### Assertions +```pseudo +# setOptions completes without error +# Implementation note: once encryption is supported (RSL5), this test +# should verify that cipher params set via setOptions are applied to +# subsequent publish/history operations. +``` + +--- + +## RSL8 - status makes GET request to correct endpoint + +**Test ID**: `rest/unit/RSL8/status-get-correct-endpoint-0` + +**Spec requirement:** `RestChannel#status` function makes an HTTP GET request to `/channels/`. + +Tests that calling status() sends a GET request to the correct URL path. + +### Setup +```pseudo +captured_request = null + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_request = req + req.respond_with(200, { + "channelId": "test-RSL8", + "status": { + "isActive": true, + "occupancy": { + "metrics": { + "connections": 0, + "publishers": 0, + "subscribers": 0, + "presenceConnections": 0, + "presenceMembers": 0, + "presenceSubscribers": 0 + } + } + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +channel = client.channels.get("test-RSL8") +``` + +### Test Steps +```pseudo +result = AWAIT channel.status() +``` + +### Assertions +```pseudo +# Correct HTTP method and path +ASSERT captured_request IS NOT null +ASSERT captured_request.method == "GET" +ASSERT captured_request.url.path ENDS_WITH "/channels/test-RSL8" +``` + +--- + +## RSL8 - status with special characters in channel name + +**Test ID**: `rest/unit/RSL8/status-special-chars-encoded-1` + +**Spec requirement:** The channel ID in the URL must be properly encoded. + +Tests that channel names with special characters are URL-encoded in the status request. + +### Setup +```pseudo +captured_request = null + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_request = req + req.respond_with(200, { + "channelId": "namespace:my channel", + "status": { + "isActive": true, + "occupancy": { + "metrics": { + "connections": 0, + "publishers": 0, + "subscribers": 0, + "presenceConnections": 0, + "presenceMembers": 0, + "presenceSubscribers": 0 + } + } + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +channel = client.channels.get("namespace:my channel") +``` + +### Test Steps +```pseudo +result = AWAIT channel.status() +``` + +### Assertions +```pseudo +ASSERT captured_request IS NOT null +ASSERT captured_request.method == "GET" +# Channel name must be URI-encoded in the path +ASSERT captured_request.url.path ENDS_WITH "/channels/" + encode_uri_component("namespace:my channel") +``` + +--- + +## RSL8a - status returns ChannelDetails object + +**Test ID**: `rest/unit/RSL8a/status-returns-channel-details-0` + +**Spec requirement:** `RestChannel#status` returns a `ChannelDetails` object. + +Tests that the status() response is parsed into a ChannelDetails object with correct attributes. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { + "channelId": "test-RSL8a", + "status": { + "isActive": true, + "occupancy": { + "metrics": { + "connections": 5, + "publishers": 2, + "subscribers": 3, + "presenceConnections": 1, + "presenceMembers": 1, + "presenceSubscribers": 0 + } + } + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +channel = client.channels.get("test-RSL8a") +``` + +### Test Steps +```pseudo +result = AWAIT channel.status() +``` + +### Assertions +```pseudo +# Result is a ChannelDetails object (CHD1) +ASSERT result IS ChannelDetails + +# CHD2a: channelId attribute +ASSERT result.channelId == "test-RSL8a" + +# CHD2b: status attribute is a ChannelStatus (CHS1) +ASSERT result.status IS NOT null +ASSERT result.status.isActive == true + +# CHS2b: occupancy metrics +ASSERT result.status.occupancy IS NOT null +ASSERT result.status.occupancy.metrics.connections == 5 +ASSERT result.status.occupancy.metrics.publishers == 2 +ASSERT result.status.occupancy.metrics.subscribers == 3 +``` + +--- + +## CHD2, CHS2, CHO2, CHM2 - status() response parses all ChannelMetrics fields + +**Test ID**: `rest/unit/CHM2/parses-all-metrics-fields-0` + +| Spec | Requirement | +|------|-------------| +| CHD2 | ChannelDetails attributes: channelId (CHD2a), status (CHD2b) | +| CHS2 | ChannelStatus attributes: isActive (CHS2a), occupancy (CHS2b) | +| CHO2 | ChannelOccupancy attributes: metrics (CHO2a) | +| CHM2 | ChannelMetrics attributes: connections (CHM2a), presenceConnections (CHM2b), presenceMembers (CHM2c), presenceSubscribers (CHM2d), publishers (CHM2e), subscribers (CHM2f), objectPublishers (CHM2g), objectSubscribers (CHM2h) | + +Tests that status() parses the complete set of ChannelMetrics fields from the response, including the newer objectPublishers and objectSubscribers fields. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { + "channelId": "test-CHM2-all-fields", + "status": { + "isActive": true, + "occupancy": { + "metrics": { + "connections": 10, + "presenceConnections": 7, + "presenceMembers": 4, + "presenceSubscribers": 3, + "publishers": 6, + "subscribers": 8, + "objectPublishers": 2, + "objectSubscribers": 5 + } + } + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +channel = client.channels.get("test-CHM2-all-fields") +``` + +### Test Steps +```pseudo +result = AWAIT channel.status() +``` + +### Assertions +```pseudo +# CHD2a: channelId +ASSERT result.channelId == "test-CHM2-all-fields" + +# CHD2b + CHS2a: status.isActive +ASSERT result.status IS NOT null +ASSERT result.status.isActive == true + +# CHS2b + CHO2a: occupancy.metrics +ASSERT result.status.occupancy IS NOT null +ASSERT result.status.occupancy.metrics IS NOT null + +metrics = result.status.occupancy.metrics + +# CHM2a: connections +ASSERT metrics.connections == 10 + +# CHM2b: presenceConnections +ASSERT metrics.presenceConnections == 7 + +# CHM2c: presenceMembers +ASSERT metrics.presenceMembers == 4 + +# CHM2d: presenceSubscribers +ASSERT metrics.presenceSubscribers == 3 + +# CHM2e: publishers +ASSERT metrics.publishers == 6 + +# CHM2f: subscribers +ASSERT metrics.subscribers == 8 + +# CHM2g: objectPublishers +ASSERT metrics.objectPublishers == 2 + +# CHM2h: objectSubscribers +ASSERT metrics.objectSubscribers == 5 +``` + +--- + +## CHM2 - status() response with zero and missing metric fields + +**Test ID**: `rest/unit/CHM2/zero-and-missing-metrics-1` + +**Spec requirement:** ChannelMetrics fields (CHM2a-h) are integers. When the server response contains zero values or omits newer fields, the parsed result should default missing fields to 0. + +Tests that status() handles zero-valued and absent metric fields gracefully, defaulting missing fields to 0. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + # Response omits objectPublishers and objectSubscribers (CHM2g, CHM2h) + # to simulate an older server that does not include these fields. + # All other metrics are explicitly zero. + req.respond_with(200, { + "channelId": "test-CHM2-defaults", + "status": { + "isActive": false, + "occupancy": { + "metrics": { + "connections": 0, + "presenceConnections": 0, + "presenceMembers": 0, + "presenceSubscribers": 0, + "publishers": 0, + "subscribers": 0 + } + } + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +channel = client.channels.get("test-CHM2-defaults") +``` + +### Test Steps +```pseudo +result = AWAIT channel.status() +``` + +### Assertions +```pseudo +# CHD2a: channelId +ASSERT result.channelId == "test-CHM2-defaults" + +# CHS2a: isActive can be false +ASSERT result.status.isActive == false + +metrics = result.status.occupancy.metrics + +# CHM2a-f: explicit zero values are parsed correctly +ASSERT metrics.connections == 0 +ASSERT metrics.presenceConnections == 0 +ASSERT metrics.presenceMembers == 0 +ASSERT metrics.presenceSubscribers == 0 +ASSERT metrics.publishers == 0 +ASSERT metrics.subscribers == 0 + +# CHM2g-h: missing fields default to 0 +ASSERT metrics.objectPublishers == 0 +ASSERT metrics.objectSubscribers == 0 +``` diff --git a/uts/rest/unit/channel/update_delete_message.md b/uts/rest/unit/channel/update_delete_message.md new file mode 100644 index 000000000..31428e156 --- /dev/null +++ b/uts/rest/unit/channel/update_delete_message.md @@ -0,0 +1,552 @@ +# REST Channel UpdateMessage/DeleteMessage/AppendMessage Tests + +Spec points: `RSL15`, `RSL15a`, `RSL15b`, `RSL15b1`, `RSL15b7`, `RSL15c`, `RSL15d`, `RSL15e`, `RSL15f` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure defined in `uts/test/rest/unit/helpers/mock_http.md`. + +--- + +## RSL15b, RSL15b1 — updateMessage sends PATCH with action MESSAGE_UPDATE + +**Test ID**: `rest/unit/RSL15b/update-sends-patch-update-0` + +| Spec | Requirement | +|------|-------------| +| RSL15b | The SDK must send a PATCH to `/channels/{channelName}/messages/{serial}` | +| RSL15b1 | `action` set to `MESSAGE_UPDATE` for `updateMessage()` | + +Tests that `updateMessage()` sends a PATCH request with the correct action. + +### Setup +```pseudo +channel_name = "test-RSL15-update-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { "versionSerial": "vs1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.updateMessage( + Message(serial: "msg-serial-1", name: "updated", data: "new-data") +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "PATCH" +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages/msg-serial-1" + +body = parse_json(request.body) +ASSERT body["action"] == 1 # MESSAGE_UPDATE numeric value +ASSERT body["name"] == "updated" +ASSERT body["data"] == "new-data" +``` + +--- + +## RSL15b, RSL15b1 — deleteMessage sends PATCH with action MESSAGE_DELETE + +**Test ID**: `rest/unit/RSL15b/delete-sends-patch-delete-1` + +| Spec | Requirement | +|------|-------------| +| RSL15b | The SDK must send a PATCH to `/channels/{channelName}/messages/{serial}` | +| RSL15b1 | `action` set to `MESSAGE_DELETE` for `deleteMessage()` | + +Tests that `deleteMessage()` sends a PATCH request with the correct action. + +### Setup +```pseudo +channel_name = "test-RSL15-delete-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { "versionSerial": "vs1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.deleteMessage( + Message(serial: "msg-serial-1") +) +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.method == "PATCH" +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages/msg-serial-1" + +body = parse_json(request.body) +ASSERT body["action"] == 2 # MESSAGE_DELETE numeric value +``` + +--- + +## RSL15b, RSL15b1 — appendMessage sends PATCH with action MESSAGE_APPEND + +**Test ID**: `rest/unit/RSL15b/append-sends-patch-append-2` + +| Spec | Requirement | +|------|-------------| +| RSL15b | The SDK must send a PATCH to `/channels/{channelName}/messages/{serial}` | +| RSL15b1 | `action` set to `MESSAGE_APPEND` for `appendMessage()` | + +Tests that `appendMessage()` sends a PATCH request with the correct action. + +### Setup +```pseudo +channel_name = "test-RSL15-append-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { "versionSerial": "vs1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.appendMessage( + Message(serial: "msg-serial-1", data: "appended-data") +) +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.method == "PATCH" +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages/msg-serial-1" + +body = parse_json(request.body) +ASSERT body["action"] == 5 # MESSAGE_APPEND numeric value +ASSERT body["data"] == "appended-data" +``` + +--- + +## RSL15b7 — version set to MessageOperation when provided + +**Test ID**: `rest/unit/RSL15b7/version-set-with-operation-0` + +**Spec requirement:** RSL15b7 — `version` is set to the `MessageOperation` object if provided. + +Tests that the `version` field in the request body contains the MessageOperation fields. + +### Setup +```pseudo +channel_name = "test-RSL15b7-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { "versionSerial": "vs1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.updateMessage( + Message(serial: "s1", data: "updated"), + operation: MessageOperation( + clientId: "user1", + description: "fixed typo", + metadata: { "reason": "typo" } + ) +) +``` + +### Assertions +```pseudo +body = parse_json(captured_requests[0].body) +ASSERT "version" IN body +ASSERT body["version"]["clientId"] == "user1" +ASSERT body["version"]["description"] == "fixed typo" +ASSERT body["version"]["metadata"]["reason"] == "typo" +``` + +--- + +## RSL15b7 — version absent when no MessageOperation provided + +**Test ID**: `rest/unit/RSL15b7/version-absent-no-operation-1` + +**Spec requirement:** RSL15b7 — `version` is only set when a `MessageOperation` is provided. + +Tests that `version` is omitted from the request body when no operation is given. + +### Setup +```pseudo +channel_name = "test-RSL15b7-absent-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { "versionSerial": "vs1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.updateMessage( + Message(serial: "s1", data: "updated") +) +``` + +### Assertions +```pseudo +body = parse_json(captured_requests[0].body) +ASSERT "version" NOT IN body +``` + +--- + +## RSL15c — does not mutate user-supplied Message + +**Test ID**: `rest/unit/RSL15c/no-mutate-user-message-0` + +**Spec requirement:** RSL15c — The SDK must not mutate the user-supplied `Message` object. + +Tests that the original message object is unchanged after calling `updateMessage()`. + +### Setup +```pseudo +channel_name = "test-RSL15c-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { "versionSerial": "vs1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +original_msg = Message(serial: "s1", name: "orig", data: "original-data") + +AWAIT channel.updateMessage(original_msg) +``` + +### Assertions +```pseudo +# Original message must not have been mutated +ASSERT original_msg.action IS null # No action was set on original +ASSERT original_msg.name == "orig" +ASSERT original_msg.data == "original-data" + +# But the request body should contain the action +body = parse_json(captured_requests[0].body) +ASSERT body["action"] == 1 # MESSAGE_UPDATE +``` + +--- + +## RSL15e — returns UpdateDeleteResult on success + +**Test ID**: `rest/unit/RSL15e/returns-update-delete-result-0` + +| Spec | Requirement | +|------|-------------| +| RSL15e | On success, returns an `UpdateDeleteResult` object | +| UDR2a | `versionSerial` `String?` — the new version serial of the updated/deleted message | + +Tests that the response is parsed into an `UpdateDeleteResult`. + +### Setup +```pseudo +channel_name = "test-RSL15e-${random_id()}" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "versionSerial": "version-serial-abc" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +result = AWAIT channel.updateMessage( + Message(serial: "s1", data: "updated") +) +``` + +### Assertions +```pseudo +ASSERT result IS UpdateDeleteResult +ASSERT result.versionSerial == "version-serial-abc" +``` + +--- + +## RSL15e — UpdateDeleteResult with null versionSerial + +**Test ID**: `rest/unit/RSL15e/null-version-serial-1` + +**Spec requirement:** UDR2a — `versionSerial` will be null if the message was superseded by a subsequent update before it could be published. + +Tests that a null `versionSerial` in the response is preserved. + +### Setup +```pseudo +channel_name = "test-RSL15e-null-${random_id()}" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "versionSerial": null }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +result = AWAIT channel.updateMessage( + Message(serial: "s1", data: "updated") +) +``` + +### Assertions +```pseudo +ASSERT result IS UpdateDeleteResult +ASSERT result.versionSerial IS null +``` + +--- + +## RSL15f — params sent as querystring + +**Test ID**: `rest/unit/RSL15f/params-sent-as-querystring-0` + +**Spec requirement:** RSL15f — Any params provided in the third argument must be sent in the querystring, with values stringified. + +Tests that optional params are sent as query parameters. + +### Setup +```pseudo +channel_name = "test-RSL15f-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { "versionSerial": "vs1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.updateMessage( + Message(serial: "s1", data: "updated"), + params: { "key": "value", "num": "42" } +) +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.url.query_params["key"] == "value" +ASSERT request.url.query_params["num"] == "42" +``` + +--- + +## RSL15a — serial required, throws error if missing + +**Test ID**: `rest/unit/RSL15a/serial-required-throws-error-0` + +**Spec requirement:** RSL15a — Takes a first argument of a `Message` object which must contain a populated `serial` field. + +Tests that calling update/delete/append without a serial in the message throws an error. + +### Setup +```pseudo +channel_name = "test-RSL15a-${random_id()}" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "versionSerial": "vs1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps and Assertions +```pseudo +# updateMessage without serial +AWAIT channel.updateMessage(Message(name: "x", data: "y")) FAILS WITH error +ASSERT error.code == 40003 + +# deleteMessage without serial +AWAIT channel.deleteMessage(Message(name: "x")) FAILS WITH error +ASSERT error.code == 40003 + +# appendMessage without serial +AWAIT channel.appendMessage(Message(data: "y")) FAILS WITH error +ASSERT error.code == 40003 +``` + +--- + +## RSL15d — request body encoded per RSL4 (message data encoding) + +**Test ID**: `rest/unit/RSL15d/body-encoded-per-rsl4-0` + +| Spec | Requirement | +|------|-------------| +| RSL15d | The request body must be encoded to the appropriate format per RSC8 | +| RSL15b | Request body is a `Message` object encoded per RSL4 | + +Tests that message data is encoded following the same rules as regular publish. + +### Setup +```pseudo +channel_name = "test-RSL15d-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { "versionSerial": "vs1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# JSON object data should be encoded per RSL4 +AWAIT channel.updateMessage( + Message(serial: "s1", data: { "key": "value" }) +) +``` + +### Assertions +```pseudo +body = parse_json(captured_requests[0].body) + +# JSON data should be JSON-encoded as a string with encoding field +ASSERT body["data"] IS String # JSON-encoded string +ASSERT body["encoding"] == "json" +ASSERT parse_json(body["data"]) == { "key": "value" } +``` + +--- + +## RSL15b — serial URL-encoded in path + +**Test ID**: `rest/unit/RSL15b/serial-url-encoded-path-3` + +**Spec requirement:** RSL15b — The serial in the PATCH URL must be properly URL-encoded. + +Tests that special characters in the message serial are URL-encoded in the request path. + +### Setup +```pseudo +channel_name = "test-RSL15b-encode-${random_id()}" +captured_requests = [] +serial_with_special_chars = "serial/special:chars" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { "versionSerial": "vs1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.updateMessage( + Message(serial: serial_with_special_chars, data: "updated") +) +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages/" + encode_uri_component(serial_with_special_chars) +``` diff --git a/uts/rest/unit/channels_collection.md b/uts/rest/unit/channels_collection.md new file mode 100644 index 000000000..8c98cb509 --- /dev/null +++ b/uts/rest/unit/channels_collection.md @@ -0,0 +1,277 @@ +# REST Channels Collection Tests + +Spec points: `RSN1`, `RSN2`, `RSN3a`, `RSN3b`, `RSN3c`, `RSN4a`, `RSN4b` + +## Test Type +Unit test - no network calls required + +These tests verify the REST channels collection management functionality. No mock infrastructure is needed as these tests focus on the in-memory collection behavior. + +--- + +## RSN1 - Channels collection accessible via RestClient + +**Test ID**: `rest/unit/RSN1/channels-collection-accessible-0` + +**Spec requirement:** `Channels` is a collection of `RestChannel` objects accessible through `RestClient#channels`. + +Tests that the Rest client exposes a channels collection. + +### Setup +```pseudo +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Assertions +```pseudo +ASSERT client.channels IS RestChannels +ASSERT client.channels IS NOT null +``` + +--- + +## RSN2 - Check if channel exists + +**Test ID**: `rest/unit/RSN2/check-channel-exists-0` + +**Spec requirement:** Methods should exist to check if a channel exists or iterate through the existing channels. + +Tests the `exists()` method returns correct boolean for existing and non-existing channels. + +### Setup +```pseudo +channel_name = "test-RSN2-${random_id()}" + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Before creating any channel +exists_before = client.channels.exists(channel_name) + +# Create the channel +channel = client.channels.get(channel_name) + +# After creating the channel +exists_after = client.channels.exists(channel_name) + +# Check for non-existent channel +other_channel_name = "test-RSN2-other-${random_id()}" +exists_other = client.channels.exists(other_channel_name) +``` + +### Assertions +```pseudo +ASSERT exists_before == false +ASSERT exists_after == true +ASSERT exists_other == false +``` + +--- + +## RSN2 - Iterate through existing channels + +**Test ID**: `rest/unit/RSN2/iterate-channels-1` + +**Spec requirement:** Methods should exist to check if a channel exists or iterate through the existing channels. + +Tests that channels can be iterated. + +### Setup +```pseudo +channel_name_a = "test-RSN2-a-${random_id()}" +channel_name_b = "test-RSN2-b-${random_id()}" +channel_name_c = "test-RSN2-c-${random_id()}" + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Create several channels +client.channels.get(channel_name_a) +client.channels.get(channel_name_b) +client.channels.get(channel_name_c) + +# Iterate channels +channel_names = [ch.name FOR ch IN client.channels] +``` + +### Assertions +```pseudo +ASSERT channel_name_a IN channel_names +ASSERT channel_name_b IN channel_names +ASSERT channel_name_c IN channel_names +ASSERT length(channel_names) == 3 +``` + +--- + +## RSN3a - Get creates new channel if none exists + +**Test ID**: `rest/unit/RSN3a/get-creates-new-channel-0` + +**Spec requirement:** Creates a new `RestChannel` object for the specified channel if none exists, or returns the existing channel. `ChannelOptions` can be provided in an optional second argument. + +### Setup +```pseudo +channel_name = "test-RSN3a-${random_id()}" + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +channel = client.channels.get(channel_name) +``` + +### Assertions +```pseudo +ASSERT channel IS RestChannel +ASSERT channel.name == channel_name +ASSERT client.channels.exists(channel_name) == true +``` + +--- + +## RSN3a - Get returns existing channel + +**Test ID**: `rest/unit/RSN3a/get-returns-existing-channel-1` + +**Spec requirement:** Creates a new `RestChannel` object for the specified channel if none exists, or returns the existing channel. + +### Setup +```pseudo +channel_name = "test-RSN3a-existing-${random_id()}" + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +channel1 = client.channels.get(channel_name) +channel2 = client.channels.get(channel_name) +``` + +### Assertions +```pseudo +ASSERT channel1 IS SAME AS channel2 # Same object reference +ASSERT channel1.name == channel_name +``` + +--- + +## RSN3a - Operator subscript creates or returns channel + +**Test ID**: `rest/unit/RSN3a/subscript-creates-or-returns-2` + +**Spec requirement:** Creates a new `RestChannel` object for the specified channel if none exists, or returns the existing channel. + +### Setup +```pseudo +channel_name = "test-RSN3a-subscript-${random_id()}" + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +channel1 = client.channels[channel_name] +channel2 = client.channels.get(channel_name) +channel3 = client.channels[channel_name] +``` + +### Assertions +```pseudo +ASSERT channel1 IS SAME AS channel2 +ASSERT channel2 IS SAME AS channel3 +ASSERT channel1.name == channel_name +``` + +--- + +## RSN4a - Release removes channel + +**Test ID**: `rest/unit/RSN4a/release-removes-channel-0` + +**Spec requirement:** Takes one argument, the channel name, and releases the corresponding channel entity (that is, deletes it to allow it to be garbage collected). + +### Setup +```pseudo +channel_name = "test-RSN4a-${random_id()}" + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +client.channels.get(channel_name) +ASSERT client.channels.exists(channel_name) == true + +client.channels.release(channel_name) +``` + +### Assertions +```pseudo +ASSERT client.channels.exists(channel_name) == false +``` + +--- + +## RSN4b - Release on non-existent channel is no-op + +**Test ID**: `rest/unit/RSN4b/release-nonexistent-noop-0` + +**Spec requirement:** Calling `release()` with a channel name that does not correspond to an extant channel entity must return without error. + +### Setup +```pseudo +channel_name = "test-RSN4b-${random_id()}" + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Release a channel that was never created — should not throw +client.channels.release(channel_name) +``` + +### Assertions +```pseudo +ASSERT client.channels.exists(channel_name) == false +``` + +--- + +## RSN3a - Get after release creates new channel + +**Test ID**: `rest/unit/RSN3a/get-after-release-new-instance-3` + +**Spec requirement:** Creates a new `RestChannel` object for the specified channel if none exists. + +Tests that getting a channel after release creates a fresh instance. + +### Setup +```pseudo +channel_name = "test-RSN3a-release-${random_id()}" + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +channel1 = client.channels.get(channel_name) + +client.channels.release(channel_name) + +channel2 = client.channels.get(channel_name) +``` + +### Assertions +```pseudo +ASSERT channel1 IS NOT SAME AS channel2 # Different object instances +ASSERT channel2.name == channel_name +ASSERT client.channels.exists(channel_name) == true +``` diff --git a/uts/rest/unit/encoding/message_encoding.md b/uts/rest/unit/encoding/message_encoding.md new file mode 100644 index 000000000..089e2005a --- /dev/null +++ b/uts/rest/unit/encoding/message_encoding.md @@ -0,0 +1,1003 @@ +# Message Encoding Tests + +Spec points: `RSL4`, `RSL4a`, `RSL4b`, `RSL4c`, `RSL4d`, `RSL6`, `RSL6a`, `RSL6b` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure described in `/Users/paddy/data/worknew/dev/dart-experiments/uts/test/rest/unit/rest_client.md`. + +The mock supports: +- Intercepting HTTP requests and capturing details (URL, headers, method, body) +- Queueing responses with configurable status, headers, and body +- Handler-based configuration with `onConnectionAttempt` and `onRequest` +- Recording requests in `captured_requests` arrays +- Request counting with `request_count` variables + +## Fixtures +Tests should use the encoding fixtures from `ably-common` where available for cross-SDK consistency. + +--- + +## RSL4a - String data encoding + +**Test ID**: `rest/unit/RSL4a/string-data-no-encoding-0` + +**Spec requirement:** String data must be transmitted without transformation and without an encoding field. + +### Setup +```pseudo +channel_name = "test-RSL4a-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false # Use JSON for easier inspection +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: "plain string data") +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +ASSERT body["data"] == "plain string data" +ASSERT "encoding" NOT IN body OR body["encoding"] IS null +``` + +--- + +## RSL4b - JSON object encoding + +**Test ID**: `rest/unit/RSL4b/json-object-encoding-0` + +**Spec requirement:** JSON objects must be serialized to a JSON string with `encoding: "json"`. + +### Setup +```pseudo +channel_name = "test-RSL4b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: { "key": "value", "nested": { "a": 1 } }) +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +# Data should be JSON-serialized string +ASSERT body["data"] IS String +ASSERT parse_json(body["data"]) == { "key": "value", "nested": { "a": 1 } } +ASSERT body["encoding"] == "json" +``` + +--- + +## RSL4c - Binary data encoding with JSON protocol + +**Test ID**: `rest/unit/RSL4c/binary-base64-json-protocol-0` + +**Spec requirement:** Binary data must be base64-encoded when using JSON protocol. + +### Setup +```pseudo +channel_name = "test-RSL4c-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false # JSON protocol requires base64 for binary +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +binary_data = bytes([0x00, 0x01, 0x02, 0xFF, 0xFE]) +AWAIT channel.publish(name: "event", data: binary_data) +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +ASSERT body["encoding"] == "base64" +ASSERT base64_decode(body["data"]) == bytes([0x00, 0x01, 0x02, 0xFF, 0xFE]) +``` + +--- + +## RSL4c - Binary data with MessagePack protocol + +**Test ID**: `rest/unit/RSL4c/binary-direct-msgpack-protocol-1` + +**Spec requirement:** Binary data must be transmitted directly (without base64 encoding) when using MessagePack protocol. + +### Setup +```pseudo +channel_name = "test-RSL4c-msgpack-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: true # MessagePack +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +binary_data = bytes([0x00, 0x01, 0x02, 0xFF, 0xFE]) +AWAIT channel.publish(name: "event", data: binary_data) +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = msgpack_decode(request.body)[0] + +# Binary data should be transmitted directly, no base64 +ASSERT body["data"] == bytes([0x00, 0x01, 0x02, 0xFF, 0xFE]) +ASSERT "encoding" NOT IN body OR body["encoding"] IS null +``` + +--- + +## RSL4d - Array data encoding + +**Test ID**: `rest/unit/RSL4d/array-json-encoding-0` + +**Spec requirement:** Arrays must be JSON-encoded with `encoding: "json"`. + +### Setup +```pseudo +channel_name = "test-RSL4d-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: [1, 2, "three", { "four": 4 }]) +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +ASSERT body["encoding"] == "json" +ASSERT parse_json(body["data"]) == [1, 2, "three", { "four": 4 }] +``` + +--- + +## RSL6a - Decoding base64 data + +**Test ID**: `rest/unit/RSL6a/decode-base64-to-binary-0` + +**Spec requirement:** Data with `encoding: "base64"` must be decoded to binary, and the encoding field consumed. + +### Setup +```pseudo +channel_name = "test-RSL6a-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "id": "msg1", + "name": "event", + "data": "AAECAwQ=", # base64 of [0, 1, 2, 3, 4] + "encoding": "base64", + "timestamp": 1234567890000 + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +history = AWAIT channel.history() +message = history.items[0] +``` + +### Assertions +```pseudo +ASSERT message.data == bytes([0x00, 0x01, 0x02, 0x03, 0x04]) +ASSERT message.encoding IS null # Encoding consumed after decode +``` + +--- + +## RSL6a - Decoding JSON data + +**Test ID**: `rest/unit/RSL6a/decode-json-to-object-1` + +**Spec requirement:** Data with `encoding: "json"` must be decoded from JSON string to native object, and the encoding field consumed. + +### Setup +```pseudo +channel_name = "test-RSL6a-json-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "id": "msg1", + "name": "event", + "data": "{\"key\":\"value\",\"number\":42}", + "encoding": "json", + "timestamp": 1234567890000 + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +history = AWAIT channel.history() +message = history.items[0] +``` + +### Assertions +```pseudo +ASSERT message.data == { "key": "value", "number": 42 } +ASSERT message.encoding IS null +``` + +--- + +## RSL6a - Decoding chained encodings + +**Test ID**: `rest/unit/RSL6a/decode-chained-encodings-2` + +**Spec requirement:** Chained encodings (e.g., `json/base64`) must be decoded in reverse order (last applied encoding is removed first). When processing chained encodings, decoders MUST handle intermediate data types — for example, after decoding `base64`, the data will be binary bytes; a subsequent `json` decoder MUST convert those bytes to a UTF-8 string before JSON parsing. + +### Setup +```pseudo +channel_name = "test-RSL6a-chained-${random_id()}" +captured_requests = [] + +# Data: {"key":"value"} -> JSON string -> base64 encoded +json_string = "{\"key\":\"value\"}" +base64_of_json = base64_encode(json_string) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "id": "msg1", + "name": "event", + "data": base64_of_json, + "encoding": "json/base64", # Decode base64 first, then JSON + "timestamp": 1234567890000 + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +history = AWAIT channel.history() +message = history.items[0] +``` + +### Assertions +```pseudo +ASSERT message.data == { "key": "value" } +ASSERT message.encoding IS null +``` + +--- + +## RSL6b - Unrecognized encoding preserved + +**Test ID**: `rest/unit/RSL6b/unrecognized-encoding-preserved-0` + +**Spec requirement:** Unrecognized encoding values must be preserved in the encoding field, with only recognized encodings being decoded. + +### Setup +```pseudo +channel_name = "test-RSL6b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "id": "msg1", + "name": "event", + "data": "encrypted-data-here", + "encoding": "custom-encryption/base64", + "timestamp": 1234567890000 + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +history = AWAIT channel.history() +message = history.items[0] +``` + +### Assertions +```pseudo +# base64 should be decoded, but custom-encryption is unrecognized +ASSERT message.encoding == "custom-encryption" +# Data should be base64-decoded but not further processed +ASSERT message.data IS bytes # Result of base64 decode +``` + +--- + +## RSL6 - Decoding binary data from MessagePack response + +**Test ID**: `rest/unit/RSL6/msgpack-binary-stays-binary-0` + +**Spec requirement:** When the server returns a MessagePack response containing binary data (msgpack `bin` type), it must be decoded as binary, not as a string — even if the bytes are valid UTF-8. The msgpack wire format distinguishes `str` and `bin` types, and the SDK must preserve this distinction. + +### Setup +```pseudo +channel_name = "test-RSL6-msgpack-binary-${random_id()}" + +# Construct a msgpack response where the data field uses the msgpack +# bin type (raw bytes), NOT the str type. +binary_payload = bytes([0x48, 0x65, 0x6C, 0x6C, 0x6F]) # "Hello" as bytes — valid UTF-8 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, + body: msgpack_encode([ + { "name": "event", "data": binary_payload } # data as msgpack bin type + ]), + headers: { "Content-Type": "application/x-msgpack" } + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: true +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +history = AWAIT channel.history() +message = history.items[0] +``` + +### Assertions +```pseudo +# Binary data must remain binary, NOT be converted to a string +ASSERT message.data IS Binary/Uint8List/[]byte +ASSERT message.data == bytes([0x48, 0x65, 0x6C, 0x6C, 0x6F]) +ASSERT message.encoding IS null +``` + +### Note +This test specifically validates that the SDK does not conflate msgpack `bin` +and `str` types during deserialization. A common bug is for SDKs to deserialize +both types as strings (since the bytes may be valid UTF-8), losing the type +distinction that the server intended. The msgpack `bin` type must always produce +the SDK's binary data type, and the msgpack `str` type must always produce a +string. + +--- + +## RSL6 - Decoding string data from MessagePack response + +**Test ID**: `rest/unit/RSL6/msgpack-string-stays-string-1` + +**Spec requirement:** When the server returns a MessagePack response containing string data (msgpack `str` type), it must be decoded as a string — not as binary. + +### Setup +```pseudo +channel_name = "test-RSL6-msgpack-string-${random_id()}" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, + body: msgpack_encode([ + { "name": "event", "data": "Hello World" } # data as msgpack str type + ]), + headers: { "Content-Type": "application/x-msgpack" } + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: true +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +history = AWAIT channel.history() +message = history.items[0] +``` + +### Assertions +```pseudo +ASSERT message.data IS String +ASSERT message.data == "Hello World" +ASSERT message.encoding IS null +``` + +--- + +## RSL4 - Encoding fixtures from ably-common + +**Test ID**: `rest/unit/RSL4/encoding-fixtures-ably-common-0` + +**Spec requirement:** Implementations must correctly encode data according to standardized test fixtures from `ably-common`. + +### Setup +```pseudo +# Load fixtures from ably-common/test-resources/... +encoding_fixtures = load_fixtures("encoding.json") +``` + +### Test Steps +```pseudo +FOR EACH fixture IN encoding_fixtures: + channel_name = "test-RSL4-fixture-${random_id()}" + captured_requests = [] + + mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } + ) + install_mock(mock_http) + + client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: fixture.use_binary_protocol + )) + channel = client.channels.get(channel_name) + + # Publish with input data + AWAIT channel.publish(name: "event", data: fixture.input_data) + + # Verify encoded format + request = captured_requests[0] + + IF fixture.use_binary_protocol: + body = msgpack_decode(request.body)[0] + ELSE: + body = parse_json(request.body)[0] + + ASSERT body["data"] == fixture.expected_wire_data + ASSERT body["encoding"] == fixture.expected_encoding +``` + +--- + +## Additional Encoding Tests + +### RSL4 - Null data encoding + +**Test ID**: `rest/unit/RSL4/null-data-no-encoding-1` + +**Spec requirement:** Null values must be transmitted without transformation. + +### Setup +```pseudo +channel_name = "test-RSL4-null-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: null) +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +ASSERT body["data"] IS null +ASSERT "encoding" NOT IN body OR body["encoding"] IS null +``` + +--- + +### RSL4a - Number data type rejected + +**Test ID**: `rest/unit/RSL4a/number-type-rejected-1` + +**Spec requirement (RSL4a):** Payloads must be binary, strings, or objects capable of JSON representation. Any other data type should not be permitted and result in an error. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false +)) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: 42) FAILS WITH error +ASSERT error IS NOT null +``` + +--- + +### RSL4a - Boolean data type rejected + +**Test ID**: `rest/unit/RSL4a/boolean-type-rejected-2` + +**Spec requirement (RSL4a):** Payloads must be binary, strings, or objects capable of JSON representation. Any other data type should not be permitted and result in an error. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false +)) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: true) FAILS WITH error +ASSERT error IS NOT null +``` + +--- + +### RSL6 - Decoding UTF-8 encoded data + +**Test ID**: `rest/unit/RSL6/decode-utf8-base64-data-2` + +**Spec requirement:** Data with `encoding: "utf-8/base64"` must decode base64 first, then interpret as UTF-8 string. + +### Setup +```pseudo +channel_name = "test-RSL6-utf8-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "id": "msg1", + "name": "event", + "data": "SGVsbG8gV29ybGQ=", # base64 of UTF-8 "Hello World" + "encoding": "utf-8/base64", + "timestamp": 1234567890000 + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +history = AWAIT channel.history() +message = history.items[0] +``` + +### Assertions +```pseudo +ASSERT message.data == "Hello World" +ASSERT message.data IS String +ASSERT message.encoding IS null +``` + +--- + +### RSL6 - Complex chained encoding + +**Test ID**: `rest/unit/RSL6/complex-chained-encoding-3` + +**Spec requirement:** Multiple encoding layers must be decoded in correct order. + +### Setup +```pseudo +channel_name = "test-RSL6-complex-${random_id()}" +captured_requests = [] + +# Create data: object -> JSON -> UTF-8 bytes -> base64 +original_object = { "status": "active", "count": 5 } +json_string = to_json(original_object) +utf8_bytes = encode_utf8(json_string) +base64_data = base64_encode(utf8_bytes) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "id": "msg1", + "name": "event", + "data": base64_data, + "encoding": "json/utf-8/base64", + "timestamp": 1234567890000 + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +history = AWAIT channel.history() +message = history.items[0] +``` + +### Assertions +```pseudo +# Should decode: base64 -> utf-8 -> json +ASSERT message.data == { "status": "active", "count": 5 } +ASSERT message.encoding IS null +``` + +--- + +## Protocol Selection Tests + +### RSL4 - JSON protocol uses correct Content-Type + +**Test ID**: `rest/unit/RSL4/json-protocol-content-type-2` + +**Spec requirement:** When `useBinaryProtocol: false`, requests must use `Content-Type: application/json`. + +### Setup +```pseudo +channel_name = "test-RSL4-json-ct-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: "test") +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.headers["Content-Type"] == "application/json" +ASSERT request.headers["Accept"] == "application/json" +``` + +--- + +### RSL4 - MessagePack protocol uses correct Content-Type + +**Test ID**: `rest/unit/RSL4/msgpack-protocol-content-type-3` + +**Spec requirement:** When `useBinaryProtocol: true`, requests must use `Content-Type: application/x-msgpack`. + +### Setup +```pseudo +channel_name = "test-RSL4-msgpack-ct-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: true +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: "test") +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.headers["Content-Type"] == "application/x-msgpack" +ASSERT request.headers["Accept"] == "application/x-msgpack" +``` + +--- + +## Empty Data Tests + +### RSL4 - Empty string encoding + +**Test ID**: `rest/unit/RSL4/empty-string-no-encoding-4` + +**Spec requirement:** Empty strings must be transmitted as empty strings without encoding. + +### Setup +```pseudo +channel_name = "test-RSL4-empty-str-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: "") +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +ASSERT body["data"] == "" +ASSERT "encoding" NOT IN body OR body["encoding"] IS null +``` + +--- + +### RSL4 - Empty array encoding + +**Test ID**: `rest/unit/RSL4/empty-array-json-encoding-5` + +**Spec requirement:** Empty arrays must be JSON-encoded. + +### Setup +```pseudo +channel_name = "test-RSL4-empty-arr-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: []) +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +ASSERT body["encoding"] == "json" +ASSERT parse_json(body["data"]) == [] +``` + +--- + +### RSL4 - Empty object encoding + +**Spec requirement:** Empty objects must be JSON-encoded. + +### Setup +```pseudo +channel_name = "test-RSL4-empty-obj-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: {}) +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +ASSERT body["encoding"] == "json" +ASSERT parse_json(body["data"]) == {} +``` diff --git a/uts/rest/unit/encoding/msgpack_interop.md b/uts/rest/unit/encoding/msgpack_interop.md new file mode 100644 index 000000000..c56484027 --- /dev/null +++ b/uts/rest/unit/encoding/msgpack_interop.md @@ -0,0 +1,111 @@ +# MessagePack Interoperability Tests + +Spec points: `RSL6a3` + +## Test Type +Unit test — no server or mock needed. Operates on static fixture data. + +## Fixtures +Tests use `ably-common/test-resources/msgpack_test_fixtures.json`. + +Each fixture has: +- `name`: human-readable description +- `data`: the expected decoded data value +- `numRepeat`: if > 0, the expected data is `data` repeated `numRepeat` times +- `type`: one of `"string"`, `"binary"`, `"jsonArray"`, `"jsonObject"` +- `encoding`: the encoding field on the wire message (empty string means none) +- `msgpack`: base64-encoded msgpack bytes of an entire ProtocolMessage + +The ProtocolMessage contains a single Message in its `messages` array. The `data` +field in the fixture describes the expected decoded content of that message. + +--- + +## RSL6a3 - Decode binary-encoded protocol messages using interop fixtures + +**Spec requirement:** A set of tests should exist to ensure that the client library +can successfully encode and decode binary encoded protocol messages. + +### Setup +```pseudo +fixtures = load_json("ably-common/test-resources/msgpack_test_fixtures.json") +``` + +### Test: each fixture decodes correctly +```pseudo +FOR EACH fixture IN fixtures: + # 1. Decode the msgpack ProtocolMessage + msgpack_bytes = base64_decode(fixture["msgpack"]) + protocol_message = msgpack_deserialize(msgpack_bytes) + + # 2. Extract the first (only) message + wire_message = protocol_message["messages"][0] + + # 3. Build the expected data + IF fixture["type"] == "string": + IF fixture["numRepeat"] > 0: + expected = fixture["data"] * fixture["numRepeat"] # repeat string + ELSE: + expected = fixture["data"] + END + ELSE IF fixture["type"] == "binary": + raw_string = fixture["data"] * fixture["numRepeat"] + expected = encode_utf8(raw_string) # Uint8List / byte array + ELSE IF fixture["type"] == "jsonArray" OR fixture["type"] == "jsonObject": + expected = fixture["data"] # native array or map + END + + # 4. Decode the wire message using the standard decoding pipeline + message = Message.fromMap(wire_message) + + # 5. Verify + ASSERT message.data == expected + ASSERT message.encoding IS null # all encoding consumed +END +``` + +### Assertions per fixture type + +**String fixtures** (`type == "string"`): +- `message.data` is a String equal to `fixture["data"]` repeated `fixture["numRepeat"]` times + +**Binary fixtures** (`type == "binary"`): +- The wire message has `encoding: "base64"` and base64-encoded `data` +- After decoding, `message.data` is a byte array (Uint8List) +- The byte content equals the UTF-8 encoding of `fixture["data"]` repeated `fixture["numRepeat"]` times + +**JSON fixtures** (`type == "jsonArray"` or `type == "jsonObject"`): +- The wire message has `encoding: "json"` and JSON-encoded `data` +- After decoding, `message.data` is a native List or Map matching `fixture["data"]` + +--- + +## RSL6a3 - Re-encode decoded messages back to msgpack (round-trip) + +### Test: each fixture round-trips through encode/decode +```pseudo +FOR EACH fixture IN fixtures: + # 1. Decode the original + msgpack_bytes = base64_decode(fixture["msgpack"]) + protocol_message = msgpack_deserialize(msgpack_bytes) + wire_message = protocol_message["messages"][0] + + # 2. Decode to a Message + message = Message.fromMap(wire_message) + + # 3. Re-encode the message for msgpack wire format + re_encoded = message.toMap(useBinaryProtocol: true) + + # 4. Wrap in a ProtocolMessage and serialize + re_pm = { "messages": [re_encoded], "msgSerial": 0 } + re_bytes = msgpack_serialize(re_pm) + + # 5. Deserialize and decode again + re_pm2 = msgpack_deserialize(re_bytes) + re_message = Message.fromMap(re_pm2["messages"][0]) + + # 6. Verify round-trip + ASSERT re_message.data == message.data + ASSERT re_message.encoding IS null +END +``` diff --git a/uts/rest/unit/fallback.md b/uts/rest/unit/fallback.md new file mode 100644 index 000000000..c0e98b9ab --- /dev/null +++ b/uts/rest/unit/fallback.md @@ -0,0 +1,1566 @@ +# Host Fallback and Endpoint Configuration Tests + +Spec points: `RSC15`, `RSC15a`, `RSC15f`, `RSC15j`, `RSC15l`, `RSC15m`, `REC1`, `REC1a`, `REC1b`, `REC1b1`, `REC1b2`, `REC1b3`, `REC1b4`, `REC1c`, `REC1c1`, `REC1c2`, `REC1d`, `REC1d1`, `REC1d2`, `REC2`, `REC2a`, `REC2a1`, `REC2a2`, `REC2b`, `REC2c`, `REC2c1`, `REC2c2`, `REC2c3`, `REC2c4`, `REC2c5`, `REC2c6`, `REC3`, `REC3a`, `REC3b` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `rest_client.md` for the full Mock HTTP Infrastructure specification. These tests use the same `MockHttpClient` interface with `PendingConnection` and `PendingRequest`. + +Fallback tests require the mock to support: +- Connection-level failures (DNS, connection refused, timeout) +- Per-host or per-request response configuration +- Tracking multiple sequential requests to different hosts + +--- + +## RSC15m - Fallback only when fallback domains non-empty + +**Test ID**: `rest/unit/RSC15m/no-fallback-empty-hosts-0` + +**Spec requirement:** Fallback retry is only attempted when fallback hosts are configured (non-empty list). + +Tests that fallback behavior is skipped when no fallback hosts are configured. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, { "error": { "code": 50000 } }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + fallbackHosts: [] # Explicitly empty +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() FAILS WITH error +# Should fail without retry +ASSERT mock_http.captured_requests.length == 1 +ASSERT error.statusCode == 500 +``` + +--- + +## RSC15a - Fallback hosts tried in random order + +**Test ID**: `rest/unit/RSC15a/fallback-random-order-0` + +**Spec requirement:** When the primary host fails, fallback hosts must be tried in random order to distribute load. + +Tests that fallback hosts are tried when primary fails, in random order. + +### Setup +```pseudo +mock_http = MockHttpClient() +# All requests fail to test full fallback sequence +mock_http.queue_responses( + count: 6, # primary + 5 fallbacks + status: 500, + body: { "error": { "code": 50000 } } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.time() FAILS WITH error +# Expected to fail after all retries +``` + +### Assertions +```pseudo +requests = mock_http.captured_requests + +# First request to primary +ASSERT requests[0].url.host == "main.realtime.ably.net" + +# Subsequent requests to fallback hosts +fallback_hosts_used = [r.url.host FOR r IN requests[1:]] + +expected_fallbacks = [ + "main.a.fallback.ably-realtime.com", + "main.b.fallback.ably-realtime.com", + "main.c.fallback.ably-realtime.com", + "main.d.fallback.ably-realtime.com", + "main.e.fallback.ably-realtime.com" +] + +# All used hosts should be valid fallbacks +ASSERT ALL host IN fallback_hosts_used: host IN expected_fallbacks + +# To test randomness: run test multiple times and verify order varies +# (Implementation note: may need statistical test or seed control) +``` + +--- + +## RSC15l - Qualifying errors trigger fallback + +**Test ID**: `rest/unit/RSC15l/qualifying-errors-trigger-fallback-0` + +| Spec | Requirement | +|------|-------------| +| RSC15l1 | Host unreachable errors trigger fallback | +| RSC15l2 | Request timeout errors trigger fallback | +| RSC15l3 | HTTP 5xx status codes (500-504) trigger fallback | + +Tests that specific error conditions trigger fallback retry. + +### Test Cases + +| ID | Spec | Condition | Should Retry | +|----|------|-----------|--------------| +| 1 | RSC15l1 | Host unreachable | Yes | +| 2 | RSC15l2 | Request timeout | Yes | +| 3 | RSC15l3 | HTTP 500 | Yes | +| 4 | RSC15l3 | HTTP 501 | Yes | +| 5 | RSC15l3 | HTTP 502 | Yes | +| 6 | RSC15l3 | HTTP 503 | Yes | +| 7 | RSC15l3 | HTTP 504 | Yes | +| 8 | | HTTP 400 | No | +| 9 | | HTTP 401 | No | +| 10 | | HTTP 404 | No | + +### Setup (HTTP status codes) +```pseudo +FOR EACH test_case IN [500, 501, 502, 503, 504]: + mock_http = MockHttpClient() + mock_http.queue_response(test_case, { "error": { "code": test_case * 100 } }) + mock_http.queue_response(200, { "time": 1234567890000 }) + + client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + + AWAIT client.time() + + ASSERT mock_http.captured_requests.length == 2 + ASSERT mock_http.captured_requests[1].url.host != mock_http.captured_requests[0].url.host +``` + +### Setup (Non-retryable errors) +```pseudo +FOR EACH test_case IN [400, 401, 404]: + mock_http = MockHttpClient() + mock_http.queue_response(test_case, { "error": { "code": test_case * 100 } }) + + client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + + AWAIT client.time() FAILS WITH error + # Expected to fail + + # Should NOT have retried + ASSERT mock_http.captured_requests.length == 1 +``` + +### Setup (Timeout) +```pseudo +mock_http = MockHttpClient() +mock_http.queue_timeout() # Simulates timeout +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + httpRequestTimeout: 1000 +)) + +AWAIT client.time() + +ASSERT mock_http.captured_requests.length == 2 +``` + +--- + +## RSC15l4 - CloudFront errors trigger fallback + +**Test ID**: `rest/unit/RSC15l4/cloudfront-error-triggers-fallback-0` + +**Spec requirement:** Responses with a CloudFront Server header and status >= 400 must trigger fallback retry. + +Tests that responses with CloudFront server header and status >= 400 trigger fallback. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(403, + body: { "error": "Forbidden" }, + headers: { "Server": "CloudFront" } +) +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 2 +ASSERT mock_http.captured_requests[0].url.host == "main.realtime.ably.net" +ASSERT mock_http.captured_requests[1].url.host != "main.realtime.ably.net" +``` + +--- + +## RSC15l - Comprehensive fallback scenarios with different error types + +These tests verify that fallback behavior works correctly for different network and HTTP error conditions. + +### RSC15l - Connection refused triggers fallback + +**Test ID**: `rest/unit/RSC15l/connection-refused-fallback-0` + +```pseudo +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => { + request_count++ + IF request_count == 1: + # First attempt (primary host) - connection refused + conn.respond_with_refused() + ELSE: + # Fallback succeeds + conn.respond_with_success() + }, + onRequest: (req) => { + req.respond_with(200, {"time": 1234567890000}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +result = AWAIT client.time() + +# Should have succeeded on fallback +ASSERT result IS valid +ASSERT request_count == 2 +``` + +### RSC15l - DNS error triggers fallback + +**Test ID**: `rest/unit/RSC15l/dns-error-fallback-1` + +```pseudo +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => { + request_count++ + IF request_count == 1: + # First attempt - DNS failure + conn.respond_with_dns_error() + ELSE: + # Fallback succeeds + conn.respond_with_success() + }, + onRequest: (req) => { + req.respond_with(200, {"time": 1234567890000}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +result = AWAIT client.time() + +ASSERT result IS valid +ASSERT request_count == 2 +``` + +### RSC15l - Connection timeout triggers fallback + +**Test ID**: `rest/unit/RSC15l/connection-timeout-fallback-2` + +```pseudo +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => { + request_count++ + IF request_count == 1: + # First attempt - connection timeout + conn.respond_with_timeout() + ELSE: + # Fallback succeeds + conn.respond_with_success() + }, + onRequest: (req) => { + req.respond_with(200, {"time": 1234567890000}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + httpRequestTimeout: 1000 +)) + +result = AWAIT client.time() + +ASSERT result IS valid +ASSERT request_count == 2 +``` + +### RSC15l - Request timeout triggers fallback + +**Test ID**: `rest/unit/RSC15l/request-timeout-fallback-3` + +```pseudo +request_count = 0 +captured_hosts = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => { + captured_hosts.append(conn.host) + conn.respond_with_success() + }, + onRequest: (req) => { + request_count++ + IF request_count == 1: + # First request times out + req.respond_with_timeout() + ELSE: + # Fallback succeeds + req.respond_with(200, {"time": 1234567890000}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + httpRequestTimeout: 1000 +)) + +result = AWAIT client.time() + +ASSERT result IS valid +ASSERT request_count == 2 +# Should have tried different hosts +ASSERT captured_hosts[0] != captured_hosts[1] +``` + +### RSC15l - HTTP 5xx errors trigger fallback + +**Test ID**: `rest/unit/RSC15l/http-5xx-triggers-fallback-4` + +```pseudo +FOR EACH status_code IN [500, 501, 502, 503, 504]: + request_count = 0 + + mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count++ + IF request_count == 1: + req.respond_with(status_code, {"error": {"code": status_code * 100}}) + ELSE: + req.respond_with(200, {"time": 1234567890000}) + } + ) + install_mock(mock_http) + + client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + + result = AWAIT client.time() + + ASSERT result IS valid + ASSERT request_count == 2 +``` + +### RSC15l - HTTP 4xx errors do NOT trigger fallback + +**Test ID**: `rest/unit/RSC15l/http-4xx-no-fallback-5` + +```pseudo +FOR EACH status_code IN [400, 401, 404]: + request_count = 0 + + mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count++ + req.respond_with(status_code, {"error": {"code": status_code * 100}}) + } + ) + install_mock(mock_http) + + client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + + AWAIT client.time() FAILS WITH error + ASSERT error.statusCode == status_code + + # Should NOT have retried + ASSERT request_count == 1 +``` + +--- + +## RSC15j - Host header matches request host + +**Test ID**: `rest/unit/RSC15j/host-header-matches-request-0` + +**Spec requirement:** The HTTP Host header must match the actual host being requested, including for fallback hosts. + +Tests that the Host header is set correctly for fallback requests. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, { "error": { "code": 50000 } }) +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +request_1 = mock_http.captured_requests[0] +request_2 = mock_http.captured_requests[1] + +# Host header should match the actual host being requested +ASSERT request_1.headers["Host"] == request_1.url.host +ASSERT request_2.headers["Host"] == request_2.url.host +ASSERT request_1.headers["Host"] != request_2.headers["Host"] +``` + +--- + +## RSC15f - Successful fallback host cached + +**Test ID**: `rest/unit/RSC15f/successful-fallback-cached-0` + +**Spec requirement:** When a fallback host succeeds, it should be cached and used for subsequent requests (for a limited time). + +Tests that after successful fallback, that host is used for subsequent requests. + +### Setup +```pseudo +mock_http = MockHttpClient() +# First request to primary fails +mock_http.queue_response_for_host("main.realtime.ably.net", 500, { "error": {} }) +# First fallback succeeds +mock_http.queue_response_for_host("main.a.fallback.ably-realtime.com", 200, { "time": 1000 }) +# Second request should go directly to cached fallback +mock_http.queue_response_for_host("main.a.fallback.ably-realtime.com", 200, { "time": 2000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + fallbackRetryTimeout: 60000 # 60 seconds +)) +``` + +### Test Steps +```pseudo +# First request - triggers fallback +result1 = AWAIT client.time() + +# Second request - should use cached fallback +result2 = AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 3 + +# Request 1: primary (failed) +ASSERT mock_http.captured_requests[0].url.host == "main.realtime.ably.net" + +# Request 2: fallback (succeeded) +ASSERT mock_http.captured_requests[1].url.host == "main.a.fallback.ably-realtime.com" + +# Request 3: cached fallback (no retry to primary) +ASSERT mock_http.captured_requests[2].url.host == "main.a.fallback.ably-realtime.com" +``` + +--- + +## RSC15f - Cached fallback expires after timeout + +**Test ID**: `rest/unit/RSC15f/cached-fallback-expires-1` + +**Spec requirement:** Cached fallback hosts must expire after `fallbackRetryTimeout` duration, after which the primary host is tried again. + +Tests that cached fallback host is cleared after `fallbackRetryTimeout`. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response_for_host("main.realtime.ably.net", 500, { "error": {} }) +mock_http.queue_response_for_host("main.a.fallback.ably-realtime.com", 200, { "time": 1000 }) +# After timeout, primary should be tried again +mock_http.queue_response_for_host("main.realtime.ably.net", 200, { "time": 2000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + fallbackRetryTimeout: 100 # 100ms for testing +)) +``` + +### Test Steps +```pseudo +# First request triggers fallback +AWAIT client.time() + +# Wait for timeout to expire +WAIT 150 milliseconds + +# Next request should try primary again +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 3 + +# After timeout, primary is tried again +ASSERT mock_http.captured_requests[2].url.host == "main.realtime.ably.net" +``` + +--- + +## RSC15f - Expired preferred fallback host not resurrected by late in-flight success + +**Test ID**: `rest/unit/RSC15f/expired-not-resurrected-2` + +**Spec requirement:** After `fallbackRetryTimeout` has elapsed the preference must be un-stored and future requests must restart the fallback sequence from the primary host. A late-arriving successful response against the previously-preferred fallback must not re-establish it as the preference. + +Tests that a request that completes successfully against a fallback *after* `fallbackRetryTimeout` has expired does not re-pin that fallback as the preferred host. + +### Setup +```pseudo +mock_http = MockHttpClient() + +# Request handler: primary fails on first attempt, all others succeed. +# Second request (to cached fallback) is NOT responded to immediately — +# we hold the PendingRequest and respond later, after the timeout expires. +held_request = null +request_index = 0 + +mock_http.onRequest = (req) => + request_index += 1 + if request_index == 1 + # First request to primary — fail to trigger fallback + req.respond_with(500, { "error": { "message": "fail", "code": 50000, "statusCode": 500 } }) + else if request_index == 2 + # First fallback — succeed, caches this host + req.respond_with(200, [1000]) + else if request_index == 3 + # Second request goes to cached fallback — hold it (don't respond yet) + held_request = req + else + # All subsequent requests — succeed + req.respond_with(200, [1000]) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + fallbackRetryTimeout: 100 # 100ms for testing +)) +``` + +### Test Steps +```pseudo +# Request 1+2: primary fails → fallback succeeds → fallback cached +AWAIT client.time() + +# Request 3: goes to cached fallback, but we hold the response +request_future = client.time() # starts but does not complete + +# Advance time past fallbackRetryTimeout so the cache expires +WAIT 150 milliseconds + +# Request 4: cache expired → should try primary again +AWAIT client.time() + +# Now let the held request (3) complete successfully +held_request.respond_with(200, [1000]) +AWAIT request_future + +# Request 5: the late success from request 3 must NOT have re-pinned +# the fallback — this request should go to primary again +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 5 + +# Requests 1+2: primary fail → fallback success +ASSERT mock_http.captured_requests[0].url.host == "main.realtime.ably.net" +ASSERT mock_http.captured_requests[1].url.host != "main.realtime.ably.net" + +fallback_host = mock_http.captured_requests[1].url.host + +# Request 3: went to cached fallback (held, not yet responded) +ASSERT mock_http.captured_requests[2].url.host == fallback_host + +# Request 4: after timeout expiry, primary is tried again +ASSERT mock_http.captured_requests[3].url.host == "main.realtime.ably.net" + +# Request 5: late success from request 3 did NOT re-pin fallback +ASSERT mock_http.captured_requests[4].url.host == "main.realtime.ably.net" +``` + +--- + +# REC1 - Primary Domain Configuration + +## REC1a - Default primary domain + +**Test ID**: `rest/unit/REC1a/default-primary-domain-0` + +**Spec requirement:** When no endpoint configuration is provided, the default primary domain is `rest.ably.io` for REST and `realtime.ably.io` for Realtime. + +Tests that the default primary domain is used when no endpoint options are specified. + +> **Note:** The spec defines the legacy default as `rest.ably.io` for REST and `realtime.ably.io` for Realtime. SDKs adopting the new `endpoint` routing policy (REC1b) should use `main.realtime.ably.net` as the new default. SDKs still using the legacy `restHost`/`realtimeHost` pattern should assert against `rest.ably.io` / `realtime.ably.io` respectively. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests[0].url.host == "main.realtime.ably.net" +``` + +--- + +## REC1b2 - Endpoint option as explicit hostname (with period) + +**Test ID**: `rest/unit/REC1b2/explicit-hostname-with-period-0` + +Tests that when `endpoint` contains a period (`.`), it's treated as an explicit hostname. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "custom.ably.example.com" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests[0].url.host == "custom.ably.example.com" +``` + +--- + +## REC1b2 - Endpoint option as localhost + +**Test ID**: `rest/unit/REC1b2/endpoint-localhost-1` + +Tests that `endpoint: "localhost"` is treated as an explicit hostname. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "localhost" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests[0].url.host == "localhost" +``` + +--- + +## REC1b2 - Endpoint option as IPv6 address + +**Test ID**: `rest/unit/REC1b2/endpoint-ipv6-address-2` + +Tests that `endpoint` containing `::` is treated as an explicit hostname (IPv6). + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "::1" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +# IPv6 addresses may be bracketed in URLs +ASSERT mock_http.captured_requests[0].url.host == "::1" OR + mock_http.captured_requests[0].url.host == "[::1]" +``` + +--- + +## REC1b3 - Endpoint option as nonprod routing policy + +**Test ID**: `rest/unit/REC1b3/nonprod-routing-policy-0` + +Tests that `endpoint: "nonprod:[id]"` resolves to `[id].realtime.ably-nonprod.net`. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "nonprod:staging" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests[0].url.host == "staging.realtime.ably-nonprod.net" +``` + +--- + +## REC1b4 - Endpoint option as production routing policy + +**Test ID**: `rest/unit/REC1b4/production-routing-policy-0` + +Tests that `endpoint: "[id]"` (without period or nonprod prefix) resolves to `[id].realtime.ably.net`. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "test" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests[0].url.host == "test.realtime.ably.net" +``` + +--- + +## REC1b1 - Endpoint conflicts with deprecated environment option + +**Test ID**: `rest/unit/REC1b1/endpoint-conflicts-environment-0` + +Tests that specifying both `endpoint` and `environment` is invalid. + +### Setup +```pseudo +# No mock needed - should fail during client construction +``` + +### Test Steps +```pseudo +Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "test", + environment: "production" # Deprecated, conflicts with endpoint +)) FAILS WITH error +ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message CONTAINS "conflict" +``` + +--- + +## REC1b1 - Endpoint conflicts with deprecated restHost option + +**Test ID**: `rest/unit/REC1b1/endpoint-conflicts-resthost-1` + +Tests that specifying both `endpoint` and `restHost` is invalid. + +### Setup +```pseudo +# No mock needed - should fail during client construction +``` + +### Test Steps +```pseudo +Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "test", + restHost: "custom.host.com" # Deprecated, conflicts with endpoint +)) FAILS WITH error +ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message CONTAINS "conflict" +``` + +--- + +## REC1b1 - Endpoint conflicts with deprecated realtimeHost option + +**Test ID**: `rest/unit/REC1b1/endpoint-conflicts-realtimehost-2` + +Tests that specifying both `endpoint` and `realtimeHost` is invalid. + +### Setup +```pseudo +# No mock needed - should fail during client construction +``` + +### Test Steps +```pseudo +Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "test", + realtimeHost: "custom.realtime.com" # Deprecated, conflicts with endpoint +)) FAILS WITH error +ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message CONTAINS "conflict" +``` + +--- + +## REC1b1 - Endpoint conflicts with deprecated fallbackHostsUseDefault option + +**Test ID**: `rest/unit/REC1b1/endpoint-conflicts-fallback-default-3` + +Tests that specifying both `endpoint` and `fallbackHostsUseDefault` is invalid. + +### Setup +```pseudo +# No mock needed - should fail during client construction +``` + +### Test Steps +```pseudo +Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "test", + fallbackHostsUseDefault: true # Deprecated, conflicts with endpoint +)) FAILS WITH error +ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message CONTAINS "conflict" +``` + +--- + +## REC1c2 - Deprecated environment option determines primary domain + +**Test ID**: `rest/unit/REC1c2/environment-sets-primary-domain-0` + +Tests that the deprecated `environment` option sets primary domain to `[id].realtime.ably.net`. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + environment: "sandbox" # Deprecated but still supported +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests[0].url.host == "sandbox.realtime.ably.net" +``` + +--- + +## REC1c1 - Environment conflicts with restHost + +**Test ID**: `rest/unit/REC1c1/environment-conflicts-resthost-0` + +Tests that specifying both `environment` and `restHost` is invalid. + +### Setup +```pseudo +# No mock needed - should fail during client construction +``` + +### Test Steps +```pseudo +Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + environment: "sandbox", + restHost: "custom.host.com" +)) FAILS WITH error +ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message CONTAINS "conflict" +``` + +--- + +## REC1c1 - Environment conflicts with realtimeHost + +**Test ID**: `rest/unit/REC1c1/environment-conflicts-realtimehost-1` + +Tests that specifying both `environment` and `realtimeHost` is invalid. + +### Setup +```pseudo +# No mock needed - should fail during client construction +``` + +### Test Steps +```pseudo +Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + environment: "sandbox", + realtimeHost: "custom.realtime.com" +)) FAILS WITH error +ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message CONTAINS "conflict" +``` + +--- + +## REC1d1 - Deprecated restHost option determines primary domain + +**Test ID**: `rest/unit/REC1d1/resthost-sets-primary-domain-0` + +Tests that the deprecated `restHost` option sets the primary domain. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + restHost: "custom.rest.example.com" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests[0].url.host == "custom.rest.example.com" +``` + +--- + +## REC1d2 - Deprecated realtimeHost option determines primary domain (when restHost not set) + +**Test ID**: `rest/unit/REC1d2/realtimehost-sets-primary-domain-0` + +Tests that `realtimeHost` sets primary domain when `restHost` is not specified. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeHost: "custom.realtime.example.com" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests[0].url.host == "custom.realtime.example.com" +``` + +--- + +## REC1d - restHost takes precedence over realtimeHost + +**Test ID**: `rest/unit/REC1d/resthost-precedence-over-realtimehost-0` + +Tests that when both `restHost` and `realtimeHost` are specified, `restHost` is used for REST requests. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + restHost: "rest.example.com", + realtimeHost: "realtime.example.com" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +# REST client uses restHost, not realtimeHost +ASSERT mock_http.captured_requests[0].url.host == "rest.example.com" +``` + +--- + +# REC2 - Fallback Domains Configuration + +## REC2c1 - Default fallback domains + +**Test ID**: `rest/unit/REC2c1/default-fallback-domains-0` + +**Spec requirement:** When using default configuration, fallback domains follow the pattern `[a-e].ably-realtime.com`. + +Tests that default configuration provides the standard fallback domains. + +> **Note:** The spec defines the legacy fallback pattern as `[a-e].ably-realtime.com`. SDKs adopting the new `endpoint` routing policy (REC1b) should use `main.[a-e].fallback.ably-realtime.com`. SDKs still using the legacy pattern should assert against `[a-e].ably-realtime.com`. + +### Setup +```pseudo +mock_http = MockHttpClient() +# Primary fails +mock_http.queue_response(500, { "error": { "code": 50000 } }) +# Fallback succeeds +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 2 +ASSERT mock_http.captured_requests[0].url.host == "main.realtime.ably.net" + +expected_fallbacks = [ + "main.a.fallback.ably-realtime.com", + "main.b.fallback.ably-realtime.com", + "main.c.fallback.ably-realtime.com", + "main.d.fallback.ably-realtime.com", + "main.e.fallback.ably-realtime.com" +] +ASSERT mock_http.captured_requests[1].url.host IN expected_fallbacks +``` + +--- + +## REC2a2 - Custom fallbackHosts option + +**Test ID**: `rest/unit/REC2a2/custom-fallback-hosts-0` + +Tests that the `fallbackHosts` option overrides default fallbacks. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, { "error": { "code": 50000 } }) +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + fallbackHosts: ["fb1.example.com", "fb2.example.com", "fb3.example.com"] +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 2 +ASSERT mock_http.captured_requests[0].url.host == "main.realtime.ably.net" +ASSERT mock_http.captured_requests[1].url.host IN ["fb1.example.com", "fb2.example.com", "fb3.example.com"] +``` + +--- + +## REC2a1 - fallbackHosts conflicts with fallbackHostsUseDefault + +**Test ID**: `rest/unit/REC2a1/fallback-hosts-conflicts-use-default-0` + +Tests that specifying both `fallbackHosts` and `fallbackHostsUseDefault` is invalid. + +### Setup +```pseudo +# No mock needed - should fail during client construction +``` + +### Test Steps +```pseudo +Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + fallbackHosts: ["fb1.example.com"], + fallbackHostsUseDefault: true +)) FAILS WITH error +ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message CONTAINS "conflict" +``` + +--- + +## REC2b - Deprecated fallbackHostsUseDefault option + +**Test ID**: `rest/unit/REC2b/fallback-hosts-use-default-0` + +Tests that `fallbackHostsUseDefault: true` uses the default fallback domains. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, { "error": { "code": 50000 } }) +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + restHost: "custom.host.com", # Would normally disable fallbacks + fallbackHostsUseDefault: true # Force default fallbacks +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 2 +ASSERT mock_http.captured_requests[0].url.host == "custom.host.com" + +# Should use default fallbacks despite custom restHost +expected_fallbacks = [ + "main.a.fallback.ably-realtime.com", + "main.b.fallback.ably-realtime.com", + "main.c.fallback.ably-realtime.com", + "main.d.fallback.ably-realtime.com", + "main.e.fallback.ably-realtime.com" +] +ASSERT mock_http.captured_requests[1].url.host IN expected_fallbacks +``` + +--- + +## REC2c2 - Explicit hostname endpoint has no fallbacks + +**Test ID**: `rest/unit/REC2c2/explicit-hostname-no-fallbacks-0` + +Tests that when `endpoint` is an explicit hostname, fallback domains are empty. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, { "error": { "code": 50000 } }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "custom.ably.example.com" # Contains period = explicit hostname +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() FAILS WITH error +# Expected to fail with no fallback +``` + +### Assertions +```pseudo +# No fallback attempted - only one request +ASSERT mock_http.captured_requests.length == 1 +ASSERT mock_http.captured_requests[0].url.host == "custom.ably.example.com" +``` + +--- + +## REC2c3 - Nonprod routing policy fallback domains + +**Test ID**: `rest/unit/REC2c3/nonprod-fallback-domains-0` + +Tests that nonprod routing policy has corresponding nonprod fallback domains. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, { "error": { "code": 50000 } }) +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "nonprod:staging" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 2 +ASSERT mock_http.captured_requests[0].url.host == "staging.realtime.ably-nonprod.net" + +expected_fallbacks = [ + "staging.a.fallback.ably-realtime-nonprod.com", + "staging.b.fallback.ably-realtime-nonprod.com", + "staging.c.fallback.ably-realtime-nonprod.com", + "staging.d.fallback.ably-realtime-nonprod.com", + "staging.e.fallback.ably-realtime-nonprod.com" +] +ASSERT mock_http.captured_requests[1].url.host IN expected_fallbacks +``` + +--- + +## REC2c4 - Production routing policy fallback domains (via endpoint) + +**Test ID**: `rest/unit/REC2c4/production-endpoint-fallback-domains-0` + +Tests that production routing policy via `endpoint` has corresponding fallback domains. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, { "error": { "code": 50000 } }) +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "test" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 2 +ASSERT mock_http.captured_requests[0].url.host == "test.realtime.ably.net" + +expected_fallbacks = [ + "test.a.fallback.ably-realtime.com", + "test.b.fallback.ably-realtime.com", + "test.c.fallback.ably-realtime.com", + "test.d.fallback.ably-realtime.com", + "test.e.fallback.ably-realtime.com" +] +ASSERT mock_http.captured_requests[1].url.host IN expected_fallbacks +``` + +--- + +## REC2c5 - Production routing policy fallback domains (via deprecated environment) + +**Test ID**: `rest/unit/REC2c5/production-environment-fallback-domains-0` + +Tests that production routing policy via deprecated `environment` has corresponding fallback domains. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, { "error": { "code": 50000 } }) +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + environment: "sandbox" # Deprecated +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 2 +ASSERT mock_http.captured_requests[0].url.host == "sandbox.realtime.ably.net" + +expected_fallbacks = [ + "sandbox.a.fallback.ably-realtime.com", + "sandbox.b.fallback.ably-realtime.com", + "sandbox.c.fallback.ably-realtime.com", + "sandbox.d.fallback.ably-realtime.com", + "sandbox.e.fallback.ably-realtime.com" +] +ASSERT mock_http.captured_requests[1].url.host IN expected_fallbacks +``` + +--- + +## REC2c6 - Custom restHost has no fallbacks + +**Test ID**: `rest/unit/REC2c6/custom-resthost-no-fallbacks-0` + +Tests that deprecated `restHost` option results in no fallback domains. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, { "error": { "code": 50000 } }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + restHost: "custom.rest.example.com" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() FAILS WITH error +# Expected to fail with no fallback +``` + +### Assertions +```pseudo +# No fallback attempted +ASSERT mock_http.captured_requests.length == 1 +ASSERT mock_http.captured_requests[0].url.host == "custom.rest.example.com" +``` + +--- + +## REC2c6 - Custom realtimeHost has no fallbacks + +**Test ID**: `rest/unit/REC2c6/custom-realtimehost-no-fallbacks-1` + +Tests that deprecated `realtimeHost` option results in no fallback domains. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, { "error": { "code": 50000 } }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeHost: "custom.realtime.example.com" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() FAILS WITH error +# Expected to fail with no fallback +``` + +### Assertions +```pseudo +# No fallback attempted +ASSERT mock_http.captured_requests.length == 1 +ASSERT mock_http.captured_requests[0].url.host == "custom.realtime.example.com" +``` + +--- + +# REC3 - Connectivity Check URL + +## REC3a - Default connectivity check URL + +**Test ID**: `rest/unit/REC3a/default-connectivity-check-url-0` + +Tests that the default connectivity check URL is `https://internet-up.ably-realtime.com/is-the-internet-up.txt`. + +### Note +This test is primarily relevant for Realtime clients that perform connectivity checks. The connectivity check URL is used to verify internet connectivity before attempting to connect. + +### Setup +```pseudo +mock_http = MockHttpClient() +# Queue response for connectivity check +mock_http.queue_response_for_url( + "https://internet-up.ably-realtime.com/is-the-internet-up.txt", + 200, + "yes" +) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Trigger connectivity check (implementation-specific) +# Some libraries expose this, others do it internally +result = AWAIT client.connection.checkConnectivity() +# OR: observe that connectivity check request was made during connection +``` + +### Assertions +```pseudo +connectivity_requests = mock_http.captured_requests.filter( + r => r.url.path CONTAINS "is-the-internet-up" +) +ASSERT connectivity_requests.length >= 1 +ASSERT connectivity_requests[0].url.toString() == "https://internet-up.ably-realtime.com/is-the-internet-up.txt" + +CLOSE_CLIENT(client) +``` + +--- + +## REC3b - Custom connectivity check URL + +**Test ID**: `rest/unit/REC3b/custom-connectivity-check-url-0` + +Tests that the `connectivityCheckUrl` option overrides the default. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response_for_url( + "https://custom.example.com/connectivity", + 200, + "ok" +) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + connectivityCheckUrl: "https://custom.example.com/connectivity" +)) +``` + +### Test Steps +```pseudo +result = AWAIT client.connection.checkConnectivity() +``` + +### Assertions +```pseudo +connectivity_requests = mock_http.captured_requests.filter( + r => r.url.host == "custom.example.com" +) +ASSERT connectivity_requests.length >= 1 +ASSERT connectivity_requests[0].url.toString() == "https://custom.example.com/connectivity" + +# Should NOT request the default URL +default_requests = mock_http.captured_requests.filter( + r => r.url.host == "internet-up.ably-realtime.com" +) +ASSERT default_requests.length == 0 + +CLOSE_CLIENT(client) +``` + +--- + +## REC3 - Connectivity check response validation + +**Test ID**: `rest/unit/REC3/connectivity-check-validation-0` + +Tests that the connectivity check expects a specific response. + +### Test Cases + +| ID | Response | Expected Result | +|----|----------|-----------------| +| 1 | HTTP 200 with body "yes" | Connected | +| 2 | HTTP 200 with body "no" | Not connected | +| 3 | HTTP 200 with empty body | Not connected | +| 4 | HTTP 404 | Not connected | +| 5 | Network error | Not connected | + +### Setup (Case 1 - Success) +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response_for_url( + "https://internet-up.ably-realtime.com/is-the-internet-up.txt", + 200, + "yes" +) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +result = AWAIT client.connection.checkConnectivity() + +ASSERT result == true + +CLOSE_CLIENT(client) +``` + +### Setup (Case 2 - Wrong body) +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response_for_url( + "https://internet-up.ably-realtime.com/is-the-internet-up.txt", + 200, + "no" +) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +result = AWAIT client.connection.checkConnectivity() + +ASSERT result == false + +CLOSE_CLIENT(client) +``` + +### Setup (Case 4 - HTTP error) +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response_for_url( + "https://internet-up.ably-realtime.com/is-the-internet-up.txt", + 404, + "Not Found" +) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +result = AWAIT client.connection.checkConnectivity() + +ASSERT result == false + +CLOSE_CLIENT(client) +``` diff --git a/uts/rest/unit/helpers/mock_http.md b/uts/rest/unit/helpers/mock_http.md new file mode 100644 index 000000000..6cd99961a --- /dev/null +++ b/uts/rest/unit/helpers/mock_http.md @@ -0,0 +1,227 @@ +# Mock HTTP Infrastructure + +This document specifies the mock HTTP infrastructure for REST unit tests. All REST unit tests that need to intercept HTTP requests should reference this document. + +## Purpose + +The mock infrastructure enables unit testing of REST client behavior without making real network calls. It supports: + +1. **Intercepting HTTP requests** - Capture the URL, headers, method, and body of outgoing requests +2. **Controlling request outcomes** - Simulate various connection results including successful responses, connection refused, DNS errors, timeouts, and other network-level failures +3. **Injecting responses** - Configure responses (status, headers, body) to be returned +4. **Capturing requests** - Record all request details for test assertions + +## Installation Mechanism + +The mechanism for injecting the mock is implementation-specific and not part of the public API. Possible approaches include: + +- Dependency injection of HTTP client interface +- Platform-specific mocking (e.g., URLProtocol in Swift, HttpClientHandler in .NET) +- Test doubles or mocking frameworks +- Package-level variable substitution + +## Mock Interface + +```pseudo +interface MockHttpClient: + # Awaitable event triggers for test code + await_connection_attempt(timeout?: Duration): Future + await_request(timeout?: Duration): Future + + # Test management + reset() # Clear all state + +interface PendingConnection: + host: String + port: Int + tls: Boolean + timestamp: Time + + # Methods for test code to respond to the connection attempt + respond_with_success() # Connection succeeds, allows HTTP requests + respond_with_refused() # Connection refused at network level + respond_with_timeout() # Connection times out (unresponsive) + respond_with_dns_error() # DNS resolution fails + +interface PendingRequest: + url: URL + method: String # GET, POST, etc. + headers: Map + body: Bytes + timestamp: Time + + # Methods for test code to respond to the HTTP request + respond_with(status: Int, body: Any, headers?: Map) + respond_with_delay(delay: Duration, status: Int, body: Any, headers?: Map) + respond_with_timeout() # Request timeout (after connection established) +``` + +## Handler-Based Configuration + +For simple test scenarios, implementations may support handler-based configuration: + +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + }, + onRequest: (req) => { + IF req.url.path == "/time": + req.respond_with(200, {"time": 1234567890000}) + ELSE: + req.respond_with(404, {"error": {"code": 40400}}) + } +) +``` + +Handlers are called automatically when connection attempts or requests occur. The await-based API should always be available for tests that need to coordinate responses with test state. + +### When to Use Each Pattern + +**Handler pattern** (recommended for most tests): +- Response is predetermined based on URL, method, or request count +- Simple scenarios with known request/response pairs +- No need to coordinate with external test state + +**Await pattern** (for advanced scenarios): +- Need to inspect request details before deciding how to respond +- Test logic depends on external state not known at setup time +- Complex coordination between request timing and test assertions + +## Example: Handler Pattern + +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"time": 1234567890000}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +result = AWAIT client.time() + +ASSERT captured_requests.length == 1 +ASSERT captured_requests[0].url.path == "/time" +``` + +## Example: Handler with State (Different Responses by Count) + +```pseudo +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count++ + IF request_count == 1: + req.respond_with(500, {"error": {"code": 50000}}) + ELSE: + req.respond_with(200, {"time": 1234567890000}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# First request fails, triggers retry, second succeeds +result = AWAIT client.time() + +ASSERT request_count == 2 +``` + +## Example: Await Pattern + +```pseudo +mock_http = MockHttpClient() +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# Start request in background +request_future = client.time() + +# Wait for and handle connection +connection = AWAIT mock_http.await_connection_attempt() +connection.respond_with_success() + +# Wait for and handle HTTP request +request = AWAIT mock_http.await_request() +ASSERT request.headers["X-Ably-Version"] IS NOT null +request.respond_with(200, {"time": 1234567890000}) + +# Complete the operation +result = AWAIT request_future +``` + +## Connection-Level Failures + +The mock distinguishes between connection-level and request-level failures: + +**Connection-level failures** (handled by `PendingConnection`): +- `respond_with_refused()` - TCP connection refused +- `respond_with_timeout()` - Connection attempt times out +- `respond_with_dns_error()` - DNS resolution fails + +**Request-level failures** (handled by `PendingRequest`): +- `respond_with(4xx/5xx, ...)` - HTTP error response +- `respond_with_timeout()` - Request times out after connection established + +```pseudo +# Connection refused example +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_refused() +) + +# vs HTTP 500 error example +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(500, {"error": {...}}) +) +``` + +## Test Isolation + +Each test should: + +1. Create a fresh mock HTTP client +2. Install/inject the mock +3. Create the REST client +4. Perform test steps and assertions +5. Clean up the mock + +```pseudo +BEFORE EACH TEST: + mock_http = MockHttpClient() + install_mock(mock_http) + +AFTER EACH TEST: + uninstall_mock() +``` + +## Timer Mocking for Timeouts + +Tests that verify timeout behavior should use timer mocking where practical to avoid slow tests. + +**Approaches (in order of preference):** + +1. **Mock/fake timers** - Use framework-provided timer mocking + ```pseudo + enable_fake_timers() + request_future = client.time() + ADVANCE_TIME(1000) # Instantly trigger timeout + ``` + +2. **Dependency injection** - Library accepts clock interface in tests + +3. **Short timeouts** - Use very short timeout values + ```pseudo + client = Rest(options: ClientOptions(httpRequestTimeout: 50)) + ``` + +4. **Actual delays** - Last resort if mocking unavailable diff --git a/uts/rest/unit/logging.md b/uts/rest/unit/logging.md new file mode 100644 index 000000000..c50a21b21 --- /dev/null +++ b/uts/rest/unit/logging.md @@ -0,0 +1,207 @@ +# Logging Tests + +Spec points: `RSC2`, `RSC3`, `RSC4`, `TO3b`, `TO3c` + +## Test Type +Unit test with mocked HTTP client + +## Mock Configuration + +These tests use the mock HTTP infrastructure defined in `rest_client.md`. The mock supports: +- Handler-based configuration with `onConnectionAttempt` and `onRequest` +- Capturing requests via `captured_requests` arrays +- Configurable responses with status codes, bodies, and headers + +See `rest_client.md` for detailed mock interface documentation. + +## Purpose + +Tests the logging support for the Ably client. The logging API uses a structured +format where each log event has a fixed message string and a context map of +key-value pairs, rather than interpolated strings. + +The `LogHandler` signature is: +``` +LogHandler(level: LogLevel, message: String, context: Map) +``` + +--- + +## RSC2 - Default log level is warn + +**Test ID**: `rest/unit/RSC2/default-log-level-warn-0` + +**Spec requirement:** The default log level is `warn`. Only `error` and `warn` level +events should be emitted when the default level is used. + +### Setup +```pseudo +captured_logs = [] + +mock_http = MockHttpClient( + onRequest: (req) => req.respond_with(200, [1704067200000]) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "app.key:secret", + logHandler: (level, message, context) => captured_logs.push({level, message, context}) +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() + +# Default level is warn, so info/debug/verbose messages should be filtered +ASSERT ALL log IN captured_logs: log.level IN [error, warn] +``` + +--- + +## TO3b - Log level can be changed + +**Test ID**: `rest/unit/TO3b/log-level-changeable-0` + +**Spec requirement:** The log level can be changed via `ClientOptions.logLevel`. +Setting the level to `verbose` should capture all log events. + +### Setup +```pseudo +captured_logs = [] + +mock_http = MockHttpClient( + onRequest: (req) => req.respond_with(200, [1704067200000]) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "app.key:secret", + logLevel: verbose, + logHandler: (level, message, context) => captured_logs.push({level, message, context}) +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() + +# With verbose, should have info+debug+verbose messages +info_logs = captured_logs.filter(l => l.level == info) +ASSERT info_logs.length > 0 + +# Must have an info log for the time() method entry (not checked directly, +# but client creation emits "Client created" at info level) +ASSERT ANY log IN captured_logs: log.level == info + +# Must have a debug log for the HTTP request +debug_logs = captured_logs.filter(l => l.level == debug) +ASSERT ANY log IN debug_logs: log.message CONTAINS "HTTP request" +``` + +--- + +## TO3c - Custom log handler receives structured events + +**Test ID**: `rest/unit/TO3c/custom-handler-structured-events-0` + +**Spec requirement:** A custom log handler provided via `ClientOptions.logHandler` +receives structured log events with level, message, and context. + +### Setup +```pseudo +captured_logs = [] + +mock_http = MockHttpClient( + onRequest: (req) => req.respond_with(200, [1704067200000]) +) +install_mock(mock_http) + +handler = (level, message, context) => captured_logs.push({level, message, context}) + +client = Rest(options: ClientOptions( + key: "app.key:secret", + logLevel: info, + logHandler: handler +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() + +# Custom handler was called +ASSERT captured_logs.length > 0 + +# Structured context is provided +ASSERT ANY log IN captured_logs: log.context IS NOT EMPTY +``` + +--- + +## TO3c2 - Structured context contains expected keys + +**Test ID**: `rest/unit/TO3c2/context-contains-expected-keys-0` + +**Spec requirement:** The structured context map contains relevant key-value pairs +for the log event. HTTP request logs include method, host, and path. + +### Setup +```pseudo +captured_logs = [] + +mock_http = MockHttpClient( + onRequest: (req) => req.respond_with(200, [1704067200000]) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "app.key:secret", + logLevel: debug, + logHandler: (level, message, context) => captured_logs.push({level, message, context}) +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() + +# Find the HTTP request log +http_logs = captured_logs.filter(l => l.message CONTAINS "HTTP request" AND l.level == debug) +ASSERT http_logs.length >= 1 +ASSERT "method" IN http_logs[0].context +ASSERT "host" IN http_logs[0].context +ASSERT "path" IN http_logs[0].context +``` + +--- + +## RSC2b - LogLevel.none produces no log events + +**Test ID**: `rest/unit/RSC2b/log-level-none-suppresses-all-0` + +**Spec requirement:** Setting log level to `none` should suppress all log output. + +### Setup +```pseudo +captured_logs = [] + +mock_http = MockHttpClient( + onRequest: (req) => req.respond_with(200, [1704067200000]) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "app.key:secret", + logLevel: none, + logHandler: (level, message, context) => captured_logs.push({level, message, context}) +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() + +# No logs should be captured +ASSERT captured_logs.length == 0 +``` diff --git a/uts/rest/unit/presence/rest_presence.md b/uts/rest/unit/presence/rest_presence.md new file mode 100644 index 000000000..2eac62e18 --- /dev/null +++ b/uts/rest/unit/presence/rest_presence.md @@ -0,0 +1,1683 @@ +# REST Presence Unit Tests + +Spec points: `RSL3`, `RSP1`, `RSP1a`, `RSP1b`, `RSP3`, `RSP3a1`, `RSP3a2`, `RSP3a3`, `RSP4`, `RSP4a`, `RSP4b1`, `RSP4b2`, `RSP4b3`, `RSP5` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure described in `/Users/paddy/data/worknew/dev/dart-experiments/uts/test/rest/unit/rest_client.md`. + +The mock supports: +- Intercepting HTTP requests and capturing details (URL, headers, method, body) +- Queueing responses with configurable status, headers, and body +- Handler-based configuration with `onConnectionAttempt` and `onRequest` +- Recording requests in `captured_requests` arrays +- Request counting with `request_count` variables + +--- + +## RSP1, RSL3 - RestPresence object associated with channel + +### RSP1a, RSL3 - Presence accessible via RestChannel#presence + +**Test ID**: `rest/unit/RSP1a/presence-channel-attribute-0` + +**Spec requirement:** Each `RestChannel` provides access to a `RestPresence` object via the `presence` property (RSP1a). The `RestChannel#presence` attribute contains a `RestPresence` object for this channel (RSL3). + +```pseudo +channel_name = "test-RSP1a-${random_id()}" + +Given a REST client with mocked HTTP +And a channel channel_name +When accessing channel.presence +Then a RestPresence object is returned +And the presence object is associated with channel_name +``` + +### RSP1b - Same presence object returned for same channel + +**Test ID**: `rest/unit/RSP1b/same-instance-returned-0` + +**Spec requirement:** The same `RestPresence` instance must be returned for multiple accesses to the same channel's presence property. + +```pseudo +channel_name = "test-RSP1b-${random_id()}" + +Given a REST client with mocked HTTP +And a channel = client.channels.get(channel_name) +When accessing channel.presence multiple times +Then the same RestPresence instance is returned each time +``` + +--- + +## RSP3 - RestPresence#get + +### RSP3a - Get sends GET request to presence endpoint + +**Test ID**: `rest/unit/RSP3a/get-request-endpoint-0` + +**Spec requirement:** The `get` method sends a GET request to `/channels//presence` and returns a `PaginatedResult`. + +### Setup +```pseudo +channel_name = "test-RSP3a-${random_id()}" +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + request_count++ + req.respond_with(200, [ + { "action": 1, "clientId": "client1", "data": "hello" }, + { "action": 1, "clientId": "client2", "data": "world" } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT request_count == 1 +ASSERT captured_requests[0].method == "GET" +ASSERT captured_requests[0].url.path == "/channels/" + encode_uri_component(channel_name) + "/presence" +ASSERT result IS PaginatedResult +ASSERT result.items.length == 2 +``` + +--- + +### RSP3b - Get returns PresenceMessage objects + +**Test ID**: `rest/unit/RSP3b/get-returns-presence-messages-0` + +**Spec requirement:** The response items must be decoded into `PresenceMessage` objects with all fields correctly populated. + +### Setup +```pseudo +channel_name = "test-RSP3b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "action": 1, + "clientId": "user123", + "connectionId": "conn456", + "data": "status data", + "encoding": null, + "timestamp": 1234567890000 + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT result.items.length == 1 +ASSERT result.items[0] IS PresenceMessage +ASSERT result.items[0].action == PresenceAction.present # action 1 +ASSERT result.items[0].clientId == "user123" +ASSERT result.items[0].connectionId == "conn456" +ASSERT result.items[0].data == "status data" +ASSERT result.items[0].timestamp == 1234567890000 +``` + +--- + +### RSP3c - Get with no members returns empty list + +**Test ID**: `rest/unit/RSP3c/get-empty-members-0` + +**Spec requirement:** When no presence members exist, `get` returns an empty list in the `PaginatedResult`. + +### Setup +```pseudo +channel_name = "test-RSP3c-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT result.items IS List +ASSERT result.items.length == 0 +ASSERT result.hasNext() == false +``` + +--- + +### RSP3a1a - Get with limit parameter + +**Test ID**: `rest/unit/RSP3a1/get-limit-parameter-0` + +**Spec requirement:** The `limit` parameter must be included in the query string when specified. + +### Setup +```pseudo +channel_name = "test-RSP3a1a-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { "action": 1, "clientId": "client1" }, + { "action": 1, "clientId": "client2" } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.get(limit: 50) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["limit"] == "50" +``` + +--- + +### RSP3a1b - Get limit defaults to 100 + +**Test ID**: `rest/unit/RSP3a1/get-limit-default-100-1` + +**Spec requirement:** When no limit is specified, the default limit of 100 is used (or not explicitly sent). + +### Setup +```pseudo +channel_name = "test-RSP3a1b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT "limit" NOT IN captured_requests[0].url.query_params + OR captured_requests[0].url.query_params["limit"] == "100" +``` + +--- + +### RSP3a1c - Get limit maximum is 1000 + +**Test ID**: `rest/unit/RSP3a1/get-limit-max-1000-2` + +**Spec requirement:** The maximum allowed limit value is 1000. + +### Setup +```pseudo +channel_name = "test-RSP3a1c-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.get(limit: 1000) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["limit"] == "1000" +``` + +--- + +### RSP3a2 - Get with clientId filter + +**Test ID**: `rest/unit/RSP3a2/get-clientid-filter-0` + +**Spec requirement:** The `clientId` parameter filters presence members by client identifier. + +### Setup +```pseudo +channel_name = "test-RSP3a2-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { "action": 1, "clientId": "specific-client", "data": "filtered" } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.get(clientId: "specific-client") +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["clientId"] == "specific-client" +``` + +--- + +### RSP3a3 - Get with connectionId filter + +**Test ID**: `rest/unit/RSP3a3/get-connectionid-filter-0` + +**Spec requirement:** The `connectionId` parameter filters presence members by connection identifier. + +### Setup +```pseudo +channel_name = "test-RSP3a3-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { "action": 1, "clientId": "client1", "connectionId": "conn123" } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.get(connectionId: "conn123") +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["connectionId"] == "conn123" +``` + +--- + +### RSP3 - Get with multiple filters + +**Test ID**: `rest/unit/RSP3/get-multiple-filters-0` + +**Spec requirement:** Multiple query parameters can be combined in a single request. + +### Setup +```pseudo +channel_name = "test-RSP3-multi-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.get( + limit: 25, + clientId: "user1", + connectionId: "conn1" +) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["limit"] == "25" +ASSERT captured_requests[0].url.query_params["clientId"] == "user1" +ASSERT captured_requests[0].url.query_params["connectionId"] == "conn1" +``` + +--- + +## RSP4 - RestPresence#history + +### RSP4a - History sends GET request to presence history endpoint + +**Test ID**: `rest/unit/RSP4a/history-request-endpoint-0` + +| Spec | Requirement | +|------|-------------| +| RSP4 | History method fetches presence event history | +| RSP4a | Returns `PaginatedResult` | + +### Setup +```pseudo +channel_name = "test-RSP4a-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { "action": 2, "clientId": "client1", "data": "entered" }, + { "action": 4, "clientId": "client1", "data": "left" } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.history() +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].method == "GET" +ASSERT captured_requests[0].url.path == "/channels/" + encode_uri_component(channel_name) + "/presence/history" +ASSERT result IS PaginatedResult +``` + +--- + +### RSP4a - History returns PaginatedResult of PresenceMessage + +**Test ID**: `rest/unit/RSP4a/history-returns-paginated-1` + +**Spec requirement:** History responses contain `PresenceMessage` objects with various action types. + +### Setup +```pseudo +channel_name = "test-RSP4a-result-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { "action": 2, "clientId": "user1", "data": "d1", "timestamp": 1000 }, + { "action": 3, "clientId": "user1", "data": "d2", "timestamp": 2000 }, + { "action": 4, "clientId": "user1", "data": "d3", "timestamp": 3000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.history() +``` + +### Assertions +```pseudo +ASSERT result IS PaginatedResult +ASSERT result.items.length == 3 +ASSERT result.items[0].action == PresenceAction.enter # action 2 +ASSERT result.items[1].action == PresenceAction.leave # action 3 +ASSERT result.items[2].action == PresenceAction.update # action 4 +``` + +--- + +### RSP4b1a - History with start parameter + +**Test ID**: `rest/unit/RSP4b1/history-start-parameter-0` + +**Spec requirement:** The `start` parameter filters events from a given timestamp (inclusive). + +### Setup +```pseudo +channel_name = "test-RSP4b1a-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +start_time = 1609459200000 # 2021-01-01 00:00:00 UTC +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history(start: start_time) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["start"] == "1609459200000" +``` + +--- + +### RSP4b1b - History with end parameter + +**Test ID**: `rest/unit/RSP4b1/history-end-parameter-1` + +**Spec requirement:** The `end` parameter filters events up to a given timestamp (inclusive). + +### Setup +```pseudo +channel_name = "test-RSP4b1b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +end_time = 1609545600000 # 2021-01-02 00:00:00 UTC +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history(end: end_time) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["end"] == "1609545600000" +``` + +--- + +### RSP4b1c - History with start and end parameters + +**Test ID**: `rest/unit/RSP4b1/history-start-end-params-2` + +**Spec requirement:** Start and end parameters can be combined to define a time range. + +### Setup +```pseudo +channel_name = "test-RSP4b1c-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +start_time = 1609459200000 +end_time = 1609545600000 +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history( + start: start_time, + end: end_time +) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["start"] == "1609459200000" +ASSERT captured_requests[0].url.query_params["end"] == "1609545600000" +``` + +--- + +### RSP4b1d - History accepts DateTime objects for start/end + +**Test ID**: `rest/unit/RSP4b1/history-datetime-objects-3` + +**Spec requirement:** Language-specific DateTime objects should be accepted and converted to milliseconds since epoch. + +### Setup +```pseudo +channel_name = "test-RSP4b1d-${random_id()}" +# Language-specific: if the language supports DateTime/Date objects +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +start_datetime = DateTime(2021, 1, 1, 0, 0, 0, UTC) # language-specific +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history(start: start_datetime) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["start"] == "1609459200000" +``` + +--- + +### RSP4b2a - History with direction backwards (default) + +**Test ID**: `rest/unit/RSP4b2/history-direction-backwards-default-0` + +**Spec requirement:** The default direction is `backwards` (newest first). + +### Setup +```pseudo +channel_name = "test-RSP4b2a-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history() +``` + +### Assertions +```pseudo +ASSERT "direction" NOT IN captured_requests[0].url.query_params + OR captured_requests[0].url.query_params["direction"] == "backwards" +``` + +--- + +### RSP4b2b - History with direction forwards + +**Test ID**: `rest/unit/RSP4b2/history-direction-forwards-1` + +**Spec requirement:** The `direction` parameter can be set to `forwards` (oldest first). + +### Setup +```pseudo +channel_name = "test-RSP4b2b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history(direction: "forwards") +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["direction"] == "forwards" +``` + +--- + +### RSP4b2c - History with direction backwards explicit + +**Test ID**: `rest/unit/RSP4b2/history-direction-backwards-explicit-2` + +**Spec requirement:** The `direction` parameter can be explicitly set to `backwards`. + +### Setup +```pseudo +channel_name = "test-RSP4b2c-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history(direction: "backwards") +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["direction"] == "backwards" +``` + +--- + +### RSP4b3a - History with limit parameter + +**Test ID**: `rest/unit/RSP4b3/history-limit-parameter-0` + +**Spec requirement:** The `limit` parameter controls the maximum number of results per page. + +### Setup +```pseudo +channel_name = "test-RSP4b3a-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history(limit: 50) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["limit"] == "50" +``` + +--- + +### RSP4b3b - History limit defaults to 100 + +**Test ID**: `rest/unit/RSP4b3/history-limit-default-100-1` + +**Spec requirement:** When no limit is specified, the default is 100. + +### Setup +```pseudo +channel_name = "test-RSP4b3b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history() +``` + +### Assertions +```pseudo +ASSERT "limit" NOT IN captured_requests[0].url.query_params + OR captured_requests[0].url.query_params["limit"] == "100" +``` + +--- + +### RSP4b3c - History limit maximum is 1000 + +**Test ID**: `rest/unit/RSP4b3/history-limit-max-1000-2` + +**Spec requirement:** The maximum allowed limit is 1000. + +### Setup +```pseudo +channel_name = "test-RSP4b3c-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history(limit: 1000) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["limit"] == "1000" +``` + +--- + +### RSP4 - History with all parameters + +**Test ID**: `rest/unit/RSP4/history-all-parameters-0` + +**Spec requirement:** All query parameters can be combined in a single request. + +### Setup +```pseudo +channel_name = "test-RSP4-all-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history( + start: 1609459200000, + end: 1609545600000, + direction: "forwards", + limit: 50 +) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["start"] == "1609459200000" +ASSERT captured_requests[0].url.query_params["end"] == "1609545600000" +ASSERT captured_requests[0].url.query_params["direction"] == "forwards" +ASSERT captured_requests[0].url.query_params["limit"] == "50" +``` + +--- + +## RSP5 - Presence message decoding + +### RSP5a - String data decoded as string + +**Test ID**: `rest/unit/RSP5/decode-string-data-0` + +**Spec requirement:** Plain string data must be decoded without modification. + +### Setup +```pseudo +channel_name = "test-RSP5a-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { "action": 1, "clientId": "c1", "data": "plain string data" } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT result.items[0].data == "plain string data" +ASSERT result.items[0].data IS String +``` + +--- + +### RSP5b - JSON encoded data decoded to object + +**Test ID**: `rest/unit/RSP5/decode-json-data-1` + +**Spec requirement:** Data with `encoding: "json"` must be decoded from JSON string to native object. + +### Setup +```pseudo +channel_name = "test-RSP5b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "action": 1, + "clientId": "c1", + "data": "{\"status\":\"online\",\"count\":42}", + "encoding": "json" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT result.items[0].data IS Object/Map +ASSERT result.items[0].data["status"] == "online" +ASSERT result.items[0].data["count"] == 42 +ASSERT result.items[0].encoding == null # encoding consumed +``` + +--- + +### RSP5c - Base64 encoded data decoded to binary + +**Test ID**: `rest/unit/RSP5/decode-base64-binary-2` + +**Spec requirement:** Data with `encoding: "base64"` must be decoded from base64 to binary. + +### Setup +```pseudo +channel_name = "test-RSP5c-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "action": 1, + "clientId": "c1", + "data": "SGVsbG8gV29ybGQ=", # "Hello World" in base64 + "encoding": "base64" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT result.items[0].data IS Binary/Uint8List/[]byte +ASSERT result.items[0].data == bytes("Hello World") +ASSERT result.items[0].encoding == null # encoding consumed +``` + +--- + +### RSP5 - Binary presence data decoded from MessagePack response + +**Test ID**: `rest/unit/RSP5/decode-msgpack-binary-3` + +**Spec requirement:** When a presence response is returned in MessagePack format with binary data (msgpack `bin` type), the data must be decoded as binary, not as a string — even if the bytes are valid UTF-8. This parallels the RSL6 msgpack binary decoding test for channel messages. + +### Setup +```pseudo +channel_name = "test-RSP5-msgpack-binary-${random_id()}" + +# Binary payload using msgpack bin type (valid UTF-8 bytes) +binary_payload = bytes([0x73, 0x6F, 0x6D, 0x65, 0x20, 0x64, 0x61, 0x74, 0x61]) # "some data" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, + body: msgpack_encode([ + { "action": 1, "clientId": "client1", "data": binary_payload } + ]), + headers: { "Content-Type": "application/x-msgpack" } + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: true +)) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +# Binary data must remain binary, NOT be converted to a string +ASSERT result.items[0].data IS Binary/Uint8List/[]byte +ASSERT result.items[0].data == bytes([0x73, 0x6F, 0x6D, 0x65, 0x20, 0x64, 0x61, 0x74, 0x61]) +ASSERT result.items[0].encoding IS null +``` + +--- + +### RSP5d - UTF-8 encoded data decoded correctly + +**Test ID**: `rest/unit/RSP5/decode-utf8-data-4` + +**Spec requirement:** Data with `encoding: "utf-8/base64"` must be decoded through both layers. + +### Setup +```pseudo +channel_name = "test-RSP5d-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "action": 1, + "clientId": "c1", + "data": "SGVsbG8gV29ybGQ=", # base64 of UTF-8 bytes + "encoding": "utf-8/base64" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT result.items[0].data == "Hello World" +ASSERT result.items[0].data IS String +``` + +--- + +### RSP5e - Chained encoding decoded in order + +**Test ID**: `rest/unit/RSP5/decode-chained-encoding-5` + +**Spec requirement:** Chained encodings (e.g., `json/base64`) must be decoded in reverse order (last applied, first removed). + +### Setup +```pseudo +channel_name = "test-RSP5e-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "action": 1, + "clientId": "c1", + "data": "eyJrZXkiOiJ2YWx1ZSJ9", # base64 of {"key":"value"} + "encoding": "json/base64" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +# Decoding order: base64 first, then json +ASSERT result.items[0].data IS Object/Map +ASSERT result.items[0].data["key"] == "value" +``` + +--- + +### RSP5f - History messages also decoded + +**Test ID**: `rest/unit/RSP5/decode-history-messages-6` + +**Spec requirement:** Encoding decoding applies to both `get` and `history` methods. + +### Setup +```pseudo +channel_name = "test-RSP5f-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "action": 2, + "clientId": "c1", + "data": "{\"event\":\"entered\"}", + "encoding": "json" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.history() +``` + +### Assertions +```pseudo +ASSERT result.items[0].data IS Object/Map +ASSERT result.items[0].data["event"] == "entered" +``` + +--- + +### RSP5g - Cipher decoding with channel options + +**Test ID**: `rest/unit/RSP5/decode-cipher-channel-7` + +**Spec requirement:** Encrypted data with cipher encoding must be decrypted using channel cipher options. + +### Setup +```pseudo +channel_name = "test-RSP5g-${random_id()}" +captured_requests = [] +cipher_key = base64_decode("WUP6u0K7MXI5Zeo0VppPwg==") + +# Encrypted data for {"secret":"data"} +encrypted_data = "HO4cYSP8LybPYBPZPHQOtuD53yrD3YV3NBoTEYBh4U0=" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "action": 1, + "clientId": "c1", + "data": encrypted_data, + "encoding": "json/utf-8/cipher+aes-128-cbc/base64" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name, options: RestChannelOptions( + cipher: CipherParams(key: cipher_key, algorithm: "aes", mode: "cbc") +)) +``` + +### Test Steps +```pseudo +result = AWAIT channel.presence.get() +``` + +### Assertions +```pseudo +ASSERT result.items[0].data IS Object/Map +# Decryption applied based on cipher+aes-128-cbc encoding +``` + +--- + +## Pagination + +### RSP_Pagination_1 - Get returns paginated result with Link header + +**Test ID**: `rest/unit/RSP3/get-pagination-link-header-1` + +**Spec requirement:** Responses with Link headers must support pagination via `hasNext()` and `next()`. + +### Setup +```pseudo +channel_name = "test-RSP-pagination1-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, + body: [ + { "action": 1, "clientId": "client1" }, + { "action": 1, "clientId": "client2" } + ], + headers: { + "Link": "; rel=\"next\"" + } + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT result.items.length == 2 +ASSERT result.hasNext() == true +``` + +--- + +### RSP_Pagination_2 - Get next page fetches from Link URL + +**Test ID**: `rest/unit/RSP3/get-pagination-next-page-2` + +**Spec requirement:** Calling `next()` must use the URL from the Link header to fetch the next page. + +### Setup +```pseudo +channel_name = "test-RSP-pagination2-${random_id()}" +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + request_count++ + + IF request_count == 1: + req.respond_with(200, + body: [{ "action": 1, "clientId": "client1" }], + headers: { "Link": "; rel=\"next\"" } + ) + ELSE: + req.respond_with(200, body: [{ "action": 1, "clientId": "client2" }]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +page1 = AWAIT client.channels.get(channel_name).presence.get() +page2 = AWAIT page1.next() +``` + +### Assertions +```pseudo +ASSERT page1.items[0].clientId == "client1" +ASSERT page2.items[0].clientId == "client2" +ASSERT page2.hasNext() == false +``` + +--- + +### RSP_Pagination_3 - History pagination works the same + +**Test ID**: `rest/unit/RSP4/history-pagination-1` + +**Spec requirement:** History results must support the same pagination behavior as get. + +### Setup +```pseudo +channel_name = "test-RSP-pagination3-${random_id()}" +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + request_count++ + + IF request_count == 1: + req.respond_with(200, + body: [{ "action": 2, "clientId": "c1", "timestamp": 3000 }], + headers: { "Link": "; rel=\"next\"" } + ) + ELSE: + req.respond_with(200, body: [{ "action": 4, "clientId": "c1", "timestamp": 1000 }]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +page1 = AWAIT client.channels.get(channel_name).presence.history() +page2 = AWAIT page1.next() +``` + +### Assertions +```pseudo +ASSERT page1.items[0].action == PresenceAction.enter +ASSERT page2.items[0].action == PresenceAction.leave +``` + +--- + +## Error Handling + +### RSP_Error_1 - Get with server error throws AblyException + +**Test ID**: `rest/unit/RSP3/get-server-error-3` + +**Spec requirement:** Server errors must be raised as `AblyException` with appropriate error code and status. + +### Setup +```pseudo +channel_name = "test-RSP-error1-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(500, { + "error": { + "code": 50000, + "statusCode": 500, + "message": "Internal server error" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.get() FAILS WITH error +ASSERT error.code == 50000 +ASSERT error.statusCode == 500 +``` + +--- + +### RSP_Error_2 - History with invalid auth throws AblyException + +**Test ID**: `rest/unit/RSP4/history-auth-error-2` + +**Spec requirement:** Authentication errors must raise `AblyException` with code 40101. + +### Setup +```pseudo +channel_name = "test-RSP-error2-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(401, { + "error": { + "code": 40101, + "statusCode": 401, + "message": "Invalid credentials" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "invalid.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history() FAILS WITH error +ASSERT error.code == 40101 +ASSERT error.statusCode == 401 +``` + +--- + +### RSP_Error_3 - Get with channel not found + +**Test ID**: `rest/unit/RSP3/get-channel-not-found-4` + +**Spec requirement:** 404 responses must raise `AblyException` with code 40400. + +### Setup +```pseudo +channel_name = "test-RSP-error3-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(404, { + "error": { + "code": 40400, + "statusCode": 404, + "message": "Channel not found" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.get() FAILS WITH error +ASSERT error.code == 40400 +ASSERT error.statusCode == 404 +``` + +--- + +## Request Headers + +### RSP_Headers_1 - Get includes standard headers + +**Test ID**: `rest/unit/RSP3/get-standard-headers-5` + +**Spec requirement:** All REST requests must include standard Ably headers (X-Ably-Version, Ably-Agent, Accept). + +### Setup +```pseudo +channel_name = "test-RSP-headers1-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT "X-Ably-Version" IN captured_requests[0].headers +ASSERT captured_requests[0].headers["Ably-Agent"] contains "ably-" +ASSERT "Accept" IN captured_requests[0].headers +``` + +--- + +### RSP_Headers_2 - History includes authorization header + +**Test ID**: `rest/unit/RSP4/history-auth-header-3` + +**Spec requirement:** Authenticated requests must include the Authorization header. + +### Setup +```pseudo +channel_name = "test-RSP-headers2-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history() +``` + +### Assertions +```pseudo +ASSERT "Authorization" IN captured_requests[0].headers +ASSERT captured_requests[0].headers["Authorization"] starts with "Basic " +``` + +--- + +### RSP_Headers_3 - Request ID included when enabled + +**Test ID**: `rest/unit/RSP3/get-request-id-enabled-6` + +**Spec requirement:** When `addRequestIds` is enabled, a unique `request_id` query parameter must be included. + +### Setup +```pseudo +channel_name = "test-RSP-headers3-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + addRequestIds: true +)) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT "request_id" IN captured_requests[0].url.query_params +ASSERT captured_requests[0].url.query_params["request_id"] IS NOT empty +``` + +--- + +## PresenceAction Values + +### RSP_Action_1 - All presence actions correctly mapped + +**Test ID**: `rest/unit/RSP5/presence-action-mapping-8` + +**Spec requirement:** All presence action values must be correctly mapped between wire protocol and SDK types. + +### Setup +```pseudo +channel_name = "test-RSP-action1-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { "action": 0, "clientId": "c1" }, # absent + { "action": 1, "clientId": "c2" }, # present + { "action": 2, "clientId": "c3" }, # enter + { "action": 3, "clientId": "c4" }, # leave + { "action": 4, "clientId": "c5" } # update + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.history() +``` + +### Assertions +```pseudo +ASSERT result.items[0].action == PresenceAction.absent +ASSERT result.items[1].action == PresenceAction.present +ASSERT result.items[2].action == PresenceAction.enter +ASSERT result.items[3].action == PresenceAction.leave +ASSERT result.items[4].action == PresenceAction.update +``` + +Note: Action values may vary by SDK. The wire protocol uses: +- 0 = absent +- 1 = present +- 2 = enter +- 3 = leave (some SDKs use 4) +- 4 = update (some SDKs use 3) + +Verify against your SDK's specific mapping. diff --git a/uts/rest/unit/push/push_admin_publish.md b/uts/rest/unit/push/push_admin_publish.md new file mode 100644 index 000000000..7b13c3e22 --- /dev/null +++ b/uts/rest/unit/push/push_admin_publish.md @@ -0,0 +1,346 @@ +# PushAdmin Publish Tests + +Spec points: `RSH1`, `RSH1a` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. + +--- + +## RSH1 — client.push.admin exposes PushAdmin object + +**Test ID**: `rest/unit/RSH1/push-admin-accessible-0` + +**Spec requirement:** RSH1 — `Push#admin` object provides the PushAdmin interface. + +Tests that the REST client exposes a `push.admin` object of the correct type. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {}) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Assertions +```pseudo +ASSERT client.push IS Push +ASSERT client.push.admin IS PushAdmin +ASSERT client.push.admin.deviceRegistrations IS PushDeviceRegistrations +ASSERT client.push.admin.channelSubscriptions IS PushChannelSubscriptions +``` + +--- + +## RSH1a — publish sends POST to /push/publish + +**Test ID**: `rest/unit/RSH1a/publish-post-push-publish-0` + +**Spec requirement:** RSH1a — `publish(recipient, data)` performs an HTTP request to `/push/publish`. + +Tests that `push.admin.publish()` sends a POST with correct recipient and data. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.publish( + recipient: { + "transportType": "apns", + "deviceToken": "foo" + }, + data: { + "notification": { + "title": "Test", + "body": "Hello" + } + } +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "POST" +ASSERT request.url.path == "/push/publish" + +body = parse_json(request.body) +ASSERT body["recipient"]["transportType"] == "apns" +ASSERT body["recipient"]["deviceToken"] == "foo" +ASSERT body["notification"]["title"] == "Test" +ASSERT body["notification"]["body"] == "Hello" +``` + +--- + +## RSH1a — publish with clientId recipient + +**Test ID**: `rest/unit/RSH1a/publish-clientid-recipient-1` + +**Spec requirement:** RSH1a — Tests should exist with valid recipient details. + +Tests that publish works with a `clientId` recipient. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.publish( + recipient: { + "clientId": "user-123" + }, + data: { + "data": { + "key": "value" + } + } +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +body = parse_json(captured_requests[0].body) +ASSERT body["recipient"]["clientId"] == "user-123" +ASSERT body["data"]["key"] == "value" +``` + +--- + +## RSH1a — publish with deviceId recipient + +**Test ID**: `rest/unit/RSH1a/publish-deviceid-recipient-2` + +**Spec requirement:** RSH1a — Tests should exist with valid recipient details. + +Tests that publish works with a `deviceId` recipient. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.publish( + recipient: { + "deviceId": "device-abc" + }, + data: { + "notification": { + "title": "Device Push" + } + } +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +body = parse_json(captured_requests[0].body) +ASSERT body["recipient"]["deviceId"] == "device-abc" +ASSERT body["notification"]["title"] == "Device Push" +``` + +--- + +## RSH1a — publish rejects empty recipient + +**Test ID**: `rest/unit/RSH1a/rejects-empty-recipient-3` + +**Spec requirement:** RSH1a — Empty values for `recipient` should be immediately rejected. + +Tests that calling publish with an empty recipient throws an error without making an HTTP request. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.admin.publish( + recipient: {}, + data: { "notification": { "title": "Test" } } +) FAILS WITH error +ASSERT error.code == 40000 + +# No HTTP request should have been made +ASSERT captured_requests.length == 0 +``` + +--- + +## RSH1a — publish rejects empty data + +**Test ID**: `rest/unit/RSH1a/rejects-empty-data-4` + +**Spec requirement:** RSH1a — Empty values for `data` should be immediately rejected. + +Tests that calling publish with empty data throws an error without making an HTTP request. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.admin.publish( + recipient: { "clientId": "user-123" }, + data: {} +) FAILS WITH error +ASSERT error.code == 40000 + +# No HTTP request should have been made +ASSERT captured_requests.length == 0 +``` + +--- + +## RSH1a — publish rejects null recipient + +**Test ID**: `rest/unit/RSH1a/rejects-null-recipient-5` + +**Spec requirement:** RSH1a — Empty values for `recipient` should be immediately rejected. + +Tests that calling publish with a null recipient throws an error. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.admin.publish( + recipient: null, + data: { "notification": { "title": "Test" } } +) FAILS WITH error +ASSERT error.code == 40000 + +ASSERT captured_requests.length == 0 +``` + +--- + +## RSH1a — publish propagates server error + +**Test ID**: `rest/unit/RSH1a/server-error-propagated-6` + +**Spec requirement:** RSH1a — Tests should exist with invalid recipient details. + +Tests that a server error response is propagated to the caller. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(400, { + "error": { + "code": 40000, + "statusCode": 400, + "message": "Invalid recipient" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.admin.publish( + recipient: { "transportType": "invalid" }, + data: { "notification": { "title": "Test" } } +) FAILS WITH error +ASSERT error.code == 40000 +ASSERT error.statusCode == 400 +``` diff --git a/uts/rest/unit/push/push_channel_subscriptions.md b/uts/rest/unit/push/push_channel_subscriptions.md new file mode 100644 index 000000000..3eaf2ef5a --- /dev/null +++ b/uts/rest/unit/push/push_channel_subscriptions.md @@ -0,0 +1,620 @@ +# PushChannelSubscriptions Tests + +Spec points: `RSH1c`, `RSH1c1`, `RSH1c2`, `RSH1c3`, `RSH1c4`, `RSH1c5` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. + +--- + +## RSH1c1 — list returns paginated PushChannelSubscription filtered by channel + +**Test ID**: `rest/unit/RSH1c1/list-filtered-by-channel-0` + +**Spec requirement:** RSH1c1 — `#list(params)` performs a request to `/push/channelSubscriptions` and returns a paginated result with `PushChannelSubscription` objects filtered by the provided params. + +Tests that `list()` sends a GET with `channel` filter and returns a `PaginatedResult`. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { + "channel": "my-channel", + "deviceId": "device-001" + }, + { + "channel": "my-channel", + "clientId": "client-abc" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.channelSubscriptions.list({"channel": "my-channel"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.url.path == "/push/channelSubscriptions" +ASSERT request.url.queryParams["channel"] == "my-channel" + +ASSERT result IS PaginatedResult +ASSERT result.items.length == 2 +ASSERT result.items[0] IS PushChannelSubscription +ASSERT result.items[0].channel == "my-channel" +ASSERT result.items[0].deviceId == "device-001" +ASSERT result.items[1].clientId == "client-abc" +``` + +--- + +## RSH1c1 — list filters by deviceId and clientId + +**Test ID**: `rest/unit/RSH1c1/list-filtered-by-device-client-1` + +**Spec requirement:** RSH1c1 — A test should exist filtering by `deviceId` and/or `clientId`. + +Tests that `list()` forwards `deviceId` and `clientId` as query parameters. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { + "channel": "notifications", + "deviceId": "device-001" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.channelSubscriptions.list({ + "deviceId": "device-001", + "clientId": "client-abc" +}) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.queryParams["deviceId"] == "device-001" +ASSERT captured_requests[0].url.queryParams["clientId"] == "client-abc" +ASSERT result.items.length == 1 +``` + +--- + +## RSH1c1 — list supports limit for pagination + +**Test ID**: `rest/unit/RSH1c1/list-with-limit-param-2` + +**Spec requirement:** RSH1c1 — A test should exist controlling the pagination with the `limit` attribute. + +Tests that `list()` forwards the `limit` parameter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { + "channel": "ch-1", + "deviceId": "device-001" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.channelSubscriptions.list({"limit": "5"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.queryParams["limit"] == "5" +``` + +--- + +## RSH1c2 — listChannels returns paginated channel names + +**Test ID**: `rest/unit/RSH1c2/list-channels-paginated-0` + +**Spec requirement:** RSH1c2 — `#listChannels(params)` performs a request to `/push/channels` and returns a paginated result with `String` objects. + +Tests that `listChannels()` sends a GET to the correct endpoint and returns a paginated list of channel name strings. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, ["channel-1", "channel-2", "channel-3"]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.channelSubscriptions.listChannels({}) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.url.path == "/push/channels" + +ASSERT result IS PaginatedResult +ASSERT result.items.length == 3 +ASSERT result.items[0] == "channel-1" +ASSERT result.items[1] == "channel-2" +ASSERT result.items[2] == "channel-3" +``` + +--- + +## RSH1c2 — listChannels supports limit and pagination + +**Test ID**: `rest/unit/RSH1c2/list-channels-with-limit-1` + +**Spec requirement:** RSH1c2 — A test should exist using the `limit` attribute and pagination. + +Tests that `listChannels()` forwards the `limit` parameter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, ["channel-1"]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.channelSubscriptions.listChannels({"limit": "1"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.queryParams["limit"] == "1" +ASSERT result.items.length == 1 +``` + +--- + +## RSH1c3 — save issues POST with PushChannelSubscription + +**Test ID**: `rest/unit/RSH1c3/save-post-subscription-0` + +**Spec requirement:** RSH1c3 — `#save(pushChannelSubscription)` issues a `POST` request to `/push/channelSubscriptions` using the `PushChannelSubscription` object argument. + +Tests that `save()` sends a POST with the subscription in the body and returns the saved `PushChannelSubscription`. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { + "channel": "my-channel", + "deviceId": "device-001" + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +subscription = PushChannelSubscription( + channel: "my-channel", + deviceId: "device-001" +) + +result = AWAIT client.push.admin.channelSubscriptions.save(subscription) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "POST" +ASSERT request.url.path == "/push/channelSubscriptions" + +body = parse_json(request.body) +ASSERT body["channel"] == "my-channel" +ASSERT body["deviceId"] == "device-001" + +ASSERT result IS PushChannelSubscription +ASSERT result.channel == "my-channel" +ASSERT result.deviceId == "device-001" +``` + +--- + +## RSH1c3 — save updates existing subscription + +**Test ID**: `rest/unit/RSH1c3/save-updates-existing-1` + +**Spec requirement:** RSH1c3 — A test should exist for a successful subsequent save with an update. + +Tests that saving an existing subscription performs an update. + +### Setup +```pseudo +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count++ + IF request_count == 1: + req.respond_with(200, { + "channel": "my-channel", + "clientId": "client-abc" + }) + ELSE: + req.respond_with(200, { + "channel": "my-channel", + "clientId": "client-abc" + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +subscription = PushChannelSubscription( + channel: "my-channel", + clientId: "client-abc" +) + +result1 = AWAIT client.push.admin.channelSubscriptions.save(subscription) +result2 = AWAIT client.push.admin.channelSubscriptions.save(subscription) +``` + +### Assertions +```pseudo +ASSERT request_count == 2 +ASSERT result1.channel == "my-channel" +ASSERT result2.channel == "my-channel" +``` + +--- + +## RSH1c3 — save propagates server error + +**Test ID**: `rest/unit/RSH1c3/save-error-propagated-2` + +**Spec requirement:** RSH1c3 — A test should exist for a failed save operation. + +Tests that a server error during save is propagated to the caller. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(400, { + "error": { + "code": 40000, + "statusCode": 400, + "message": "Invalid subscription" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +subscription = PushChannelSubscription( + channel: "my-channel", + deviceId: "device-001" +) + +AWAIT client.push.admin.channelSubscriptions.save(subscription) FAILS WITH error +ASSERT error.code == 40000 +ASSERT error.statusCode == 400 +``` + +--- + +## RSH1c4 — remove issues DELETE with clientId subscription attributes + +**Test ID**: `rest/unit/RSH1c4/remove-delete-clientid-0` + +**Spec requirement:** RSH1c4 — `#remove(push_channel_subscription)` issues a `DELETE` request to `/push/channelSubscriptions` and deletes the channel subscription using the attributes as params to the `DELETE` request. + +Tests that `remove()` sends a DELETE with the subscription's attributes as query parameters for a `clientId`-based subscription. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +subscription = PushChannelSubscription( + channel: "my-channel", + clientId: "client-abc" +) + +AWAIT client.push.admin.channelSubscriptions.remove(subscription) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "DELETE" +ASSERT request.url.path == "/push/channelSubscriptions" +ASSERT request.url.queryParams["channel"] == "my-channel" +ASSERT request.url.queryParams["clientId"] == "client-abc" +``` + +--- + +## RSH1c4 — remove issues DELETE with deviceId subscription attributes + +**Test ID**: `rest/unit/RSH1c4/remove-delete-deviceid-1` + +**Spec requirement:** RSH1c4 — A test should exist that deletes a `deviceId` channel subscription. + +Tests that `remove()` sends a DELETE with the subscription's attributes as query parameters for a `deviceId`-based subscription. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +subscription = PushChannelSubscription( + channel: "my-channel", + deviceId: "device-001" +) + +AWAIT client.push.admin.channelSubscriptions.remove(subscription) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].method == "DELETE" +ASSERT captured_requests[0].url.path == "/push/channelSubscriptions" +ASSERT captured_requests[0].url.queryParams["channel"] == "my-channel" +ASSERT captured_requests[0].url.queryParams["deviceId"] == "device-001" +``` + +--- + +## RSH1c4 — remove succeeds for nonexistent subscription + +**Test ID**: `rest/unit/RSH1c4/remove-nonexistent-succeeds-2` + +**Spec requirement:** RSH1c4 — A test should exist that deletes a subscription that does not exist but still succeeds. + +Tests that removing a nonexistent subscription does not throw an error. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +subscription = PushChannelSubscription( + channel: "nonexistent-channel", + clientId: "nonexistent-client" +) + +# Should not throw — server returns success even for nonexistent subscriptions +AWAIT client.push.admin.channelSubscriptions.remove(subscription) +``` + +--- + +## RSH1c5 — removeWhere issues DELETE with clientId param + +**Test ID**: `rest/unit/RSH1c5/remove-where-clientid-0` + +**Spec requirement:** RSH1c5 — `#removeWhere(params)` issues a `DELETE` request to `/push/channelSubscriptions` and deletes the matching channel subscriptions provided in `params`. + +Tests that `removeWhere()` sends a DELETE with `clientId` as a query parameter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.channelSubscriptions.removeWhere({"clientId": "client-abc"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "DELETE" +ASSERT request.url.path == "/push/channelSubscriptions" +ASSERT request.url.queryParams["clientId"] == "client-abc" +``` + +--- + +## RSH1c5 — removeWhere issues DELETE with deviceId param + +**Test ID**: `rest/unit/RSH1c5/remove-where-deviceid-1` + +**Spec requirement:** RSH1c5 — A test should exist that deletes channel subscriptions by `deviceId`. + +Tests that `removeWhere()` sends a DELETE with `deviceId` as a query parameter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.channelSubscriptions.removeWhere({"deviceId": "device-001"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].method == "DELETE" +ASSERT captured_requests[0].url.path == "/push/channelSubscriptions" +ASSERT captured_requests[0].url.queryParams["deviceId"] == "device-001" +``` + +--- + +## RSH1c5 — removeWhere succeeds with no matching subscriptions + +**Test ID**: `rest/unit/RSH1c5/remove-where-no-match-succeeds-2` + +**Spec requirement:** RSH1c5 — A test should exist that issues a delete for subscriptions with no matching params and checks the operation still succeeds. + +Tests that `removeWhere()` succeeds even when no subscriptions match the params. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +# Should not throw — server returns success even with no matching subscriptions +AWAIT client.push.admin.channelSubscriptions.removeWhere({"clientId": "nonexistent-client"}) +``` diff --git a/uts/rest/unit/push/push_channels.md b/uts/rest/unit/push/push_channels.md new file mode 100644 index 000000000..6d8c8299d --- /dev/null +++ b/uts/rest/unit/push/push_channels.md @@ -0,0 +1,540 @@ +# PushChannel Tests + +Spec points: `RSH7`, `RSH7a`, `RSH7a1`, `RSH7a2`, `RSH7a3`, `RSH7b`, `RSH7b1`, `RSH7b2`, `RSH7c`, `RSH7c1`, `RSH7c2`, `RSH7c3`, `RSH7d`, `RSH7d1`, `RSH7d2`, `RSH7e` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. + +## Notes + +These tests cover the `PushChannel` interface (`RSH7`), which is the `push` field on `RestChannel` and `RealtimeChannel`. This is distinct from the `push.admin.channelSubscriptions` API (`RSH1c`) — the `PushChannel` methods operate from the perspective of the local device (the push target), not the admin API. + +The `PushChannel` methods require access to a `LocalDevice` (`RSH8`) which represents the current device's push registration state. In unit tests, the `LocalDevice` is configured with test values to simulate a registered device. + +Push device authentication (`RSH6`) means adding either an `X-Ably-DeviceToken` header (if the device has a `deviceIdentityToken`, per `RSH6a`) or an `X-Ably-DeviceSecret` header (if the device has a `deviceSecret`, per `RSH6b`). + +--- + +## RSH7a1, RSH7a2, RSH7a3 — subscribeDevice sends POST with deviceId, channel name, and device auth + +**Test ID**: `rest/unit/RSH7a2/subscribe-device-post-0` + +| Spec | Requirement | +|------|-------------| +| RSH7a1 | Fails if the LocalDevice doesn't have a deviceIdentityToken | +| RSH7a2 | Performs a POST request to /push/channelSubscriptions with the device's id and the channel name | +| RSH7a3 | The request must include push device authentication | + +Tests that `subscribeDevice()` sends a POST to `/push/channelSubscriptions` with the device's `id` and the channel name in the request body, and includes the `X-Ably-DeviceToken` header for push device authentication. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { + "channel": "my-channel", + "deviceId": "test-device-001" + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# Configure the local device as a registered push target +client.device = LocalDevice( + id: "test-device-001", + deviceIdentityToken: "test-device-identity-token", + clientId: "test-client" +) + +channel = client.channels.get("my-channel") +``` + +### Test Steps +```pseudo +AWAIT channel.push.subscribeDevice() +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "POST" +ASSERT request.url.path == "/push/channelSubscriptions" + +body = parse_json(request.body) +ASSERT body["channel"] == "my-channel" +ASSERT body["deviceId"] == "test-device-001" + +# RSH7a3 + RSH6a — push device authentication via deviceIdentityToken +ASSERT request.headers["X-Ably-DeviceToken"] == "test-device-identity-token" +``` + +--- + +## RSH7a1 — subscribeDevice fails if no deviceIdentityToken + +**Test ID**: `rest/unit/RSH7a1/subscribe-device-no-token-fails-0` + +**Spec requirement:** RSH7a1 — Fails if the LocalDevice doesn't have a `deviceIdentityToken`, ie. it isn't registered yet. + +Tests that `subscribeDevice()` fails when the local device has no `deviceIdentityToken`. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# Configure the local device WITHOUT a deviceIdentityToken (not registered) +client.device = LocalDevice( + id: "test-device-001", + deviceIdentityToken: null, + clientId: "test-client" +) + +channel = client.channels.get("my-channel") +``` + +### Test Steps and Assertions +```pseudo +AWAIT channel.push.subscribeDevice() FAILS WITH error +ASSERT error.code IS NOT null +ASSERT error.message CONTAINS "deviceIdentityToken" +``` + +--- + +## RSH7b1, RSH7b2 — subscribeClient sends POST with clientId and channel name + +**Test ID**: `rest/unit/RSH7b2/subscribe-client-post-0` + +| Spec | Requirement | +|------|-------------| +| RSH7b1 | Fails if the LocalDevice doesn't have a clientId | +| RSH7b2 | Performs a POST request to /push/channelSubscriptions with the device's clientId and the channel name | + +Tests that `subscribeClient()` sends a POST to `/push/channelSubscriptions` with the device's `clientId` and the channel name in the request body. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { + "channel": "my-channel", + "clientId": "test-client" + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# Configure the local device with a clientId +client.device = LocalDevice( + id: "test-device-001", + deviceIdentityToken: "test-device-identity-token", + clientId: "test-client" +) + +channel = client.channels.get("my-channel") +``` + +### Test Steps +```pseudo +AWAIT channel.push.subscribeClient() +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "POST" +ASSERT request.url.path == "/push/channelSubscriptions" + +body = parse_json(request.body) +ASSERT body["channel"] == "my-channel" +ASSERT body["clientId"] == "test-client" +``` + +--- + +## RSH7b1 — subscribeClient fails if no clientId + +**Test ID**: `rest/unit/RSH7b1/subscribe-client-no-clientid-fails-0` + +**Spec requirement:** RSH7b1 — Fails if the LocalDevice doesn't have a `clientId`. + +Tests that `subscribeClient()` fails when the local device has no `clientId`. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# Configure the local device WITHOUT a clientId +client.device = LocalDevice( + id: "test-device-001", + deviceIdentityToken: "test-device-identity-token", + clientId: null +) + +channel = client.channels.get("my-channel") +``` + +### Test Steps and Assertions +```pseudo +AWAIT channel.push.subscribeClient() FAILS WITH error +ASSERT error.code IS NOT null +ASSERT error.message CONTAINS "clientId" +``` + +--- + +## RSH7c1, RSH7c2, RSH7c3 — unsubscribeDevice sends DELETE with deviceId, channel name, and device auth + +**Test ID**: `rest/unit/RSH7c2/unsubscribe-device-delete-0` + +| Spec | Requirement | +|------|-------------| +| RSH7c1 | Fails if the LocalDevice doesn't have a deviceIdentityToken | +| RSH7c2 | Performs a DELETE request to /push/channelSubscriptions with the device's id and the channel name | +| RSH7c3 | The request must include push device authentication | + +Tests that `unsubscribeDevice()` sends a DELETE to `/push/channelSubscriptions` with the device's `id` and the channel name as query parameters, and includes the `X-Ably-DeviceToken` header for push device authentication. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# Configure the local device as a registered push target +client.device = LocalDevice( + id: "test-device-001", + deviceIdentityToken: "test-device-identity-token", + clientId: "test-client" +) + +channel = client.channels.get("my-channel") +``` + +### Test Steps +```pseudo +AWAIT channel.push.unsubscribeDevice() +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "DELETE" +ASSERT request.url.path == "/push/channelSubscriptions" +ASSERT request.url.queryParams["channel"] == "my-channel" +ASSERT request.url.queryParams["deviceId"] == "test-device-001" + +# RSH7c3 + RSH6a — push device authentication via deviceIdentityToken +ASSERT request.headers["X-Ably-DeviceToken"] == "test-device-identity-token" +``` + +--- + +## RSH7c1 — unsubscribeDevice fails if no deviceIdentityToken + +**Test ID**: `rest/unit/RSH7c1/unsubscribe-device-no-token-fails-0` + +**Spec requirement:** RSH7c1 — Fails if the LocalDevice doesn't have a `deviceIdentityToken`, ie. it isn't registered yet. + +Tests that `unsubscribeDevice()` fails when the local device has no `deviceIdentityToken`. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# Configure the local device WITHOUT a deviceIdentityToken (not registered) +client.device = LocalDevice( + id: "test-device-001", + deviceIdentityToken: null, + clientId: "test-client" +) + +channel = client.channels.get("my-channel") +``` + +### Test Steps and Assertions +```pseudo +AWAIT channel.push.unsubscribeDevice() FAILS WITH error +ASSERT error.code IS NOT null +ASSERT error.message CONTAINS "deviceIdentityToken" +``` + +--- + +## RSH7d1, RSH7d2 — unsubscribeClient sends DELETE with clientId and channel name + +**Test ID**: `rest/unit/RSH7d2/unsubscribe-client-delete-0` + +| Spec | Requirement | +|------|-------------| +| RSH7d1 | Fails if the LocalDevice doesn't have a clientId | +| RSH7d2 | Performs a DELETE request to /push/channelSubscriptions with the device's clientId and the channel name | + +Tests that `unsubscribeClient()` sends a DELETE to `/push/channelSubscriptions` with the device's `clientId` and the channel name as query parameters. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# Configure the local device with a clientId +client.device = LocalDevice( + id: "test-device-001", + deviceIdentityToken: "test-device-identity-token", + clientId: "test-client" +) + +channel = client.channels.get("my-channel") +``` + +### Test Steps +```pseudo +AWAIT channel.push.unsubscribeClient() +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "DELETE" +ASSERT request.url.path == "/push/channelSubscriptions" +ASSERT request.url.queryParams["channel"] == "my-channel" +ASSERT request.url.queryParams["clientId"] == "test-client" +``` + +--- + +## RSH7d1 — unsubscribeClient fails if no clientId + +**Test ID**: `rest/unit/RSH7d1/unsubscribe-client-no-clientid-fails-0` + +**Spec requirement:** RSH7d1 — Fails if the LocalDevice doesn't have a `clientId`. + +Tests that `unsubscribeClient()` fails when the local device has no `clientId`. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# Configure the local device WITHOUT a clientId +client.device = LocalDevice( + id: "test-device-001", + deviceIdentityToken: "test-device-identity-token", + clientId: null +) + +channel = client.channels.get("my-channel") +``` + +### Test Steps and Assertions +```pseudo +AWAIT channel.push.unsubscribeClient() FAILS WITH error +ASSERT error.code IS NOT null +ASSERT error.message CONTAINS "clientId" +``` + +--- + +## RSH7e — listSubscriptions sends GET with channel, deviceId, clientId, and concatFilters + +**Test ID**: `rest/unit/RSH7e/list-subscriptions-with-filters-0` + +**Spec requirement:** RSH7e — `#listSubscriptions(params)` performs a GET request to `/push/channelSubscriptions` and returns a paginated result with `PushChannelSubscription` objects filtered by the provided params, the channel name, the device ID, and the client ID if it exists, as supported by the REST API. A `concatFilters` param needs to be set to `true` as well. + +Tests that `listSubscriptions()` sends a GET to `/push/channelSubscriptions` with the channel name, device ID, client ID (if present), any user-provided params, and `concatFilters=true`. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { + "channel": "my-channel", + "deviceId": "test-device-001" + }, + { + "channel": "my-channel", + "clientId": "test-client" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# Configure the local device with both deviceId and clientId +client.device = LocalDevice( + id: "test-device-001", + deviceIdentityToken: "test-device-identity-token", + clientId: "test-client" +) + +channel = client.channels.get("my-channel") +``` + +### Test Steps +```pseudo +result = AWAIT channel.push.listSubscriptions({"limit": "10"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.url.path == "/push/channelSubscriptions" + +# Channel name, device ID, and client ID are automatically included +ASSERT request.url.queryParams["channel"] == "my-channel" +ASSERT request.url.queryParams["deviceId"] == "test-device-001" +ASSERT request.url.queryParams["clientId"] == "test-client" + +# concatFilters must be set to true +ASSERT request.url.queryParams["concatFilters"] == "true" + +# User-provided params are forwarded +ASSERT request.url.queryParams["limit"] == "10" + +ASSERT result IS PaginatedResult +ASSERT result.items.length == 2 +ASSERT result.items[0] IS PushChannelSubscription +ASSERT result.items[0].channel == "my-channel" +ASSERT result.items[0].deviceId == "test-device-001" +ASSERT result.items[1].clientId == "test-client" +``` + +--- + +## RSH7e — listSubscriptions omits clientId when LocalDevice has no clientId + +**Test ID**: `rest/unit/RSH7e/list-subscriptions-omits-clientid-1` + +**Spec requirement:** RSH7e — The client ID is included if it exists. + +Tests that `listSubscriptions()` does not include `clientId` in the query parameters when the local device has no `clientId`. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { + "channel": "my-channel", + "deviceId": "test-device-001" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# Configure the local device WITHOUT a clientId +client.device = LocalDevice( + id: "test-device-001", + deviceIdentityToken: "test-device-identity-token", + clientId: null +) + +channel = client.channels.get("my-channel") +``` + +### Test Steps +```pseudo +result = AWAIT channel.push.listSubscriptions({}) +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.url.queryParams["channel"] == "my-channel" +ASSERT request.url.queryParams["deviceId"] == "test-device-001" +ASSERT request.url.queryParams["concatFilters"] == "true" +ASSERT "clientId" NOT IN request.url.queryParams + +ASSERT result.items.length == 1 +``` diff --git a/uts/rest/unit/push/push_device_registrations.md b/uts/rest/unit/push/push_device_registrations.md new file mode 100644 index 000000000..d433d2290 --- /dev/null +++ b/uts/rest/unit/push/push_device_registrations.md @@ -0,0 +1,670 @@ +# PushDeviceRegistrations Tests + +Spec points: `RSH1b`, `RSH1b1`, `RSH1b2`, `RSH1b3`, `RSH1b4`, `RSH1b5` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. + +--- + +## RSH1b1 — get returns DeviceDetails for known device + +**Test ID**: `rest/unit/RSH1b1/get-device-details-0` + +**Spec requirement:** RSH1b1 — `#get(deviceId)` performs a request to `/push/deviceRegistrations/:deviceId` and returns a `DeviceDetails` object. + +Tests that `get()` sends a GET request with the correct path and returns a parsed `DeviceDetails`. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { + "id": "device-001", + "clientId": "client-abc", + "formFactor": "phone", + "platform": "ios", + "metadata": { "model": "iPhone 14" }, + "push": { + "recipient": { "transportType": "apns", "deviceToken": "token-123" }, + "state": "Active" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +device = AWAIT client.push.admin.deviceRegistrations.get("device-001") +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.url.path == "/push/deviceRegistrations/" + encode_uri_component("device-001") + +ASSERT device IS DeviceDetails +ASSERT device.id == "device-001" +ASSERT device.clientId == "client-abc" +ASSERT device.formFactor == "phone" +ASSERT device.platform == "ios" +ASSERT device.metadata["model"] == "iPhone 14" +ASSERT device.push.recipient["transportType"] == "apns" +ASSERT device.push.state == "Active" +``` + +--- + +## RSH1b1 — get returns error for unknown device + +**Test ID**: `rest/unit/RSH1b1/get-unknown-device-error-1` + +**Spec requirement:** RSH1b1 — Results in a not found error if the device cannot be found. + +Tests that `get()` propagates a 404 error when the device does not exist. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(404, { + "error": { + "code": 40400, + "statusCode": 404, + "message": "Device not found" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.admin.deviceRegistrations.get("nonexistent-device") FAILS WITH error +ASSERT error.code == 40400 +ASSERT error.statusCode == 404 +``` + +--- + +## RSH1b1 — get URL-encodes deviceId + +**Test ID**: `rest/unit/RSH1b1/get-url-encodes-deviceid-2` + +**Spec requirement:** RSH1b1 — `#get(deviceId)` performs a request to `/push/deviceRegistrations/:deviceId`. + +Tests that the deviceId is properly URL-encoded in the request path. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { + "id": "device/with special:chars", + "platform": "ios", + "formFactor": "phone", + "push": { + "recipient": {}, + "state": "Active" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.deviceRegistrations.get("device/with special:chars") +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.path == "/push/deviceRegistrations/" + encode_uri_component("device/with special:chars") +``` + +--- + +## RSH1b2 — list returns paginated DeviceDetails filtered by deviceId + +**Test ID**: `rest/unit/RSH1b2/list-filtered-by-deviceid-0` + +**Spec requirement:** RSH1b2 — `#list(params)` performs a request to `/push/deviceRegistrations` and returns a paginated result with `DeviceDetails` objects filtered by the provided params. + +Tests that `list()` sends a GET with `deviceId` filter and returns a `PaginatedResult`. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { + "id": "device-001", + "clientId": "client-abc", + "platform": "ios", + "formFactor": "phone", + "push": { "recipient": {}, "state": "Active" } + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.deviceRegistrations.list({"deviceId": "device-001"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.url.path == "/push/deviceRegistrations" +ASSERT request.url.queryParams["deviceId"] == "device-001" + +ASSERT result IS PaginatedResult +ASSERT result.items.length == 1 +ASSERT result.items[0] IS DeviceDetails +ASSERT result.items[0].id == "device-001" +``` + +--- + +## RSH1b2 — list returns paginated DeviceDetails filtered by clientId + +**Test ID**: `rest/unit/RSH1b2/list-filtered-by-clientid-1` + +**Spec requirement:** RSH1b2 — A test should exist filtering by `clientId`. + +Tests that `list()` sends a GET with `clientId` filter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { + "id": "device-001", + "clientId": "client-abc", + "platform": "ios", + "formFactor": "phone", + "push": { "recipient": {}, "state": "Active" } + }, + { + "id": "device-002", + "clientId": "client-abc", + "platform": "android", + "formFactor": "tablet", + "push": { "recipient": {}, "state": "Active" } + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.deviceRegistrations.list({"clientId": "client-abc"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.queryParams["clientId"] == "client-abc" +ASSERT result.items.length == 2 +ASSERT result.items[0].clientId == "client-abc" +ASSERT result.items[1].clientId == "client-abc" +``` + +--- + +## RSH1b2 — list supports limit for pagination + +**Test ID**: `rest/unit/RSH1b2/list-with-limit-param-2` + +**Spec requirement:** RSH1b2 — A test should exist controlling the pagination with the `limit` attribute. + +Tests that `list()` forwards the `limit` parameter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { + "id": "device-001", + "platform": "ios", + "formFactor": "phone", + "push": { "recipient": {}, "state": "Active" } + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.deviceRegistrations.list({"limit": "2"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.queryParams["limit"] == "2" +``` + +--- + +## RSH1b3 — save issues PUT with DeviceDetails + +**Test ID**: `rest/unit/RSH1b3/save-put-device-details-0` + +**Spec requirement:** RSH1b3 — `#save(device)` issues a `PUT` request to `/push/deviceRegistrations/:deviceId` using the `DeviceDetails` object argument. + +Tests that `save()` sends a PUT with the device details in the body and returns the saved `DeviceDetails`. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { + "id": "device-001", + "clientId": "client-abc", + "platform": "ios", + "formFactor": "phone", + "metadata": {}, + "push": { + "recipient": { "transportType": "apns", "deviceToken": "token-123" }, + "state": "Active" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +device = DeviceDetails( + id: "device-001", + clientId: "client-abc", + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": "token-123" } + ) +) + +result = AWAIT client.push.admin.deviceRegistrations.save(device) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "PUT" +ASSERT request.url.path == "/push/deviceRegistrations/" + encode_uri_component("device-001") + +body = parse_json(request.body) +ASSERT body["id"] == "device-001" +ASSERT body["clientId"] == "client-abc" +ASSERT body["platform"] == "ios" +ASSERT body["formFactor"] == "phone" +ASSERT body["push"]["recipient"]["transportType"] == "apns" + +ASSERT result IS DeviceDetails +ASSERT result.id == "device-001" +ASSERT result.push.state == "Active" +``` + +--- + +## RSH1b3 — save updates existing device + +**Test ID**: `rest/unit/RSH1b3/save-updates-existing-1` + +**Spec requirement:** RSH1b3 — A test should exist for a successful subsequent save with an update. + +Tests that `save()` can update an already-registered device. + +### Setup +```pseudo +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count++ + IF request_count == 1: + # First save — initial registration + req.respond_with(200, { + "id": "device-001", + "platform": "ios", + "formFactor": "phone", + "push": { + "recipient": { "transportType": "apns", "deviceToken": "token-old" }, + "state": "Active" + } + }) + ELSE: + # Second save — update + req.respond_with(200, { + "id": "device-001", + "platform": "ios", + "formFactor": "phone", + "push": { + "recipient": { "transportType": "apns", "deviceToken": "token-new" }, + "state": "Active" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +device = DeviceDetails( + id: "device-001", + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": "token-old" } + ) +) + +result1 = AWAIT client.push.admin.deviceRegistrations.save(device) + +updated_device = DeviceDetails( + id: "device-001", + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": "token-new" } + ) +) + +result2 = AWAIT client.push.admin.deviceRegistrations.save(updated_device) +``` + +### Assertions +```pseudo +ASSERT result1.push.recipient["deviceToken"] == "token-old" +ASSERT result2.push.recipient["deviceToken"] == "token-new" +ASSERT request_count == 2 +``` + +--- + +## RSH1b3 — save propagates server error + +**Test ID**: `rest/unit/RSH1b3/save-error-propagated-2` + +**Spec requirement:** RSH1b3 — A test should exist for a failed save operation. + +Tests that a server error during save is propagated to the caller. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(400, { + "error": { + "code": 40000, + "statusCode": 400, + "message": "Invalid device details" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +device = DeviceDetails( + id: "device-001", + platform: "ios", + formFactor: "phone", + push: DevicePushDetails(recipient: {}) +) + +AWAIT client.push.admin.deviceRegistrations.save(device) FAILS WITH error +ASSERT error.code == 40000 +ASSERT error.statusCode == 400 +``` + +--- + +## RSH1b4 — remove issues DELETE for device + +**Test ID**: `rest/unit/RSH1b4/remove-delete-device-0` + +**Spec requirement:** RSH1b4 — `#remove(deviceId)` issues a `DELETE` request to `/push/deviceRegistrations/:deviceId`. + +Tests that `remove()` sends a DELETE request with the correct path. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.deviceRegistrations.remove("device-001") +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "DELETE" +ASSERT request.url.path == "/push/deviceRegistrations/" + encode_uri_component("device-001") +``` + +--- + +## RSH1b4 — remove succeeds for nonexistent device + +**Test ID**: `rest/unit/RSH1b4/remove-nonexistent-succeeds-1` + +**Spec requirement:** RSH1b4 — A test should exist that deletes a device that does not exist but still succeeds. + +Tests that removing a nonexistent device does not throw an error. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +# Should not throw — server returns success even for nonexistent devices +AWAIT client.push.admin.deviceRegistrations.remove("nonexistent-device") +``` + +--- + +## RSH1b5 — removeWhere issues DELETE with clientId param + +**Test ID**: `rest/unit/RSH1b5/remove-where-clientid-0` + +**Spec requirement:** RSH1b5 — `#removeWhere(params)` issues a `DELETE` request to `/push/deviceRegistrations` and deletes the registered devices matching the provided `params`. + +Tests that `removeWhere()` sends a DELETE with `clientId` as a query parameter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.deviceRegistrations.removeWhere({"clientId": "client-abc"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "DELETE" +ASSERT request.url.path == "/push/deviceRegistrations" +ASSERT request.url.queryParams["clientId"] == "client-abc" +``` + +--- + +## RSH1b5 — removeWhere issues DELETE with deviceId param + +**Test ID**: `rest/unit/RSH1b5/remove-where-deviceid-1` + +**Spec requirement:** RSH1b5 — A test should exist that deletes devices by `deviceId`. + +Tests that `removeWhere()` sends a DELETE with `deviceId` as a query parameter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.deviceRegistrations.removeWhere({"deviceId": "device-001"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].method == "DELETE" +ASSERT captured_requests[0].url.path == "/push/deviceRegistrations" +ASSERT captured_requests[0].url.queryParams["deviceId"] == "device-001" +``` + +--- + +## RSH1b5 — removeWhere succeeds with no matching devices + +**Test ID**: `rest/unit/RSH1b5/remove-where-no-match-succeeds-2` + +**Spec requirement:** RSH1b5 — A test should exist that issues a delete for devices with no matching params and checks the operation still succeeds. + +Tests that `removeWhere()` succeeds even when no devices match the params. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +# Should not throw — server returns success even with no matching devices +AWAIT client.push.admin.deviceRegistrations.removeWhere({"clientId": "nonexistent-client"}) +``` diff --git a/uts/rest/unit/request.md b/uts/rest/unit/request.md new file mode 100644 index 000000000..4971775f7 --- /dev/null +++ b/uts/rest/unit/request.md @@ -0,0 +1,1046 @@ +# REST Client request() Tests + +Spec points: `RSC19`, `RSC19b`, `RSC19c`, `RSC19d`, `RSC19e`, `RSC19f`, `RSC19f1`, `HP1`, `HP3`, `HP4`, `HP5`, `HP6`, `HP7`, `HP8` + +## Test Type +Unit test with mocked HTTP client + +## Mock Configuration + +These tests use the mock HTTP infrastructure defined in `rest_client.md`. The mock supports: +- Handler-based configuration with `onConnectionAttempt` and `onRequest` +- Capturing requests via `captured_requests` arrays +- Configurable responses with status codes, bodies, and headers +- Await-based API for coordinating test responses + +See `rest_client.md` for detailed mock interface documentation. + +## Overview + +The `request()` method provides a generic way to make HTTP requests to Ably endpoints with all built-in library functionality (authentication, paging, fallback hosts, protocol encoding). + +--- + +## RSC19f - Method signature supports required HTTP methods + +**Test ID**: `rest/unit/RSC19f/supports-http-methods-0` + +**Spec requirement:** The `request()` method must support GET, POST, PUT, PATCH, and DELETE HTTP methods. + +Tests that the request() method supports GET, POST, PUT, PATCH, and DELETE methods. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) +``` + +### Test Cases + +| ID | Method | Path | Expected | +|----|--------|------|----------| +| 1 | GET | /test | Success | +| 2 | POST | /test | Success | +| 3 | PUT | /test | Success | +| 4 | PATCH | /test | Success | +| 5 | DELETE | /test | Success | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + captured_requests = [] + + client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + + response = AWAIT client.request(test_case.method, test_case.path, version: 3) + + ASSERT captured_requests.length == 1 + request = captured_requests[0] + ASSERT request.method == test_case.method + ASSERT request.url.path == test_case.path +``` + +--- + +## RSC19f - Query parameters passed correctly + +**Test ID**: `rest/unit/RSC19f/query-params-passed-1` + +**Spec requirement:** The `params` argument must add query parameters to the request URL. + +Tests that the params argument adds URL query parameters. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/channels/test/messages", + version: 3, + params: { "limit": "10", "direction": "backwards" } +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.url.query_params["limit"] == "10" +ASSERT request.url.query_params["direction"] == "backwards" +``` + +--- + +## RSC19f - Custom headers passed correctly + +**Test ID**: `rest/unit/RSC19f/custom-headers-passed-2` + +**Spec requirement:** The `headers` argument must add custom HTTP headers to the request. + +Tests that the headers argument adds custom HTTP headers. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/test", + version: 3, + headers: { "X-Custom-Header": "custom-value", "X-Another": "another-value" } +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.headers["X-Custom-Header"] == "custom-value" +ASSERT request.headers["X-Another"] == "another-value" +``` + +--- + +## RSC19f - Request body sent correctly + +**Test ID**: `rest/unit/RSC19f/request-body-sent-3` + +**Spec requirement:** The `body` argument must be included in the request and encoded according to the configured protocol. + +Tests that the body argument is included in the request. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "id": "123" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false # JSON for easier inspection +)) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("POST", "/channels/test/messages", + version: 3, + body: { "name": "event", "data": "payload" } +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +body = json_decode(request.body) +ASSERT body["name"] == "event" +ASSERT body["data"] == "payload" +``` + +--- + +## RSC19f1 - X-Ably-Version header uses explicit version parameter + +**Test ID**: `rest/unit/RSC19f1/version-param-sets-header-0` + +Tests that the version parameter sets the X-Ably-Version header. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, []) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Cases + +| ID | Version | Expected Header | +|----|---------|-----------------| +| 1 | 2 | "2" | +| 2 | 3 | "3" | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + mock_http.reset() + mock_http.queue_response(200, []) + + response = AWAIT client.request("GET", "/test", version: test_case.version) + + request = mock_http.captured_requests[0] + ASSERT request.headers["X-Ably-Version"] == test_case.expected_header +``` + +--- + +## RSC19b - Uses configured authentication + +**Test ID**: `rest/unit/RSC19b/uses-configured-auth-0` + +**Spec requirement:** The `request()` method must use the REST client's configured authentication mechanism (Basic auth for API keys, Bearer token for token auth). + +Tests that request() uses the REST client's configured authentication mechanism. + +### Test Case 1: Basic authentication (API key) + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/test", version: 3) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT "Authorization" IN request.headers +ASSERT request.headers["Authorization"] STARTS_WITH "Basic " + +# Verify the base64 encoded credentials +credentials = base64_decode(request.headers["Authorization"].substring(6)) +ASSERT credentials == "appId.keyId:keySecret" +``` + +### Test Case 2: Token authentication + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(token: "my-token-string")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/test", version: 3) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT "Authorization" IN request.headers +ASSERT request.headers["Authorization"] STARTS_WITH "Bearer " +``` + +--- + +## RSC19c - Protocol headers set correctly (JSON) + +**Test ID**: `rest/unit/RSC19c/protocol-headers-json-0` + +Tests that Accept and Content-Type headers reflect the configured protocol. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, []) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false # JSON +)) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("POST", "/test", + version: 3, + body: { "data": "test" } +) +``` + +### Assertions +```pseudo +request = mock_http.captured_requests[0] +ASSERT request.headers["Accept"] == "application/json" +ASSERT request.headers["Content-Type"] == "application/json" +``` + +--- + +## RSC19c - Protocol headers set correctly (MsgPack) + +**Test ID**: `rest/unit/RSC19c/protocol-headers-msgpack-1` + +Tests that Accept and Content-Type headers reflect MsgPack protocol when configured. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, msgpack_encode([])) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: true # MsgPack +)) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("POST", "/test", + version: 3, + body: { "data": "test" } +) +``` + +### Assertions +```pseudo +request = mock_http.captured_requests[0] +ASSERT request.headers["Accept"] == "application/x-msgpack" +ASSERT request.headers["Content-Type"] == "application/x-msgpack" +``` + +--- + +## RSC19c - Request body encoded according to protocol + +**Test ID**: `rest/unit/RSC19c/body-encoded-per-protocol-2` + +Tests that the request body is encoded using the configured protocol. + +### Test Case 1: JSON encoding + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, []) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false +)) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("POST", "/test", + version: 3, + body: { "name": "event", "data": { "nested": "value" } } +) +``` + +### Assertions +```pseudo +request = mock_http.captured_requests[0] +# Body should be valid JSON +body = json_decode(request.body) +ASSERT body["name"] == "event" +ASSERT body["data"]["nested"] == "value" +``` + +### Test Case 2: MsgPack encoding + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, msgpack_encode([])) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: true +)) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("POST", "/test", + version: 3, + body: { "name": "event", "data": "value" } +) +``` + +### Assertions +```pseudo +request = mock_http.captured_requests[0] +# Body should be valid MsgPack +body = msgpack_decode(request.body) +ASSERT body["name"] == "event" +ASSERT body["data"] == "value" +``` + +--- + +## RSC19c - Response body decoded according to Content-Type + +**Test ID**: `rest/unit/RSC19c/response-decoded-by-content-type-3` + +Tests that the response body is automatically decoded based on Content-Type header. + +### Test Case 1: JSON response + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, + body: json_encode([{ "id": "1", "name": "item1" }, { "id": "2", "name": "item2" }]), + headers: { "Content-Type": "application/json" } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/test", version: 3) +items = response.items() +``` + +### Assertions +```pseudo +ASSERT items.length == 2 +ASSERT items[0]["id"] == "1" +ASSERT items[1]["name"] == "item2" +``` + +### Test Case 2: MsgPack response + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, + body: msgpack_encode([{ "id": "1" }]), + headers: { "Content-Type": "application/x-msgpack" } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/test", version: 3) +items = response.items() +``` + +### Assertions +```pseudo +ASSERT items.length == 1 +ASSERT items[0]["id"] == "1" +``` + +--- + +## RSC19d, HP4 - HttpPaginatedResponse provides status code + +**Test ID**: `rest/unit/RSC19d/response-status-code-0` + +| Spec | Requirement | +|------|-------------| +| RSC19d | Request returns HttpPaginatedResponse | +| HP4 | Response provides HTTP status code | + +Tests that the response object provides access to the HTTP status code. + +### Setup +```pseudo +mock_http = MockHttpClient() +``` + +### Test Cases + +| ID | Status Code | +|----|-------------| +| 1 | 200 | +| 2 | 201 | +| 3 | 400 | +| 4 | 404 | +| 5 | 500 | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + mock_http.reset() + + IF test_case.status_code >= 400: + mock_http.queue_response(test_case.status_code, + { "error": { "code": test_case.status_code * 100, "message": "Error" } }) + ELSE: + mock_http.queue_response(test_case.status_code, []) + + client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + response = AWAIT client.request("GET", "/test", version: 3) + + ASSERT response.statusCode == test_case.status_code +``` + +--- + +## RSC19d, HP5 - HttpPaginatedResponse provides success indicator + +**Test ID**: `rest/unit/RSC19d/response-success-indicator-1` + +Tests that the success property correctly reflects 2xx status codes. + +### Setup +```pseudo +mock_http = MockHttpClient() +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Cases + +| ID | Status Code | Expected Success | +|----|-------------|------------------| +| 1 | 200 | true | +| 2 | 201 | true | +| 3 | 204 | true | +| 4 | 299 | true | +| 5 | 300 | false | +| 6 | 400 | false | +| 7 | 500 | false | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + mock_http.reset() + + IF test_case.status_code >= 400: + mock_http.queue_response(test_case.status_code, + { "error": { "code": test_case.status_code * 100, "message": "Error" } }) + ELSE: + mock_http.queue_response(test_case.status_code, []) + + response = AWAIT client.request("GET", "/test", version: 3) + + ASSERT response.success == test_case.expected_success +``` + +--- + +## RSC19d, HP6 - HttpPaginatedResponse provides error code from header + +**Test ID**: `rest/unit/RSC19d/response-error-code-header-2` + +Tests that the errorCode property extracts the value from X-Ably-Errorcode header. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(401, + body: { "error": { "code": 40101, "message": "Unauthorized" } }, + headers: { "X-Ably-Errorcode": "40101" } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/test", version: 3) +``` + +### Assertions +```pseudo +ASSERT response.errorCode == 40101 +``` + +--- + +## RSC19d, HP7 - HttpPaginatedResponse provides error message from header + +**Test ID**: `rest/unit/RSC19d/response-error-message-header-3` + +Tests that the errorMessage property extracts the value from X-Ably-Errormessage header. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(401, + body: { "error": { "code": 40101, "message": "Unauthorized" } }, + headers: { + "X-Ably-Errorcode": "40101", + "X-Ably-Errormessage": "Token expired" + } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/test", version: 3) +``` + +### Assertions +```pseudo +ASSERT response.errorMessage == "Token expired" +``` + +--- + +## RSC19d, HP8 - HttpPaginatedResponse provides all response headers + +**Test ID**: `rest/unit/RSC19d/response-headers-accessible-4` + +Tests that all response headers are accessible. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, + body: [], + headers: { + "Content-Type": "application/json", + "X-Request-Id": "req-123", + "X-Custom-Header": "custom-value" + } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/test", version: 3) +``` + +### Assertions +```pseudo +headers = response.headers +ASSERT headers["Content-Type"] == "application/json" +ASSERT headers["X-Request-Id"] == "req-123" +ASSERT headers["X-Custom-Header"] == "custom-value" +``` + +--- + +## RSC19d, HP3 - HttpPaginatedResponse provides response items + +**Test ID**: `rest/unit/RSC19d/response-items-decoded-5` + +Tests that the items() method returns the decoded response body. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, [ + { "id": "msg1", "name": "event1", "data": "data1" }, + { "id": "msg2", "name": "event2", "data": "data2" } +]) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/channels/test/messages", version: 3) +items = response.items() +``` + +### Assertions +```pseudo +ASSERT items.length == 2 +ASSERT items[0]["id"] == "msg1" +ASSERT items[1]["id"] == "msg2" +``` + +--- + +## RSC19d, HP1 - HttpPaginatedResponse pagination support + +**Test ID**: `rest/unit/RSC19d/pagination-with-link-headers-6` + +| Spec | Requirement | +|------|-------------| +| RSC19d | Request returns HttpPaginatedResponse | +| HP1 | Response supports pagination with Link headers | + +Tests that multi-page responses can be navigated using next(). + +### Setup +```pseudo +mock_http = MockHttpClient() + +# First page +mock_http.queue_response(200, + body: [{ "id": "1" }, { "id": "2" }], + headers: { + "Link": '; rel="next"' + } +) + +# Second page +mock_http.queue_response(200, + body: [{ "id": "3" }], + headers: {} # No "next" link - last page +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/channels/test/messages", version: 3) +``` + +### Assertions +```pseudo +# First page +items1 = response.items() +ASSERT items1.length == 2 +ASSERT response.hasNext() == true + +# Navigate to second page +response = AWAIT response.next() +items2 = response.items() +ASSERT items2.length == 1 +ASSERT items2[0]["id"] == "3" +ASSERT response.hasNext() == false +``` + +--- + +## RSC19d - Non-array response handling + +**Test ID**: `rest/unit/RSC19d/non-array-response-handling-7` + +Tests that non-array responses are handled correctly (wrapped as single item). + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/time", version: 3) +items = response.items() +``` + +### Assertions +```pseudo +# Non-array response should be accessible +ASSERT items.length == 1 OR items["time"] == 1234567890000 +# Implementation may vary - either wrap in array or return object directly +``` + +--- + +## RSC19e - Network error handling + +**Test ID**: `rest/unit/RSC19e/network-error-propagated-0` + +**Spec requirement:** Network errors must be properly propagated to the caller after all fallback attempts are exhausted. + +Tests that network errors are properly propagated after fallback attempts. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_refused() +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + fallbackHosts: [] # Disable fallback for this test +)) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/test", version: 3) FAILS WITH error +ASSERT error.code == 80000 OR error.message CONTAINS "network" OR error.message CONTAINS "connection" +``` + +--- + +## RSC19e - Timeout error handling + +**Test ID**: `rest/unit/RSC19e/timeout-error-handling-1` + +Tests that request timeouts are properly handled. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_delayed_response( + delay: 5000, # 5 second delay + status: 200, + body: [] +) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + httpRequestTimeout: 1000, # 1 second timeout + fallbackHosts: [] # Disable fallback +)) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/test", version: 3) FAILS WITH error +ASSERT error.code == 50003 OR error.message CONTAINS "timeout" +``` + +--- + +## RSC19e - HTTP error status does not trigger fallback + +**Test ID**: `rest/unit/RSC19e/http-error-no-fallback-2` + +Tests that HTTP error responses (4xx, 5xx with valid Ably error body) are returned directly without fallback retry. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(400, + body: { "error": { "code": 40000, "message": "Bad request" } }, + headers: { "X-Ably-Errorcode": "40000" } +) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + fallbackHosts: ["a.ably-realtime.com", "b.ably-realtime.com"] +)) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/test", version: 3) +``` + +### Assertions +```pseudo +# Should return the error response, not retry to fallback +ASSERT response.statusCode == 400 +ASSERT response.success == false +ASSERT response.errorCode == 40000 + +# Only one request should have been made (no fallback) +ASSERT mock_http.captured_requests.length == 1 +``` + +--- + +## RSC19e, RSC15 - Fallback hosts tried on server errors + +**Test ID**: `rest/unit/RSC19e/fallback-on-server-error-3` + +Tests that fallback hosts are attempted when primary host returns server error without valid Ably error. + +### Setup +```pseudo +mock_http = MockHttpClient() + +# Primary host fails with non-Ably 500 error +mock_http.queue_response(500, + body: "Internal Server Error", + headers: { "Content-Type": "text/plain" } +) + +# Fallback succeeds +mock_http.queue_response(200, [{ "id": "1" }]) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + fallbackHosts: ["fallback.ably-realtime.com"] +)) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/test", version: 3) +``` + +### Assertions +```pseudo +ASSERT response.statusCode == 200 +ASSERT response.success == true + +# Two requests: primary failed, fallback succeeded +ASSERT mock_http.captured_requests.length == 2 +ASSERT mock_http.captured_requests[1].url.host == "fallback.ably-realtime.com" +``` + +--- + +## RSC19b - Cannot override authentication + +**Test ID**: `rest/unit/RSC19b/cannot-override-auth-1` + +Tests that the request() method does not allow overriding the configured authentication via custom headers. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, []) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Attempt to override auth with custom header +response = AWAIT client.request("GET", "/test", + version: 3, + headers: { "Authorization": "Bearer malicious-token" } +) +``` + +### Assertions +```pseudo +request = mock_http.captured_requests[0] + +# The configured Basic auth should be used, not the custom header +ASSERT request.headers["Authorization"] STARTS_WITH "Basic " +# Should NOT contain the attempted override +ASSERT request.headers["Authorization"] != "Bearer malicious-token" +``` + +### Note +This behavior may vary by implementation. Some libraries may allow header override while others enforce configured auth. The spec states authentication is "unconditional" per RSC19b. + +--- + +## RSC19f - Path with leading slash + +**Test ID**: `rest/unit/RSC19f/path-leading-slash-handling-4` + +Tests that paths are handled correctly whether or not they include a leading slash. + +### Setup +```pseudo +mock_http = MockHttpClient() +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Cases + +| ID | Path | Expected Path in Request | +|----|------|--------------------------| +| 1 | "/channels/test" | "/channels/test" | +| 2 | "channels/test" | "/channels/test" | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + mock_http.reset() + mock_http.queue_response(200, []) + + response = AWAIT client.request("GET", test_case.path, version: 3) + + request = mock_http.captured_requests[0] + ASSERT request.url.path == test_case.expected_path +``` + +--- + +## RSC19d - Empty response handling + +**Test ID**: `rest/unit/RSC19d/empty-response-handling-8` + +Tests that empty responses (204 No Content) are handled correctly. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(204, + body: null, # No body + headers: {} +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("DELETE", "/channels/test/messages/123", version: 3) +``` + +### Assertions +```pseudo +ASSERT response.statusCode == 204 +ASSERT response.success == true +items = response.items() +ASSERT items IS null OR items.length == 0 +``` diff --git a/uts/rest/unit/request_endpoint.md b/uts/rest/unit/request_endpoint.md new file mode 100644 index 000000000..02286bb43 --- /dev/null +++ b/uts/rest/unit/request_endpoint.md @@ -0,0 +1,182 @@ +# Request Endpoint Tests + +Spec points: `RSC25` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. + +--- + +## RSC25 - Requests sent to primary domain first + +**Spec requirement:** Requests are sent to the `primary domain` as determined by `REC1`. New HTTP requests (except where `RSC15f` applies and a cached fallback host is in effect) are first attempted against the `primary domain`. + +### RSC25 - Default primary domain used for requests + +**Test ID**: `rest/unit/RSC25/default-primary-domain-0` + +Tests that REST requests are sent to the default primary domain when no endpoint configuration is provided. + +#### Setup +```pseudo +mock_http = MockHttpClient( + onRequest: (req) => { + req.respond_with(200, {"time": 1234567890000}) + } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +#### Test Steps +```pseudo +AWAIT client.time() +``` + +#### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 1 +ASSERT mock_http.captured_requests[0].url.host == DEFAULT_REST_HOST +``` + +--- + +### RSC25 - Custom endpoint used for requests + +**Test ID**: `rest/unit/RSC25/custom-endpoint-domain-1` + +Tests that REST requests are sent to a custom production routing policy domain. + +#### Setup +```pseudo +mock_http = MockHttpClient( + onRequest: (req) => { + req.respond_with(200, {"time": 1234567890000}) + } +) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "test" +)) +``` + +#### Test Steps +```pseudo +AWAIT client.time() +``` + +#### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 1 +ASSERT mock_http.captured_requests[0].url.host == "test.realtime.ably.net" +``` + +--- + +### RSC25 - Multiple requests all go to primary domain + +**Test ID**: `rest/unit/RSC25/multiple-requests-primary-domain-2` + +Tests that successive requests continue to use the primary domain (no unexpected host switching). + +#### Setup +```pseudo +mock_http = MockHttpClient( + onRequest: (req) => { + req.respond_with(200, {"time": 1234567890000}) + } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +#### Test Steps +```pseudo +AWAIT client.time() +AWAIT client.time() +AWAIT client.time() +``` + +#### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 3 +FOR EACH request IN mock_http.captured_requests: + ASSERT request.url.host == DEFAULT_REST_HOST +``` + +--- + +### RSC25 - Primary domain tried first before fallback + +**Test ID**: `rest/unit/RSC25/primary-tried-before-fallback-3` + +Tests that when the primary host fails and a fallback succeeds, the primary was attempted first. + +#### Setup +```pseudo +request_count = 0 + +mock_http = MockHttpClient( + onRequest: (req) => { + request_count++ + IF request_count == 1: + req.respond_with(500, {"error": {"code": 50000}}) + ELSE: + req.respond_with(200, {"time": 1234567890000}) + } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +#### Test Steps +```pseudo +AWAIT client.time() +``` + +#### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 2 +# First request was to primary domain +ASSERT mock_http.captured_requests[0].url.host == DEFAULT_REST_HOST +# Second request was to a fallback domain (not primary) +ASSERT mock_http.captured_requests[1].url.host != DEFAULT_REST_HOST +``` + +--- + +### RSC25 - Request path preserved when sent to primary domain + +**Test ID**: `rest/unit/RSC25/request-path-preserved-4` + +Tests that the request path and query parameters are correctly constructed when sent to the primary domain. + +#### Setup +```pseudo +mock_http = MockHttpClient( + onRequest: (req) => { + req.respond_with(200, []) + } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +#### Test Steps +```pseudo +AWAIT client.channels.get("test-channel").history() +``` + +#### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 1 +request = mock_http.captured_requests[0] +ASSERT request.url.host == DEFAULT_REST_HOST +ASSERT request.url.path == "/channels/test-channel/messages" +ASSERT request.method == "GET" +``` diff --git a/uts/rest/unit/rest_client.md b/uts/rest/unit/rest_client.md new file mode 100644 index 000000000..80fe4241d --- /dev/null +++ b/uts/rest/unit/rest_client.md @@ -0,0 +1,590 @@ +# REST Client Tests + +Spec points: `RSC5`, `RSC7`, `RSC7b`, `RSC7c`, `RSC7d`, `RSC7e`, `RSC8`, `RSC8a`, `RSC8b`, `RSC8c`, `RSC8d`, `RSC8e`, `RSC13`, `RSC17`, `RSC18` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. + +--- + +## RSC5 - Auth Attribute + +**Test ID**: `rest/unit/RSC5/auth-attribute-accessible-0` + +**Spec requirement:** `RestClient#auth` attribute provides access to the `Auth` object that was instantiated with the `ClientOptions` provided in the `RestClient` constructor. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret" +)) +``` + +### Assertions +```pseudo +ASSERT client.auth IS NOT null +ASSERT client.auth IS Auth +``` + +--- + +## RSC7e - X-Ably-Version header + +**Test ID**: `rest/unit/RSC7e/ably-version-header-0` + +**Spec requirement:** All REST requests must include the `X-Ably-Version` header with the spec version. + +Tests that all REST requests include the `X-Ably-Version` header. + +### Setup +```pseudo +captured_request = null + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_request = req + req.respond_with(200, {"time": 1234567890000}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT captured_request IS NOT null +ASSERT "X-Ably-Version" IN captured_request.headers +ASSERT captured_request.headers["X-Ably-Version"] matches pattern "[0-9.]+" +``` + +--- + +## RSC7d, RSC7d1, RSC7d2 - Ably-Agent header + +**Test ID**: `rest/unit/RSC7d/ably-agent-header-format-0` + +| Spec | Requirement | +|------|-------------| +| RSC7d | All requests must include Ably-Agent header | +| RSC7d1 | Header format: space-separated key/value pairs | +| RSC7d2 | Must include library name and version | + +Tests that all REST requests include the `Ably-Agent` header with correct format. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +request = mock_http.captured_requests[0] +ASSERT "Ably-Agent" IN request.headers + +agent = request.headers["Ably-Agent"] +# Format: key[/value] entries joined by spaces +# Must include at least library name/version +ASSERT agent matches pattern "ably-[a-z]+/[0-9]+\\.[0-9]+\\.[0-9]+" +# May include additional entries like platform info +``` + +--- + +## RSC7c - Request ID when addRequestIds enabled + +**Test ID**: `rest/unit/RSC7c/request-id-included-0` + +**Spec requirement:** When `addRequestIds` is true, all requests must include a `request_id` query parameter with a unique URL-safe identifier. + +Tests that `request_id` query parameter is included when `addRequestIds` is true. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + addRequestIds: true +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +request = mock_http.captured_requests[0] +ASSERT "request_id" IN request.url.query_params + +request_id = request.url.query_params["request_id"] +# Should be url-safe base64 encoded, at least 12 characters (9 bytes base64) +ASSERT request_id.length >= 12 +ASSERT request_id matches pattern "[A-Za-z0-9_-]+" +``` + +--- + +## RSC7c - Request ID preserved on fallback retry + +**Test ID**: `rest/unit/RSC7c/request-id-preserved-fallback-1` + +**Spec requirement:** The same `request_id` must be preserved when retrying a failed request to fallback hosts. + +Tests that the same `request_id` is used when retrying to a fallback host. + +### Setup +```pseudo +mock_http = MockHttpClient() +# First request fails with 500 (triggers fallback retry) +mock_http.queue_response(500, { "error": { "code": 50000 } }) +# Retry succeeds +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + addRequestIds: true, + fallbackHosts: ["a.example.com", "b.example.com"] +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 2 + +request_id_1 = mock_http.captured_requests[0].url.query_params["request_id"] +request_id_2 = mock_http.captured_requests[1].url.query_params["request_id"] + +ASSERT request_id_1 == request_id_2 # Same ID for retry +``` + +--- + +## RSC8a, RSC8b - Protocol selection + +**Test ID**: `rest/unit/RSC8a/protocol-selection-0` + +| Spec | Requirement | +|------|-------------| +| RSC8a | MessagePack protocol is used by default | +| RSC8b | JSON protocol used when `useBinaryProtocol` is false | + +Tests that the correct protocol (MessagePack or JSON) is used based on configuration. + +**Note:** This test covers both `Content-Type` and `Accept` headers for the configured protocol. RSC8c below tests the same assertions in a single-case form for clarity. The two tests are complementary — RSC8a/b focuses on protocol *selection*, RSC8c on header *consistency*. + +### Setup +```pseudo +mock_http = MockHttpClient() +``` + +### Test Cases + +| ID | useBinaryProtocol | Expected Content-Type | +|----|-------------------|----------------------| +| 1 | `true` (default) | `application/x-msgpack` | +| 2 | `false` | `application/json` | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + mock_http.reset() + mock_http.queue_response(201, { "serials": ["s1"] }) + + client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: test_case.useBinaryProtocol + )) + + AWAIT client.channels.get("test").publish(name: "e", data: "d") + + request = mock_http.captured_requests[0] + ASSERT request.headers["Content-Type"] == test_case.expected_content_type + ASSERT request.headers["Accept"] == test_case.expected_content_type +``` + +--- + +## RSC8c - Accept and Content-Type headers + +**Test ID**: `rest/unit/RSC8c/accept-content-type-headers-0` + +**Spec requirement:** Accept and Content-Type headers must match the configured protocol (application/json or application/x-msgpack). + +Tests that Accept and Content-Type headers reflect the configured protocol. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(201, { "serials": ["s1"] }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false # JSON for easier inspection +)) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get("test").publish(name: "e", data: "d") +``` + +### Assertions +```pseudo +request = mock_http.captured_requests[0] +ASSERT request.headers["Accept"] == "application/json" +ASSERT request.headers["Content-Type"] == "application/json" +``` + +--- + +## RSC8d - Handle mismatched response Content-Type + +**Test ID**: `rest/unit/RSC8d/mismatched-response-content-type-0` + +**Spec requirement:** The client must be able to decode responses in either JSON or MessagePack format, regardless of which format was requested. + +Tests that responses with different Content-Type than requested are still processed if supported. + +### Setup +```pseudo +mock_http = MockHttpClient() +# Client requests JSON but server returns msgpack +mock_http.queue_response(200, + body: msgpack_encode({ "time": 1234567890000 }), + headers: { "Content-Type": "application/x-msgpack" } +) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false # Client prefers JSON +)) +``` + +### Test Steps +```pseudo +result = AWAIT client.time() +``` + +### Assertions +```pseudo +# Should successfully parse msgpack response despite requesting JSON +ASSERT result IS DateTime OR result == 1234567890000 +``` + +--- + +## RSC8e - Unsupported Content-Type handling + +**Test ID**: `rest/unit/RSC8e/unsupported-content-type-0` + +**Spec requirement:** When the server returns an unsupported Content-Type, the client must raise an error with code 40013 for 2xx responses, or propagate the HTTP status code for error responses. + +Tests error handling when server returns unsupported Content-Type. + +### Test Cases + +| ID | Status Code | Content-Type | Expected Error Code | +|----|-------------|--------------|---------------------| +| 1 | 500 | `text/html` | 500 (status propagated) | +| 2 | 200 | `text/html` | 40013 | + +### Setup (Case 1 - Error status) +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, + body: "Server Error", + headers: { "Content-Type": "text/html" } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps (Case 1) +```pseudo +AWAIT client.time() FAILS WITH error +ASSERT error.statusCode == 500 +# Note: the error message is not asserted here because the 500 path +# hits the SDK's generic error-response handling (which attempts to +# parse the body as a JSON error and falls back to a generic message). +# The key assertion is that the HTTP status code is propagated. +``` + +### Setup (Case 2 - Success status but bad content) +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, + body: "OK", + headers: { "Content-Type": "text/html" } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps (Case 2) +```pseudo +AWAIT client.time() FAILS WITH error +ASSERT error.statusCode == 400 +ASSERT error.code == 40013 +``` + +--- + +## RSC8 - Error response decoded from MessagePack + +**Test ID**: `rest/unit/RSC8/error-decoded-from-msgpack-0` + +**Spec requirement:** When the server returns an error response with `Content-Type: application/x-msgpack`, the SDK must decode the error body using MessagePack (not JSON). The error code, status code, and message must be correctly extracted. This is the default behaviour when `useBinaryProtocol` is `true` (the default), because the `Accept: application/x-msgpack` header causes the server to return all responses — including errors — in MessagePack format. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(400, + body: msgpack_encode({ + "error": { + "code": 40099, + "statusCode": 400, + "message": "Test error" + } + }), + headers: { "Content-Type": "application/x-msgpack" } + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: true # Default — server returns msgpack +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40099 +ASSERT error.statusCode == 400 +ASSERT error.message == "Test error" +``` + +### Note +A common implementation bug is to always parse error response bodies as JSON +(e.g. `response.json()`), regardless of the response `Content-Type`. When the +server returns a MessagePack-encoded error body, the JSON parse fails silently +and the SDK falls back to a generic error code (e.g. 50000 InternalError), +losing the real error information. The SDK must check the response +`Content-Type` and use the appropriate deserializer. + +--- + +## RSC13 - Request timeouts + +**Test ID**: `rest/unit/RSC13/request-timeout-enforced-0` + +**Spec requirement:** HTTP requests must respect the `httpRequestTimeout` option and fail with code 50003 when the timeout is exceeded. + +Tests that configured timeouts are applied to HTTP requests. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success() +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + httpRequestTimeout: 1000 # 1 second timeout +)) +``` + +### Test Steps +```pseudo +time_future = client.time() + +# Wait for request and respond with delay +request = AWAIT mock_http.await_request() +request.respond_with_delay(5000, 200, {"time": 1234567890000}) + +AWAIT time_future FAILS WITH error +ASSERT error.code == 50003 OR error.message CONTAINS "timeout" +``` + +### Note +The timeout must be enforced at the SDK level (wrapping the HTTP execute call), +not solely by the HTTP library's built-in timeout. HTTP library timeouts +typically do not fire with mock clients since no real network I/O occurs. + +The recommended implementation pattern is: +- The mock client's `execute()` sleeps for the configured delay before returning +- The SDK wraps the `execute()` call with its own timeout (using the language's + async timeout mechanism) +- The SDK timeout fires before the mock delay completes, producing the expected error + +This avoids requiring complex async connection-level mocking (`await_request` / +`respond_with_delay`) and keeps the test fast — the test only waits for the +short timeout duration (e.g. 100ms), not the full mock delay. + +--- + +## RSC17 - ClientId Attribute + +**Test ID**: `rest/unit/RSC17/client-id-from-options-0` + +**Spec requirement:** When instantiating a `RestClient`, if a `clientId` attribute is set in `ClientOptions`, then the `Auth#clientId` attribute will contain the provided `clientId`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + clientId: "explicit-client-id" +)) +``` + +### Assertions +```pseudo +ASSERT client.clientId == "explicit-client-id" +ASSERT client.clientId == client.auth.clientId +``` + +--- + +## RSC17 - ClientId Attribute + +**Test ID**: `rest/unit/RSC17/client-id-matches-auth-1` + +**Spec requirement:** When instantiating a `RestClient`, if a `clientId` attribute is set in `ClientOptions`, then the `Auth#clientId` attribute will contain the provided `clientId`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + clientId: "explicit-client-id" +)) +``` + +### Assertions +```pseudo +ASSERT client.clientId == "explicit-client-id" +ASSERT client.clientId == client.auth.clientId +``` + +--- + +## RSC18 - TLS configuration + +**Test ID**: `rest/unit/RSC18/tls-controls-protocol-scheme-0` + +**Spec requirement:** The `tls` option controls whether HTTPS (true, default) or HTTP (false) is used for REST requests. + +Tests that TLS setting controls protocol used. + +### Test Cases + +| ID | tls | Expected Scheme | +|----|-----|-----------------| +| 1 | `true` (default) | `https` | +| 2 | `false` | `http` | + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) +``` + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + mock_http.reset() + mock_http.queue_response(200, { "time": 1234567890000 }) + + client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + tls: test_case.tls + )) + + AWAIT client.time() + + request = mock_http.captured_requests[0] + ASSERT request.url.scheme == test_case.expected_scheme +``` + +--- + +## RSC18 - Basic auth over HTTP rejected + +**Test ID**: `rest/unit/RSC18/basic-auth-over-http-rejected-1` + +**Spec requirement:** Basic authentication (API key) must be rejected when `tls` is false. Token authentication is permitted over HTTP. Error code 40103. + +Tests that Basic authentication is rejected when TLS is disabled. + +### Setup +```pseudo +# No mock needed - should fail before making request +``` + +### Test Steps +```pseudo +Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + tls: false +)) FAILS WITH error +ASSERT error.code == 40103 OR error.message CONTAINS "insecure" OR error.message CONTAINS "TLS" +``` + +### Note +Token auth over HTTP should be allowed. Only Basic auth (API key) should be rejected. + +### Additional Test - Token auth over HTTP allowed +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + token: "some-token-string", + tls: false +)) + +result = AWAIT client.time() +# Should succeed - token auth over HTTP is permitted +ASSERT result IS valid +``` + +--- + +## Test Infrastructure Notes + +See `uts/test/rest/unit/helpers/mock_http.md` for mock installation, test isolation, and timer mocking guidance. diff --git a/uts/rest/unit/stats.md b/uts/rest/unit/stats.md new file mode 100644 index 000000000..717890d48 --- /dev/null +++ b/uts/rest/unit/stats.md @@ -0,0 +1,714 @@ +# Stats API Tests + +Spec points: `RSC6`, `RSC6a`, `RSC6b1`, `RSC6b2`, `RSC6b3`, `RSC6b4` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. + +## Purpose + +Tests the `stats()` method which retrieves application statistics from Ably. The stats endpoint requires authentication and returns paginated results. + +--- + +## RSC6a - stats() returns PaginatedResult with Stats objects + +**Test ID**: `rest/unit/RSC6a/returns-paginated-stats-0` + +**Spec requirement:** Returns a `PaginatedResult` page containing `Stats` objects in the `PaginatedResult#items` attribute returned from the stats request. + +Tests that `stats()` makes a GET request to `/stats` and returns a PaginatedResult containing Stats objects. + +### Setup +```pseudo +captured_requests = [] +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} + } + } +] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, stats_data) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.stats() +``` + +### Assertions +```pseudo +# Result should be a PaginatedResult +ASSERT result IS PaginatedResult +ASSERT result.items.length == 2 + +# Stats objects should have correct fields +ASSERT result.items[0].intervalId == "2024-01-01:00:00" +ASSERT result.items[0].unit == "hour" +ASSERT result.items[1].intervalId == "2024-01-01:01:00" + +# Verify correct endpoint and method +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.path == "/stats" +``` + +--- + +## RSC6a - stats() sends authenticated request with standard headers + +**Test ID**: `rest/unit/RSC6a/authenticated-with-headers-1` + +**Spec requirement:** The `/stats` endpoint requires authentication. Requests must include valid credentials and standard Ably headers. + +Tests that stats() sends an authenticated request with standard Ably headers. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.stats() +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] + +# Request must be authenticated +ASSERT "Authorization" IN request.headers + +# Standard Ably headers must be present +ASSERT "X-Ably-Version" IN request.headers +ASSERT "Ably-Agent" IN request.headers +``` + +--- + +## RSC6b1 - stats() with start parameter + +**Test ID**: `rest/unit/RSC6b1/start-param-millis-0` + +**Spec requirement:** `start` is an optional timestamp field represented as milliseconds since epoch. If provided, must be equal to or less than `end` if provided or to the current time otherwise. + +Tests that the `start` parameter is sent as milliseconds since epoch. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +start_time = DateTime(2024, 1, 1, 0, 0, 0, UTC) +AWAIT client.stats(start: start_time) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.query_params["start"] == str(start_time.millisecondsSinceEpoch) +``` + +--- + +## RSC6b1 - stats() with end parameter + +**Test ID**: `rest/unit/RSC6b1/end-param-millis-1` + +**Spec requirement:** `end` is an optional timestamp field represented as milliseconds since epoch. + +Tests that the `end` parameter is sent as milliseconds since epoch. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +end_time = DateTime(2024, 1, 31, 23, 59, 59, UTC) +AWAIT client.stats(end: end_time) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.query_params["end"] == str(end_time.millisecondsSinceEpoch) +``` + +--- + +## RSC6b1 - stats() with start and end parameters + +**Test ID**: `rest/unit/RSC6b1/start-and-end-params-2` + +**Spec requirement:** `start` and `end` are optional timestamp fields. `start`, if provided, must be equal to or less than `end` if provided. + +Tests that both `start` and `end` are sent as query parameters when provided together. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +start_time = DateTime(2024, 1, 1, 0, 0, 0, UTC) +end_time = DateTime(2024, 1, 31, 23, 59, 59, UTC) +AWAIT client.stats(start: start_time, end: end_time) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.query_params["start"] == str(start_time.millisecondsSinceEpoch) +ASSERT request.query_params["end"] == str(end_time.millisecondsSinceEpoch) +``` + +--- + +## RSC6b2 - stats() with direction parameter + +**Test ID**: `rest/unit/RSC6b2/direction-param-forwards-0` + +**Spec requirement:** `direction` backwards or forwards; if omitted the direction defaults to the REST API default (backwards). + +Tests that the `direction` parameter is sent as a query parameter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.stats(direction: "forwards") +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.query_params["direction"] == "forwards" +``` + +--- + +## RSC6b2 - stats() direction defaults to backwards + +**Test ID**: `rest/unit/RSC6b2/direction-defaults-backwards-1` + +**Spec requirement:** If omitted the direction defaults to the REST API default (backwards). + +Tests that when direction is not specified, it is either omitted from the query (letting the server apply the default) or sent as "backwards". + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.stats() +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] + +# Direction should either be absent (server default) or "backwards" +ASSERT "direction" NOT IN request.query_params + OR request.query_params["direction"] == "backwards" +``` + +--- + +## RSC6b3 - stats() with limit parameter + +**Test ID**: `rest/unit/RSC6b3/limit-param-value-0` + +**Spec requirement:** `limit` supports up to 1,000 items; if omitted the limit defaults to the REST API default (100). + +Tests that the `limit` parameter is sent as a query parameter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.stats(limit: 10) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.query_params["limit"] == "10" +``` + +--- + +## RSC6b3 - stats() limit defaults to 100 + +**Test ID**: `rest/unit/RSC6b3/limit-defaults-to-100-1` + +**Spec requirement:** If omitted the limit defaults to the REST API default (100). + +Tests that when limit is not specified, it is either omitted from the query (letting the server apply the default) or sent as "100". + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.stats() +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] + +# Limit should either be absent (server default) or "100" +ASSERT "limit" NOT IN request.query_params + OR request.query_params["limit"] == "100" +``` + +--- + +## RSC6b4 - stats() with unit parameter + +**Test ID**: `rest/unit/RSC6b4/unit-param-values-0` + +**Spec requirement:** `unit` is the period for which the stats will be aggregated by, values supported are `minute`, `hour`, `day` or `month`; if omitted the unit defaults to the REST API default (`minute`). + +Tests that each valid unit value is sent as a query parameter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Cases + +| ID | Unit | +|----|------| +| 1 | minute | +| 2 | hour | +| 3 | day | +| 4 | month | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + captured_requests = [] + + AWAIT client.stats(unit: test_case.unit) + + ASSERT captured_requests.length == 1 + request = captured_requests[0] + ASSERT request.query_params["unit"] == test_case.unit +``` + +--- + +## RSC6b4 - stats() unit defaults to minute + +**Test ID**: `rest/unit/RSC6b4/unit-defaults-to-minute-1` + +**Spec requirement:** If omitted the unit defaults to the REST API default (`minute`). + +Tests that when unit is not specified, it is either omitted from the query (letting the server apply the default) or sent as "minute". + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.stats() +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] + +# Unit should either be absent (server default) or "minute" +ASSERT "unit" NOT IN request.query_params + OR request.query_params["unit"] == "minute" +``` + +--- + +## RSC6b - stats() with all parameters combined + +**Test ID**: `rest/unit/RSC6b/all-params-combined-0` + +| Spec | Requirement | +|------|-------------| +| RSC6b1 | `start` and `end` timestamp parameters | +| RSC6b2 | `direction` parameter | +| RSC6b3 | `limit` parameter | +| RSC6b4 | `unit` parameter | + +Tests that all parameters can be used together in a single request. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +start_time = DateTime(2024, 1, 1, 0, 0, 0, UTC) +end_time = DateTime(2024, 1, 31, 23, 59, 59, UTC) +AWAIT client.stats( + start: start_time, + end: end_time, + direction: "forwards", + limit: 50, + unit: "hour" +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.query_params["start"] == str(start_time.millisecondsSinceEpoch) +ASSERT request.query_params["end"] == str(end_time.millisecondsSinceEpoch) +ASSERT request.query_params["direction"] == "forwards" +ASSERT request.query_params["limit"] == "50" +ASSERT request.query_params["unit"] == "hour" +``` + +--- + +## RSC6a - stats() with no parameters sends no query params + +**Test ID**: `rest/unit/RSC6a/no-params-clean-request-2` + +**Spec requirement:** All parameters are optional. When no parameters are provided, the request should omit query parameters (letting the server apply defaults). + +Tests that calling stats() with no arguments sends a clean GET to `/stats`. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.stats() +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.path == "/stats" + +# No query parameters should be sent (server applies defaults) +ASSERT request.query_params IS empty +``` + +--- + +## RSC6a - stats() pagination with Link headers + +**Test ID**: `rest/unit/RSC6a/pagination-link-headers-3` + +**Spec requirement:** Returns a `PaginatedResult` page. PaginatedResult supports navigation via Link headers (TG4, TG6). + +Tests that stats results support pagination navigation using Link headers. + +### Setup +```pseudo +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count++ + IF request_count == 1: + req.respond_with(200, + [{"intervalId": "2024-01-01:01:00", "unit": "hour"}], + headers: {"Link": '; rel="next"'} + ) + ELSE: + req.respond_with(200, + [{"intervalId": "2024-01-01:00:00", "unit": "hour"}] + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +page1 = AWAIT client.stats(limit: 1) +page2 = AWAIT page1.next() +``` + +### Assertions +```pseudo +# First page +ASSERT page1.items.length == 1 +ASSERT page1.items[0].intervalId == "2024-01-01:01:00" +ASSERT page1.hasNext() == true +ASSERT page1.isLast() == false + +# Second page +ASSERT page2.items.length == 1 +ASSERT page2.items[0].intervalId == "2024-01-01:00:00" +ASSERT page2.hasNext() == false +ASSERT page2.isLast() == true +``` + +--- + +## RSC6a - stats() empty results + +**Test ID**: `rest/unit/RSC6a/empty-results-handled-4` + +**Spec requirement:** Returns a `PaginatedResult` page containing `Stats` objects. Must handle empty result sets correctly. + +Tests that stats() handles empty results correctly. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.stats() +``` + +### Assertions +```pseudo +ASSERT result IS PaginatedResult +ASSERT result.items.length == 0 +ASSERT result.hasNext() == false +ASSERT result.isLast() == true +``` + +--- + +## RSC6a - stats() error handling + +**Test ID**: `rest/unit/RSC6a/error-propagated-5` + +**Spec requirement:** Errors from the stats endpoint must be properly propagated to the caller. + +Tests that errors from the stats endpoint are properly propagated. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(401, { + "error": { + "message": "Unauthorized", + "code": 40100, + "statusCode": 401 + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.stats() FAILS WITH error +ASSERT error.statusCode == 401 +ASSERT error.code == 40100 +``` diff --git a/uts/rest/unit/time.md b/uts/rest/unit/time.md new file mode 100644 index 000000000..dfb5418f0 --- /dev/null +++ b/uts/rest/unit/time.md @@ -0,0 +1,244 @@ +# Time API Tests + +Spec points: `RSC16` + +## Test Type +Unit test with mocked HTTP client + +## Mock Configuration + +These tests use the mock HTTP infrastructure defined in `rest_client.md`. The mock supports: +- Handler-based configuration with `onConnectionAttempt` and `onRequest` +- Capturing requests via `captured_requests` arrays +- Configurable responses with status codes, bodies, and headers + +See `rest_client.md` for detailed mock interface documentation. + +## Purpose + +Tests the `time()` method which retrieves the current server time from Ably. + +**Note:** The `time()` endpoint does NOT require authentication. Do not use it for testing authentication - use the channel status endpoint instead. + +--- + +## RSC16 - time() returns server time + +**Test ID**: `rest/unit/RSC16/returns-server-time-0` + +**Spec requirement:** The `time()` method retrieves the server time from the `/time` endpoint and returns it as a DateTime or timestamp. + +Tests that `time()` returns the server time as a DateTime/timestamp. + +### Setup +```pseudo +captured_requests = [] +server_time_ms = 1704067200000 # 2024-01-01 00:00:00 UTC + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [server_time_ms]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.time() +``` + +### Assertions +```pseudo +# Result should be a DateTime matching the server timestamp +ASSERT result IS DateTime +ASSERT result.millisecondsSinceEpoch == server_time_ms + +# Verify correct endpoint was called +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.path == "/time" +``` + +--- + +## RSC16 - time() request format + +**Test ID**: `rest/unit/RSC16/request-format-get-time-1` + +**Spec requirement:** The time request must be a GET request to `/time` with standard Ably headers. + +Tests that the time request is correctly formatted. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [1704067200000]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] + +# Should be GET request to /time +ASSERT request.method == "GET" +ASSERT request.path == "/time" + +# Should have standard Ably headers +ASSERT "X-Ably-Version" IN request.headers +ASSERT "Ably-Agent" IN request.headers +``` + +--- + +## RSC16 - time() does not require authentication + +**Test ID**: `rest/unit/RSC16/no-auth-required-2` + +**Spec requirement:** The `/time` endpoint does not require authentication and should not send an Authorization header, even when credentials are available. + +Tests that time() does not send authentication credentials, even when the client has them. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [1704067200000]) + } +) +install_mock(mock_http) + +# Client has credentials, but time() should not use them +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.time() +``` + +### Assertions +```pseudo +# Should succeed +ASSERT result IS DateTime + +# Request should not have Authorization header even though client has credentials +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT "Authorization" NOT IN request.headers +``` + +--- + +## RSC16 - time() works without TLS + +**Test ID**: `rest/unit/RSC16/works-without-tls-3` + +**Spec requirement:** The `/time` endpoint does not require authentication, so it should be callable over HTTP (non-TLS) without sending credentials. The RSC18 restriction (no basic auth over non-TLS) does not apply because time() doesn't send authentication. + +Tests that time() succeeds over HTTP (non-TLS) without sending credentials. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [1704067200000]) + } +) +install_mock(mock_http) + +# Client with API key but using token auth to avoid RSC18 restriction +# on authenticated operations. time() should still work over HTTP. +client = Rest(options: ClientOptions( + key: "app.key:secret", + tls: false, + useTokenAuth: true +)) +``` + +### Test Steps +```pseudo +result = AWAIT client.time() +``` + +### Assertions +```pseudo +# Should succeed without sending authentication over HTTP +ASSERT result IS DateTime + +# Request should use HTTP (not HTTPS) +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.url.scheme == "http" + +# Request should not have Authorization header +ASSERT "Authorization" NOT IN request.headers +``` + +### Note +This test verifies that the RSC18 check (which rejects basic auth over non-TLS connections) is only applied to operations that require authentication. The `time()` endpoint is unauthenticated, so it should work regardless of TLS settings. The client constructor still requires credentials, but time() doesn't use them. + +--- + +## RSC16 - time() error handling + +**Test ID**: `rest/unit/RSC16/error-propagated-4` + +**Spec requirement:** Errors from the `/time` endpoint should be properly propagated to the caller. + +Tests that errors from the time endpoint are properly propagated. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(500, { + "error": { + "message": "Internal server error", + "code": 50000, + "statusCode": 500 + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.time() FAILS WITH error +ASSERT error.statusCode == 500 +ASSERT error.code == 50000 +``` diff --git a/uts/rest/unit/types/error_types.md b/uts/rest/unit/types/error_types.md new file mode 100644 index 000000000..ed85212ee --- /dev/null +++ b/uts/rest/unit/types/error_types.md @@ -0,0 +1,245 @@ +# Error Types Tests + +Spec points: `TI1`, `TI2`, `TI3`, `TI4`, `TI5` + +## Test Type +Unit test - pure type/model validation + +## Mock Configuration +No mocks required - these verify type structure. + +--- + +## TI1-TI5 - ErrorInfo attributes + +**Test ID**: `rest/unit/TI1/errorinfo-attributes-0` + +**Spec requirement:** ErrorInfo type must provide all required attributes according to TI1-TI5 specifications. + +| Spec | Attribute | Description | +|------|-----------|-------------| +| TI1 | code | Ably-specific error code | +| TI2 | statusCode | HTTP status code | +| TI3 | message | Human-readable error message | +| TI4 | href | URL for more information | +| TI5 | cause | Underlying cause error/exception | + +Tests that `ErrorInfo` (or `AblyException`) has all required attributes. + +### Test Cases + +| ID | Spec | Attribute | Type | Description | +|----|------|-----------|------|-------------| +| 1 | TI1 | `code` | Integer | Ably-specific error code | +| 2 | TI2 | `statusCode` | Integer | HTTP status code | +| 3 | TI3 | `message` | String | Human-readable error message | +| 4 | TI4 | `href` | String | URL for more information | +| 5 | TI5 | `cause` | Error/Exception | Underlying cause | + +### Test Steps +```pseudo +# TI1 - code attribute +error = ErrorInfo(code: 40000) +ASSERT error.code == 40000 + +# TI2 - statusCode attribute +error = ErrorInfo(code: 40100, statusCode: 401) +ASSERT error.statusCode == 401 + +# TI3 - message attribute +error = ErrorInfo( + code: 40000, + statusCode: 400, + message: "Bad request: invalid parameter" +) +ASSERT error.message == "Bad request: invalid parameter" + +# TI4 - href attribute (optional) +error = ErrorInfo( + code: 40000, + href: "https://help.ably.io/error/40000" +) +ASSERT error.href == "https://help.ably.io/error/40000" + +# TI5 - cause attribute (optional) +original_error = Exception("Network failure") +error = ErrorInfo( + code: 50003, + statusCode: 500, + message: "Timeout", + cause: original_error +) +ASSERT error.cause == original_error +``` + +--- + +## TI - ErrorInfo from JSON response + +**Test ID**: `rest/unit/TI/errorinfo-from-json-0` + +**Spec requirement:** ErrorInfo type must support deserialization from Ably JSON error responses. + +Tests that `ErrorInfo` can be deserialized from Ably error response. + +### Test Steps +```pseudo +json_response = { + "error": { + "code": 40100, + "statusCode": 401, + "message": "Token expired", + "href": "https://help.ably.io/error/40100" + } +} + +error = ErrorInfo.fromJson(json_response["error"]) + +ASSERT error.code == 40100 +ASSERT error.statusCode == 401 +ASSERT error.message == "Token expired" +ASSERT error.href == "https://help.ably.io/error/40100" +``` + +--- + +## TI - ErrorInfo with nested error + +**Test ID**: `rest/unit/TI/errorinfo-nested-cause-1` + +**Spec requirement:** ErrorInfo must support nested error structures with a cause field (TI5). + +Tests parsing error response with nested error structure. + +### Test Steps +```pseudo +json_response = { + "error": { + "code": 50000, + "statusCode": 500, + "message": "Internal error", + "cause": { + "code": 50001, + "message": "Database connection failed" + } + } +} + +error = ErrorInfo.fromJson(json_response["error"]) + +ASSERT error.code == 50000 +ASSERT error.cause IS ErrorInfo OR error.cause IS Exception +IF error.cause IS ErrorInfo: + ASSERT error.cause.code == 50001 + ASSERT error.cause.message == "Database connection failed" +``` + +--- + +## TI - AblyException wraps ErrorInfo + +**Test ID**: `rest/unit/TI/ably-exception-wraps-errorinfo-2` + +**Spec requirement:** AblyException (throwable) must wrap ErrorInfo and expose its attributes. + +Tests that `AblyException` (throwable) wraps `ErrorInfo`. + +### Test Steps +```pseudo +error_info = ErrorInfo( + code: 40000, + statusCode: 400, + message: "Bad request" +) + +exception = AblyException(errorInfo: error_info) + +ASSERT exception.code == 40000 +ASSERT exception.statusCode == 400 +ASSERT exception.message == "Bad request" +ASSERT exception.errorInfo == error_info +``` + +--- + +## TI - Common error codes + +**Test ID**: `rest/unit/TI/common-error-codes-3` + +**Spec requirement:** ErrorInfo must correctly handle common Ably error codes with their corresponding status codes and meanings. + +Tests that common Ably error codes are handled correctly. + +### Test Cases + +| ID | Code | Status | Meaning | +|----|------|--------|---------| +| 1 | 40000 | 400 | Bad request | +| 2 | 40100 | 401 | Unauthorized | +| 3 | 40101 | 401 | Invalid credentials | +| 4 | 40140 | 401 | Token error | +| 5 | 40142 | 401 | Token expired | +| 6 | 40160 | 401 | Invalid capability | +| 7 | 40300 | 403 | Forbidden | +| 8 | 40400 | 404 | Not found | +| 9 | 50000 | 500 | Internal server error | +| 10 | 50003 | 500 | Timeout | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + error = ErrorInfo( + code: test_case.code, + statusCode: test_case.status, + message: test_case.meaning + ) + + ASSERT error.code == test_case.code + ASSERT error.statusCode == test_case.status +``` + +--- + +## TI - Error string representation + +**Test ID**: `rest/unit/TI/error-string-representation-4` + +**Spec requirement:** ErrorInfo must provide a useful string representation including error code, status code, and message. + +Tests that errors have a useful string representation. + +### Test Steps +```pseudo +error = ErrorInfo( + code: 40100, + statusCode: 401, + message: "Unauthorized: token expired" +) + +string_repr = str(error) + +# String should include key information +ASSERT "40100" IN string_repr +ASSERT "401" IN string_repr +ASSERT "Unauthorized" IN string_repr OR "token" IN string_repr +``` + +--- + +## TI - Error equality + +**Test ID**: `rest/unit/TI/error-equality-5` + +**Spec requirement:** ErrorInfo must support equality comparison based on error attributes. + +Tests that errors can be compared for equality. + +### Test Steps +```pseudo +error1 = ErrorInfo(code: 40000, statusCode: 400, message: "Bad request") +error2 = ErrorInfo(code: 40000, statusCode: 400, message: "Bad request") +error3 = ErrorInfo(code: 40100, statusCode: 401, message: "Unauthorized") + +ASSERT error1 == error2 # Same content +ASSERT error1 != error3 # Different code +``` diff --git a/uts/rest/unit/types/message_types.md b/uts/rest/unit/types/message_types.md new file mode 100644 index 000000000..69dc53a6b --- /dev/null +++ b/uts/rest/unit/types/message_types.md @@ -0,0 +1,232 @@ +# Message Types Tests + +Spec points: `TM1`, `TM2`, `TM3`, `TM4`, `TM2a`, `TM2b`, `TM2c`, `TM2d`, `TM2e`, `TM2f`, `TM2g`, `TM2h`, `TM2i` + +## Test Type +Unit test - pure type/model validation + +## Mock Configuration +No mocks required - these verify type structure, constructors, and encoding. + +--- + +## TM2a-TM2i - Message attributes + +**Test ID**: `rest/unit/TM2a/message-attributes-0` + +**Spec requirement:** Message type must provide all required attributes according to TM2a-TM2i specifications. + +| Spec | Attribute | Description | +|------|-----------|-------------| +| TM2a | id | Unique message identifier | +| TM2b | name | Event name | +| TM2c | data | Message payload (string, object, or binary) | +| TM2d | clientId | Client ID of the publisher | +| TM2e | connectionId | Connection ID of the publisher | +| TM2f | timestamp | Message timestamp in milliseconds | +| TM2g | encoding | Encoding information for the data | +| TM2h | extras | Additional message metadata | +| TM2i | serial | Server-assigned serial number | + +Tests that `Message` has all required attributes. + +### Test Steps +```pseudo +# TM2a - id attribute +message = Message(id: "unique-id") +ASSERT message.id == "unique-id" + +# TM2b - name attribute +message = Message(name: "event-name") +ASSERT message.name == "event-name" + +# TM2c - data attribute +message = Message(data: "string-data") +ASSERT message.data == "string-data" + +message = Message(data: { "key": "value" }) +ASSERT message.data == { "key": "value" } + +message = Message(data: bytes([0x01, 0x02])) +ASSERT message.data == bytes([0x01, 0x02]) + +# TM2d - clientId attribute +message = Message(clientId: "message-client") +ASSERT message.clientId == "message-client" + +# TM2e - connectionId attribute +message = Message(connectionId: "conn-id") +ASSERT message.connectionId == "conn-id" + +# TM2f - timestamp attribute +message = Message(timestamp: 1234567890000) +ASSERT message.timestamp == 1234567890000 + +# TM2g - encoding attribute +message = Message(encoding: "json/base64") +ASSERT message.encoding == "json/base64" + +# TM2h - extras attribute +message = Message(extras: { + "push": { "notification": { "title": "Hello" } } +}) +ASSERT message.extras["push"]["notification"]["title"] == "Hello" + +# TM2i - serial attribute (server-assigned) +# Serial is typically read-only from server responses +``` + +--- + +## TM3 - fromEncoded / fromEncodedArray + +**Test ID**: `rest/unit/TM3/from-encoded-deserialization-0` + +**Spec requirement (TM3):** `fromEncoded` and `fromEncodedArray` are alternative constructors that take an already-deserialized Message-like object (or array of such), and optionally a `channelOptions`, and return a `Message` (or array of `Messages`) that is decoded and decrypted as specified in RSL6. The idiomatic method name varies by SDK (e.g., `fromEncoded` in JS, `fromJson`/`fromMap` in Dart). + +Tests that `fromEncoded` correctly deserializes wire-format messages. + +### Test Steps +```pseudo +json_data = { + "id": "msg-123", + "name": "test-event", + "data": "hello world", + "clientId": "sender-client", + "connectionId": "conn-456", + "timestamp": 1234567890000, + "encoding": null, + "extras": { "headers": { "x-custom": "value" } } +} + +message = Message.fromEncoded(json_data) + +ASSERT message.id == "msg-123" +ASSERT message.name == "test-event" +ASSERT message.data == "hello world" +ASSERT message.clientId == "sender-client" +ASSERT message.connectionId == "conn-456" +ASSERT message.timestamp == 1234567890000 +ASSERT message.extras["headers"]["x-custom"] == "value" +``` + +--- + +## TM3 - fromEncoded decodes encoding field + +**Test ID**: `rest/unit/TM3/from-encoded-decodes-encoding-1` + +**Spec requirement (TM3):** `fromEncoded` decodes data based on the `encoding` field, with any residual transforms left in the `encoding` property per RSL6b. + +Tests that `fromEncoded` correctly handles encoded data during deserialization. + +### Test Cases + +| ID | Encoding | Wire Data | Expected Data | +|----|----------|-----------|---------------| +| 1 | `null` | `"plain text"` | `"plain text"` | +| 2 | `"json"` | `"{\"key\":\"value\"}"` | `{ "key": "value" }` | +| 3 | `"base64"` | `"SGVsbG8="` | `bytes("Hello")` | +| 4 | `"json/base64"` | `"eyJrIjoidiJ9"` | `{ "k": "v" }` | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + json_data = { + "id": "msg", + "name": "event", + "data": test_case.wire_data, + "encoding": test_case.encoding + } + + message = Message.fromEncoded(json_data) + + ASSERT message.data == test_case.expected_data + ASSERT message.encoding IS null # Encoding consumed +``` + +--- + +## TM4 - Message constructors + +**Test ID**: `rest/unit/TM4/message-constructors-0` + +**Spec requirement (TM4):** `Message` has constructors `constructor(name: String?, data: Data?)` and `constructor(name: String?, data: Data?, clientId: String?)`. + +Tests that `Message` can be constructed with the specified signatures. + +### Test Steps +```pseudo +# constructor(name, data) +message = Message(name: "event-name", data: "payload") +ASSERT message.name == "event-name" +ASSERT message.data == "payload" +ASSERT message.clientId IS null OR message.clientId IS undefined + +# constructor(name, data, clientId) +message = Message(name: "event-name", data: "payload", clientId: "client-1") +ASSERT message.name == "event-name" +ASSERT message.data == "payload" +ASSERT message.clientId == "client-1" + +# Both name and data are nullable +message = Message(name: null, data: null) +ASSERT message.name IS null OR message.name IS undefined +ASSERT message.data IS null OR message.data IS undefined +``` + +--- + +## TM - Null/missing attributes + +**Test ID**: `rest/unit/TM/null-missing-attributes-0` + +**Spec requirement:** Message type must handle null or missing optional attributes correctly. + +Tests that null or missing attributes are handled correctly. + +### Test Steps +```pseudo +# Minimal message +message = Message() + +# All optional attributes should be null/undefined +ASSERT message.id IS null OR message.id IS undefined +ASSERT message.name IS null OR message.name IS undefined +ASSERT message.data IS null OR message.data IS undefined +ASSERT message.clientId IS null OR message.clientId IS undefined +ASSERT message.timestamp IS null OR message.timestamp IS undefined +``` + +--- + +## TM - Message with extras + +**Test ID**: `rest/unit/TM/message-with-extras-1` + +**Spec requirement:** Message extras field must support arbitrary metadata including push notification configuration (TM2h). + +Tests that Message extras (push notifications, etc.) are handled correctly. + +### Test Steps +```pseudo +# Push notification extras +message = Message( + name: "push-event", + data: "payload", + extras: { + "push": { + "notification": { + "title": "New Message", + "body": "You have a new notification" + }, + "data": { + "customKey": "customValue" + } + } + } +) + +ASSERT message.extras["push"]["notification"]["title"] == "New Message" +ASSERT message.extras["push"]["data"]["customKey"] == "customValue" +``` diff --git a/uts/rest/unit/types/mutable_message_types.md b/uts/rest/unit/types/mutable_message_types.md new file mode 100644 index 000000000..31c385ffc --- /dev/null +++ b/uts/rest/unit/types/mutable_message_types.md @@ -0,0 +1,314 @@ +# Mutable Message Type Tests + +Spec points: `TM2j`, `TM2r`, `TM2s`, `TM2s1`, `TM2s2`, `TM2s3`, `TM2s4`, `TM2s5`, `TM2u`, `TM5`, `TM8`, `TM8a`, `MOP2a`, `MOP2b`, `MOP2c`, `UDR1`, `UDR2`, `UDR2a`, `TAN1`, `TAN2`, `TAN2a`–`TAN2l` + +## Test Type +Unit test (no mocking needed — pure type construction and serialization) + +--- + +## TM5 — MessageAction enum values + +**Test ID**: `rest/unit/TM5/message-action-enum-values-0` + +**Spec requirement:** TM5 — `Message` `Action` enum has the following values in order from zero: `MESSAGE_CREATE`, `MESSAGE_UPDATE`, `MESSAGE_DELETE`, `META`, `MESSAGE_SUMMARY`, `MESSAGE_APPEND`. + +Tests that the `MessageAction` enum has the correct numeric values for wire serialization. + +### Assertions +```pseudo +ASSERT MessageAction.MESSAGE_CREATE.toInt() == 0 +ASSERT MessageAction.MESSAGE_UPDATE.toInt() == 1 +ASSERT MessageAction.MESSAGE_DELETE.toInt() == 2 +ASSERT MessageAction.META.toInt() == 3 +ASSERT MessageAction.MESSAGE_SUMMARY.toInt() == 4 +ASSERT MessageAction.MESSAGE_APPEND.toInt() == 5 + +# Round-trip from int +ASSERT MessageAction.fromInt(0) == MessageAction.MESSAGE_CREATE +ASSERT MessageAction.fromInt(5) == MessageAction.MESSAGE_APPEND +``` + +--- + +## TM2j, TM2r — Message has action and serial fields + +**Test ID**: `rest/unit/TM2j/action-and-serial-fields-0` + +| Spec | Requirement | +|------|-------------| +| TM2j | `action` enum | +| TM2r | `serial` string — an opaque string that uniquely identifies the message | + +Tests that `Message` supports `action` and `serial` fields, and that `toJson()` serializes `action` as a numeric value. + +### Test Steps +```pseudo +msg = Message( + name: "test", + data: "hello", + serial: "serial-1", + action: MessageAction.MESSAGE_UPDATE +) +``` + +### Assertions +```pseudo +ASSERT msg.serial == "serial-1" +ASSERT msg.action == MessageAction.MESSAGE_UPDATE + +json_data = msg.toJson() +ASSERT json_data["serial"] == "serial-1" +ASSERT json_data["action"] == 1 # Numeric wire value for MESSAGE_UPDATE +ASSERT json_data["name"] == "test" +ASSERT json_data["data"] == "hello" +``` + +--- + +## TM2s — Message.version populated from wire + +**Test ID**: `rest/unit/TM2s/version-populated-from-wire-0` + +| Spec | Requirement | +|------|-------------| +| TM2s | `version` is an object containing information about the latest version of a message | +| TM2s1 | `serial` — an opaque string that identifies the specific version | +| TM2s2 | `timestamp` — time in milliseconds since epoch | +| TM2s3 | `clientId` — string | +| TM2s4 | `description` — string | +| TM2s5 | `metadata` — Dict | + +Tests that `Message.fromJson()` correctly parses the `version` object with all fields. + +### Test Steps +```pseudo +msg = Message.fromJson({ + "serial": "msg-serial-1", + "name": "test", + "data": "hello", + "version": { + "serial": "version-serial-1", + "timestamp": 1700000001000, + "clientId": "editor-1", + "description": "fixed typo", + "metadata": { "reason": "typo", "tool": "editor" } + } +}) +``` + +### Assertions +```pseudo +ASSERT msg.version IS NOT null +ASSERT msg.version IS MessageVersion +ASSERT msg.version.serial == "version-serial-1" +ASSERT msg.version.timestamp == 1700000001000 +ASSERT msg.version.clientId == "editor-1" +ASSERT msg.version.description == "fixed typo" +ASSERT msg.version.metadata["reason"] == "typo" +ASSERT msg.version.metadata["tool"] == "editor" +``` + +--- + +## TM2s1, TM2s2 — Message.version defaults when not on wire + +**Test ID**: `rest/unit/TM2s1/version-defaults-from-message-0` + +| Spec | Requirement | +|------|-------------| +| TM2s | If a message does not contain a `version` object the SDK must initialize one and set a subset of fields | +| TM2s1 | If `version.serial` is not received, must be set to the `TM2r` `serial`, if set | +| TM2s2 | If `version.timestamp` is not received, must be set to the `TM2f` `timestamp`, if set | + +Tests that when `version` is absent from the wire, the SDK initializes it with defaults from `serial` and `timestamp`. + +### Test Steps +```pseudo +msg = Message.fromJson({ + "serial": "msg-serial-1", + "timestamp": 1700000000000, + "name": "test", + "data": "hello" +}) +``` + +### Assertions +```pseudo +# version must be initialized even though not on wire +ASSERT msg.version IS NOT null +ASSERT msg.version IS MessageVersion + +# TM2s1: version.serial defaults to message serial +ASSERT msg.version.serial == "msg-serial-1" + +# TM2s2: version.timestamp defaults to message timestamp +ASSERT msg.version.timestamp == 1700000000000 + +# Other fields should be null +ASSERT msg.version.clientId IS null +ASSERT msg.version.description IS null +ASSERT msg.version.metadata IS null +``` + +--- + +## TM2u, TM8a — Message.annotations defaults to empty + +**Test ID**: `rest/unit/TM2u/annotations-defaults-empty-0` + +| Spec | Requirement | +|------|-------------| +| TM2u | `annotations` is an object of type `MessageAnnotations`. If not set on the wire, the SDK must set it to an empty `MessageAnnotations` object | +| TM8a | `summary` `Dict` — a missing `summary` field indicates an empty summary | + +Tests that `annotations` is initialized to an empty `MessageAnnotations` when not present on the wire. + +### Test Steps +```pseudo +msg = Message.fromJson({ + "serial": "msg-serial-1", + "name": "test" +}) +``` + +### Assertions +```pseudo +ASSERT msg.annotations IS NOT null +ASSERT msg.annotations IS MessageAnnotations +ASSERT msg.annotations.summary IS NOT null +ASSERT msg.annotations.summary IS empty # No keys +``` + +--- + +## MOP2a–c — MessageOperation fields + +**Test ID**: `rest/unit/MOP2a/message-operation-fields-0` + +| Spec | Requirement | +|------|-------------| +| MOP2a | `clientId?: String` | +| MOP2b | `description?: String` | +| MOP2c | `metadata?: Dict` | + +Tests that `MessageOperation` can be constructed with all optional fields and that `toJson()` serializes correctly. + +### Test Steps +```pseudo +op = MessageOperation( + clientId: "user-1", + description: "edit description", + metadata: { "reason": "typo", "tool": "editor" } +) +``` + +### Assertions +```pseudo +ASSERT op.clientId == "user-1" +ASSERT op.description == "edit description" +ASSERT op.metadata["reason"] == "typo" +ASSERT op.metadata["tool"] == "editor" + +# Serialization +json_data = op.toJson() +ASSERT json_data["clientId"] == "user-1" +ASSERT json_data["description"] == "edit description" +ASSERT json_data["metadata"]["reason"] == "typo" + +# All-null construction +empty_op = MessageOperation() +ASSERT empty_op.clientId IS null +ASSERT empty_op.description IS null +ASSERT empty_op.metadata IS null + +empty_json = empty_op.toJson() +ASSERT "clientId" NOT IN empty_json +ASSERT "description" NOT IN empty_json +ASSERT "metadata" NOT IN empty_json +``` + +--- + +## UDR2a — UpdateDeleteResult fields + +**Test ID**: `rest/unit/UDR2a/update-delete-result-fields-0` + +| Spec | Requirement | +|------|-------------| +| UDR1 | Contains the result of an update or delete message operation | +| UDR2a | `versionSerial` `String?` — the new version serial string | + +Tests that `UpdateDeleteResult` can be constructed from a response map. + +### Assertions +```pseudo +# Non-null versionSerial +result1 = UpdateDeleteResult.fromJson({ "versionSerial": "version-serial-abc" }) +ASSERT result1 IS UpdateDeleteResult +ASSERT result1.versionSerial == "version-serial-abc" + +# Null versionSerial (message superseded) +result2 = UpdateDeleteResult.fromJson({ "versionSerial": null }) +ASSERT result2.versionSerial IS null + +# Missing versionSerial key treated as null +result3 = UpdateDeleteResult.fromJson({}) +ASSERT result3.versionSerial IS null +``` + +--- + +## TAN2 — Annotation type attributes and action encoding + +**Test ID**: `rest/unit/TAN2/annotation-attributes-and-action-0` + +| Spec | Requirement | +|------|-------------| +| TAN1 | An `Annotation` represents an individual annotation event | +| TAN2a | `id` string | +| TAN2b | `action` enum: `ANNOTATION_CREATE` (0), `ANNOTATION_DELETE` (1) | +| TAN2b1 | In wire protocol action is numeric; SDK exposes as enum | +| TAN2c–TAN2l | Various string, number, and object fields | + +Tests that `Annotation.fromJson()` decodes all fields and that `AnnotationAction` enum has correct numeric values. + +### Test Steps +```pseudo +ann = Annotation.fromJson({ + "id": "ann-id-1", + "action": 0, + "clientId": "user-1", + "name": "like", + "count": 5, + "data": "thumbs-up", + "encoding": null, + "timestamp": 1700000000000, + "serial": "ann-serial-1", + "messageSerial": "msg-serial-1", + "type": "com.example.reaction", + "extras": { "custom": "metadata" } +}) +``` + +### Assertions +```pseudo +ASSERT ann IS Annotation +ASSERT ann.id == "ann-id-1" +ASSERT ann.action == AnnotationAction.ANNOTATION_CREATE +ASSERT ann.clientId == "user-1" +ASSERT ann.name == "like" +ASSERT ann.count == 5 +ASSERT ann.data == "thumbs-up" +ASSERT ann.timestamp == 1700000000000 +ASSERT ann.serial == "ann-serial-1" +ASSERT ann.messageSerial == "msg-serial-1" +ASSERT ann.type == "com.example.reaction" +ASSERT ann.extras["custom"] == "metadata" + +# AnnotationAction numeric values +ASSERT AnnotationAction.ANNOTATION_CREATE.toInt() == 0 +ASSERT AnnotationAction.ANNOTATION_DELETE.toInt() == 1 +ASSERT AnnotationAction.fromInt(0) == AnnotationAction.ANNOTATION_CREATE +ASSERT AnnotationAction.fromInt(1) == AnnotationAction.ANNOTATION_DELETE +``` diff --git a/uts/rest/unit/types/options_types.md b/uts/rest/unit/types/options_types.md new file mode 100644 index 000000000..a32917875 --- /dev/null +++ b/uts/rest/unit/types/options_types.md @@ -0,0 +1,307 @@ +# Options Types Tests + +Spec points: `TO1`, `TO2`, `TO3`, `AO1`, `AO2` + +## Test Type +Unit test - pure type/model validation + +## Mock Configuration +No mocks required - these verify type structure and defaults. + +--- + +## TO3 - ClientOptions attributes + +**Test ID**: `rest/unit/TO3/client-options-attributes-0` + +**Spec requirement:** ClientOptions type must provide all configuration attributes with correct defaults according to TO3 specification. + +Tests that `ClientOptions` has all REST-relevant attributes with correct defaults. + +### Test Cases - Required Attributes + +| ID | Attribute | Type | Default | +|----|-----------|------|---------| +| 1 | `key` | String | (none) | +| 2 | `token` | String | (none) | +| 3 | `tokenDetails` | TokenDetails | (none) | +| 4 | `authCallback` | Function | (none) | +| 5 | `authUrl` | String | (none) | +| 6 | `authMethod` | String | `"GET"` | +| 7 | `authHeaders` | Map | (empty) | +| 8 | `authParams` | Map | (empty) | +| 9 | `clientId` | String | (none) | +| 10 | `endpoint` | String | (none - uses production) | +| 11 | `restHost` | String | `"rest.ably.io"` | +| 12 | `fallbackHosts` | List | (default fallback hosts) | +| 13 | `tls` | Boolean | `true` | +| 14 | `httpRequestTimeout` | Integer | `10000` (10 seconds) | +| 15 | `httpMaxRetryCount` | Integer | `3` | +| 16 | `httpMaxRetryDuration` | Integer | `15000` (15 seconds) | +| 17 | `fallbackRetryTimeout` | Integer | `600000` (10 minutes) | +| 18 | `useBinaryProtocol` | Boolean | `true` | +| 19 | `idempotentRestPublishing` | Boolean | `true` | +| 20 | `addRequestIds` | Boolean | `false` | +| 21 | `queryTime` | Boolean | `false` | +| 22 | `maxMessageSize` | Integer | `65536` (64KB) | +| 23 | `defaultTokenParams` | TokenParams | (none) | + +### Test Steps - Defaults +```pseudo +options = ClientOptions() + +ASSERT options.authMethod == "GET" +ASSERT options.tls == true +ASSERT options.httpRequestTimeout == 10000 +ASSERT options.httpMaxRetryCount == 3 +ASSERT options.useBinaryProtocol == true +ASSERT options.idempotentRestPublishing == true +ASSERT options.addRequestIds == false +ASSERT options.queryTime == false +ASSERT options.maxMessageSize == 65536 +``` + +### Test Steps - Setting Values +```pseudo +options = ClientOptions( + key: "appId.keyId:keySecret", + clientId: "my-client", + endpoint: "test", + tls: false, + httpRequestTimeout: 30000, + useBinaryProtocol: false, + idempotentRestPublishing: false, + addRequestIds: true +) + +ASSERT options.key == "appId.keyId:keySecret" +ASSERT options.clientId == "my-client" +ASSERT options.endpoint == "test" +ASSERT options.tls == false +ASSERT options.httpRequestTimeout == 30000 +ASSERT options.useBinaryProtocol == false +ASSERT options.idempotentRestPublishing == false +ASSERT options.addRequestIds == true +``` + +--- + +## TO3 - ClientOptions with custom hosts + +**Test ID**: `rest/unit/TO3/client-options-custom-hosts-1` + +**Spec requirement:** ClientOptions must support custom host configuration including restHost and fallbackHosts. + +Tests custom host configuration. + +### Test Steps +```pseudo +options = ClientOptions( + key: "appId.keyId:keySecret", + restHost: "custom.ably.example.com", + fallbackHosts: ["fallback1.example.com", "fallback2.example.com"] +) + +ASSERT options.restHost == "custom.ably.example.com" +ASSERT options.fallbackHosts == ["fallback1.example.com", "fallback2.example.com"] +``` + +--- + +## TO3 - ClientOptions with auth URL + +**Test ID**: `rest/unit/TO3/client-options-auth-url-2` + +**Spec requirement:** ClientOptions must support authUrl configuration with customizable HTTP method, headers, and parameters. + +Tests auth URL configuration. + +### Test Steps +```pseudo +options = ClientOptions( + authUrl: "https://auth.example.com/token", + authMethod: "POST", + authHeaders: { "X-API-Key": "secret" }, + authParams: { "scope": "full" } +) + +ASSERT options.authUrl == "https://auth.example.com/token" +ASSERT options.authMethod == "POST" +ASSERT options.authHeaders["X-API-Key"] == "secret" +ASSERT options.authParams["scope"] == "full" +``` + +--- + +## TO3 - ClientOptions with defaultTokenParams + +**Test ID**: `rest/unit/TO3/client-options-default-token-params-3` + +**Spec requirement:** ClientOptions must support defaultTokenParams for specifying default token request parameters. + +Tests default token parameters configuration. + +### Test Steps +```pseudo +options = ClientOptions( + key: "appId.keyId:keySecret", + defaultTokenParams: TokenParams( + ttl: 7200000, + clientId: "default-client", + capability: "{\"*\":[\"subscribe\"]}" + ) +) + +ASSERT options.defaultTokenParams.ttl == 7200000 +ASSERT options.defaultTokenParams.clientId == "default-client" +ASSERT options.defaultTokenParams.capability == "{\"*\":[\"subscribe\"]}" +``` + +--- + +## AO2 - AuthOptions attributes + +**Test ID**: `rest/unit/AO2/auth-options-attributes-0` + +**Spec requirement:** AuthOptions type must provide all authentication-related attributes according to AO2 specification. + +| Spec | Attribute | Description | +|------|-----------|-------------| +| AO2 | key | API key for authentication | +| AO2 | token | Token string for authentication | +| AO2 | tokenDetails | TokenDetails object for authentication | +| AO2 | authCallback | Callback function for token generation | +| AO2 | authUrl | URL for token requests | +| AO2 | authMethod | HTTP method for authUrl requests | +| AO2 | authHeaders | Headers for authUrl requests | +| AO2 | authParams | Parameters for authUrl requests | +| AO2 | queryTime | Whether to query server time | + +Tests that `AuthOptions` has all required attributes. + +### Test Cases + +| ID | Attribute | Type | +|----|-----------|------| +| 1 | `key` | String | +| 2 | `token` | String | +| 3 | `tokenDetails` | TokenDetails | +| 4 | `authCallback` | Function | +| 5 | `authUrl` | String | +| 6 | `authMethod` | String | +| 7 | `authHeaders` | Map | +| 8 | `authParams` | Map | +| 9 | `queryTime` | Boolean | + +### Test Steps +```pseudo +auth_options = AuthOptions( + authUrl: "https://auth.example.com/token", + authMethod: "POST", + authHeaders: { "Authorization": "Bearer api-key" }, + authParams: { "user": "test" }, + queryTime: true +) + +ASSERT auth_options.authUrl == "https://auth.example.com/token" +ASSERT auth_options.authMethod == "POST" +ASSERT auth_options.authHeaders["Authorization"] == "Bearer api-key" +ASSERT auth_options.authParams["user"] == "test" +ASSERT auth_options.queryTime == true +``` + +--- + +## AO - AuthOptions with authCallback + +**Test ID**: `rest/unit/AO/auth-options-with-callback-0` + +**Spec requirement:** AuthOptions must support authCallback function for custom token generation logic. + +Tests that `AuthOptions` can hold an authCallback function. + +### Test Steps +```pseudo +callback_called = false + +test_callback = (params) => { + callback_called = true + RETURN TokenDetails(token: "callback-token", expires: now() + 3600000) +} + +auth_options = AuthOptions(authCallback: test_callback) + +# Verify callback is stored and callable +result = auth_options.authCallback(TokenParams()) +ASSERT callback_called == true +ASSERT result.token == "callback-token" +``` + +--- + +## TO - Endpoint affects host selection + +**Test ID**: `rest/unit/TO/endpoint-affects-host-0` + +**Spec requirement:** The endpoint option must affect host selection for REST and Realtime connections. + +Tests that endpoint option affects default hosts. + +### Test Cases + +| ID | Endpoint | Expected Rest Host | +|----|----------|--------------------| +| 1 | (none/production) | `rest.ably.io` | +| 2 | `"test"` | `test-rest.ably.io` | +| 3 | `"custom-env"` | `custom-env-rest.ably.io` | + +### Note +The actual host resolution may be tested at the HTTP client level. This test verifies the option is stored correctly. + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + IF test_case.endpoint IS none: + options = ClientOptions(key: "appId.keyId:keySecret") + ELSE: + options = ClientOptions( + key: "appId.keyId:keySecret", + endpoint: test_case.endpoint + ) + + ASSERT options.endpoint == test_case.endpoint +``` + +--- + +## TO - Conflicting options validation + +**Test ID**: `rest/unit/TO/conflicting-options-validation-1` + +**Spec requirement:** ClientOptions must validate and detect conflicting configuration options. + +Tests that conflicting options are detected. + +### Test Cases + +| ID | Options | Expected | +|----|---------|----------| +| 1 | `key` + `authCallback` | Valid (authCallback takes precedence) | +| 2 | `restHost` + `endpoint` | Invalid (conflict) | +| 3 | (no auth options) | Invalid | + +### Test Steps (Case 2 - Conflicting hosts) +```pseudo +ClientOptions( + key: "appId.keyId:keySecret", + restHost: "custom.host.com", + endpoint: "test" +) FAILS WITH error +ASSERT error.message CONTAINS "restHost" OR error.message CONTAINS "endpoint" +``` + +### Test Steps (Case 3 - No auth) +```pseudo +Rest(options: ClientOptions()) FAILS WITH error +ASSERT error.message CONTAINS "auth" OR error.message CONTAINS "key" OR error.message CONTAINS "token" +``` diff --git a/uts/rest/unit/types/paginated_result.md b/uts/rest/unit/types/paginated_result.md new file mode 100644 index 000000000..e53711d7c --- /dev/null +++ b/uts/rest/unit/types/paginated_result.md @@ -0,0 +1,733 @@ +# PaginatedResult Types Tests + +Spec points: `TG1`, `TG2`, `TG3`, `TG4` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure described in `/Users/paddy/data/worknew/dev/dart-experiments/uts/test/rest/unit/rest_client.md`. + +The mock supports: +- Intercepting HTTP requests and capturing details (URL, headers, method, body) +- Queueing responses with configurable status, headers, and body +- Handler-based configuration with `onConnectionAttempt` and `onRequest` +- Recording requests in `captured_requests` arrays +- Request counting with `request_count` variables + +--- + +## TG1 - PaginatedResult items attribute + +**Test ID**: `rest/unit/TG1/paginated-result-items-0` + +**Spec requirement:** `PaginatedResult` must contain an `items` array with the result data. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { "id": "item1", "name": "e1", "data": "d1" }, + { "id": "item2", "name": "e2", "data": "d2" } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +result = AWAIT channel.history() +``` + +### Assertions +```pseudo +ASSERT result.items IS List +ASSERT result.items.length == 2 +ASSERT result.items[0].id == "item1" +ASSERT result.items[1].id == "item2" +``` + +--- + +## TG2 - hasNext() and isLast() methods + +**Test ID**: `rest/unit/TG2/has-next-is-last-0` + +**Spec requirement:** `PaginatedResult` must provide `hasNext()` and `isLast()` methods to indicate pagination state. + +### Test Case 1: Has more pages + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, + body: [{ "id": "item1" }], + headers: { + "Link": "; rel=\"next\"" + } + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +result = AWAIT channel.history() +``` + +### Assertions +```pseudo +ASSERT result.hasNext() == true +ASSERT result.isLast() == false +``` + +--- + +### Test Case 2: No more pages + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, + body: [{ "id": "item1" }], + headers: {} # No Link header for next + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +result = AWAIT channel.history() +``` + +### Assertions +```pseudo +ASSERT result.hasNext() == false +ASSERT result.isLast() == true +``` + +--- + +## TG3 - next() method + +**Test ID**: `rest/unit/TG3/next-fetches-next-page-0` + +**Spec requirement:** The `next()` method must fetch the next page using the URL from the Link header. + +### Setup +```pseudo +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + request_count++ + + IF request_count == 1: + # First page + req.respond_with(200, + body: [{ "id": "page1-item1" }, { "id": "page1-item2" }], + headers: { + "Link": "; rel=\"next\"" + } + ) + ELSE: + # Second page + req.respond_with(200, + body: [{ "id": "page2-item1" }], + headers: {} # Last page + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +page1 = AWAIT channel.history() +page2 = AWAIT page1.next() +``` + +### Assertions +```pseudo +# First page +ASSERT page1.items.length == 2 +ASSERT page1.items[0].id == "page1-item1" +ASSERT page1.hasNext() == true + +# Second page +ASSERT page2.items.length == 1 +ASSERT page2.items[0].id == "page2-item1" +ASSERT page2.hasNext() == false + +# Verify next request used cursor from Link header +next_request = captured_requests[1] +ASSERT "cursor" IN next_request.url.query_params +ASSERT next_request.url.query_params["cursor"] == "abc123" +``` + +--- + +## TG4 - first() method + +**Test ID**: `rest/unit/TG4/first-returns-first-page-0` + +**Spec requirement:** The `first()` method must return to the first page using the URL from the Link header's `rel="first"` link. + +### Setup +```pseudo +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + request_count++ + + IF request_count == 1: + # Initial request + req.respond_with(200, + body: [{ "id": "item1" }], + headers: { + "Link": "; rel=\"next\", ; rel=\"first\"" + } + ) + ELSE IF request_count == 2: + # Next page + req.respond_with(200, + body: [{ "id": "item2" }], + headers: { + "Link": "; rel=\"first\"" + } + ) + ELSE: + # First page again + req.respond_with(200, + body: [{ "id": "item1" }], + headers: { + "Link": "; rel=\"next\"" + } + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +page1 = AWAIT channel.history() +page2 = AWAIT page1.next() +first_page = AWAIT page2.first() +``` + +### Assertions +```pseudo +ASSERT first_page.items[0].id == "item1" +``` + +--- + +## TG - Empty result + +**Test ID**: `rest/unit/TG/empty-result-handling-0` + +**Spec requirement:** Empty results must be handled correctly with an empty `items` array. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, + body: [], + headers: {} + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +result = AWAIT channel.history() +``` + +### Assertions +```pseudo +ASSERT result.items IS List +ASSERT result.items.length == 0 +ASSERT result.hasNext() == false +ASSERT result.isLast() == true +``` + +--- + +## TG - Link header parsing + +**Test ID**: `rest/unit/TG/link-header-parsing-1` + +**Spec requirement:** Various Link header formats must be correctly parsed to determine pagination state and next page URLs. + +### Test Cases + +| ID | Link Header | Expected hasNext | Expected cursor | +|----|-------------|------------------|-----------------| +| 1 | `; rel="next"` | true | `"abc"` | +| 2 | `; rel="next", ; rel="first"` | true | `"abc"` | +| 3 | `; rel="first"` | false | (none) | +| 4 | (empty) | false | (none) | + +### Setup and Execution +```pseudo +FOR EACH test_case IN test_cases: + captured_requests = [] + + mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + + IF test_case.link_header IS NOT empty: + req.respond_with(200, + body: [{ "id": "item" }], + headers: { "Link": test_case.link_header } + ) + ELSE: + req.respond_with(200, + body: [{ "id": "item" }], + headers: {} + ) + } + ) + install_mock(mock_http) + + client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + result = AWAIT client.channels.get("test").history() + + ASSERT result.hasNext() == test_case.expected_hasNext +``` + +--- + +## TG - PaginatedResult type parameter + +**Test ID**: `rest/unit/TG/type-parameter-items-2` + +**Spec requirement:** `PaginatedResult` must correctly type its items to the expected type `T`. + +### Note +This is primarily a compile-time/type-system verification for strongly-typed languages. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { "id": "msg1", "name": "event", "data": "test" } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +# History returns PaginatedResult +history_result = AWAIT channel.history() +ASSERT history_result.items[0] IS Message + +# If the language supports generics, verify: +# PaginatedResult cannot be assigned to PaginatedResult +``` + +--- + +## TG - next() on last page + +**Test ID**: `rest/unit/TG/next-on-last-page-3` + +**Spec requirement:** Calling `next()` on the last page must handle gracefully (return null, empty result, or throw). + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, + body: [{ "id": "item" }], + headers: {} # No next link + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +result = AWAIT channel.history() +ASSERT result.isLast() == true + +next_result = AWAIT result.next() +``` + +### Assertions +```pseudo +# Implementation may either: +# 1. Return null +# 2. Return empty PaginatedResult +# 3. Throw an exception + +ASSERT next_result IS null OR next_result.items.length == 0 +``` + +--- + +## TG - Pagination preserves authentication + +**Test ID**: `rest/unit/TG/pagination-preserves-auth-4` + +**Spec requirement:** Pagination requests must include the same authentication credentials as the initial request. + +### Setup +```pseudo +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + request_count++ + + IF request_count == 1: + req.respond_with(200, + body: [{ "id": "item1" }], + headers: { + "Link": "; rel=\"next\"" + } + ) + ELSE: + req.respond_with(200, body: [{ "id": "item2" }]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +page1 = AWAIT channel.history() +page2 = AWAIT page1.next() +``` + +### Assertions +```pseudo +# Both requests should have Authorization header +ASSERT "Authorization" IN captured_requests[0].headers +ASSERT "Authorization" IN captured_requests[1].headers +ASSERT captured_requests[0].headers["Authorization"] == captured_requests[1].headers["Authorization"] +``` + +--- + +## TG - Pagination with relative URLs + +**Test ID**: `rest/unit/TG/pagination-relative-urls-5` + +**Spec requirement:** Link headers with relative URLs must be resolved relative to the base REST host. + +### Setup +```pseudo +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + request_count++ + + IF request_count == 1: + req.respond_with(200, + body: [{ "id": "item1" }], + headers: { + "Link": "; rel=\"next\"" + } + ) + ELSE: + req.respond_with(200, body: [{ "id": "item2" }]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + restHost: "rest.ably.io" +)) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +page1 = AWAIT channel.history() +page2 = AWAIT page1.next() +``` + +### Assertions +```pseudo +# Second request should use full URL +ASSERT captured_requests[1].url.host == "rest.ably.io" +ASSERT captured_requests[1].url.path == "/channels/test/messages" +ASSERT "page" IN captured_requests[1].url.query_params +``` + +--- + +## TG - Multiple Link relations + +**Test ID**: `rest/unit/TG/multiple-link-relations-6` + +**Spec requirement:** Link headers may contain multiple relations (next, first, last) which must all be parsed correctly. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, + body: [{ "id": "item1" }], + headers: { + "Link": "; rel=\"next\", ; rel=\"first\", ; rel=\"last\"" + } + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +result = AWAIT channel.history() +``` + +### Assertions +```pseudo +ASSERT result.hasNext() == true +# Implementation should be able to navigate to next, first, or last pages +``` + +--- + +## TG - Pagination with presence results + +**Test ID**: `rest/unit/TG/pagination-presence-results-7` + +**Spec requirement:** Pagination must work identically for presence results as it does for message results. + +### Setup +```pseudo +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + request_count++ + + IF request_count == 1: + req.respond_with(200, + body: [{ "action": 1, "clientId": "client1" }], + headers: { + "Link": "; rel=\"next\"" + } + ) + ELSE: + req.respond_with(200, body: [{ "action": 1, "clientId": "client2" }]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +page1 = AWAIT channel.presence.get() +page2 = AWAIT page1.next() +``` + +### Assertions +```pseudo +ASSERT page1 IS PaginatedResult +ASSERT page1.items[0].clientId == "client1" +ASSERT page2.items[0].clientId == "client2" +``` + +--- + +## TG - Pagination includes request headers + +**Test ID**: `rest/unit/TG/pagination-includes-headers-8` + +**Spec requirement:** Pagination requests must include all standard Ably headers (X-Ably-Version, Ably-Agent, etc.). + +### Setup +```pseudo +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + request_count++ + + IF request_count == 1: + req.respond_with(200, + body: [{ "id": "item1" }], + headers: { + "Link": "; rel=\"next\"" + } + ) + ELSE: + req.respond_with(200, body: [{ "id": "item2" }]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +page1 = AWAIT channel.history() +page2 = AWAIT page1.next() +``` + +### Assertions +```pseudo +# Check headers on pagination request +next_request = captured_requests[1] +ASSERT "X-Ably-Version" IN next_request.headers +ASSERT "Ably-Agent" IN next_request.headers +ASSERT next_request.headers["Ably-Agent"] contains "ably-" +``` + +--- + +## TG - Error handling on next() + +**Test ID**: `rest/unit/TG/error-handling-on-next-9` + +**Spec requirement:** Errors during pagination (e.g., 404, 500) must be raised as `AblyException`. + +### Setup +```pseudo +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + request_count++ + + IF request_count == 1: + req.respond_with(200, + body: [{ "id": "item1" }], + headers: { + "Link": "; rel=\"next\"" + } + ) + ELSE: + req.respond_with(404, { + "error": { + "code": 40400, + "statusCode": 404, + "message": "Not found" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +page1 = AWAIT channel.history() + +AWAIT page1.next() FAILS WITH error +ASSERT error.statusCode == 404 +ASSERT error.code == 40400 +``` diff --git a/uts/rest/unit/types/presence_message_types.md b/uts/rest/unit/types/presence_message_types.md new file mode 100644 index 000000000..47596367b --- /dev/null +++ b/uts/rest/unit/types/presence_message_types.md @@ -0,0 +1,365 @@ +# PresenceMessage Types Tests + +Spec points: `TP1`, `TP2`, `TP3`, `TP3a`, `TP3b`, `TP3c`, `TP3d`, `TP3e`, `TP3f`, `TP3g`, `TP3h`, `TP3i`, `TP4`, `TP5` + +## Test Type +Unit test — pure type/model validation, no mocks required. + +--- + +## TP2 - PresenceAction enum values + +**Test ID**: `rest/unit/TP2/presence-action-enum-values-0` + +**Spec requirement:** PresenceMessage Action enum has the following values in order +from zero: ABSENT, PRESENT, ENTER, LEAVE, UPDATE. + +### Test Steps +```pseudo +ASSERT PresenceAction.absent.index == 0 +ASSERT PresenceAction.present.index == 1 +ASSERT PresenceAction.enter.index == 2 +ASSERT PresenceAction.leave.index == 3 +ASSERT PresenceAction.update.index == 4 +``` + +--- + +## TP3a-TP3i - PresenceMessage attributes + +**Test ID**: `rest/unit/TP3a/presence-message-attributes-0` + +**Spec requirement:** PresenceMessage type must provide all required attributes. + +| Spec | Attribute | Description | +|------|-----------|-------------| +| TP3a | id | Unique presence message identifier | +| TP3b | action | PresenceAction enum | +| TP3c | clientId | Client ID of the member | +| TP3d | connectionId | Connection ID of the member | +| TP3e | data | Payload (string, object, or binary) | +| TP3f | encoding | Encoding information for data | +| TP3g | timestamp | Timestamp in milliseconds since epoch | +| TP3h | memberKey | String combining connectionId and clientId | +| TP3i | extras | JSON-encodable key-value pairs | + +### Test Steps +```pseudo +# TP3a - id attribute +msg = PresenceMessage(id: "presence-123") +ASSERT msg.id == "presence-123" + +# TP3b - action attribute +msg = PresenceMessage(action: ENTER) +ASSERT msg.action == ENTER + +# TP3c - clientId attribute +msg = PresenceMessage(clientId: "user-1") +ASSERT msg.clientId == "user-1" + +# TP3d - connectionId attribute +msg = PresenceMessage(connectionId: "conn-1") +ASSERT msg.connectionId == "conn-1" + +# TP3e - data attribute (string) +msg = PresenceMessage(data: "hello") +ASSERT msg.data == "hello" + +# TP3e - data attribute (object) +msg = PresenceMessage(data: { "status": "online" }) +ASSERT msg.data == { "status": "online" } + +# TP3f - encoding attribute +msg = PresenceMessage(encoding: "json") +ASSERT msg.encoding == "json" + +# TP3g - timestamp attribute +msg = PresenceMessage(timestamp: 1234567890000) +ASSERT msg.timestamp == 1234567890000 + +# TP3i - extras attribute +msg = PresenceMessage(extras: { "headers": { "x-custom": "value" } }) +ASSERT msg.extras["headers"]["x-custom"] == "value" +``` + +--- + +## TP3h - memberKey combines connectionId and clientId + +**Test ID**: `rest/unit/TP3h/member-key-combines-ids-0` + +**Spec requirement:** memberKey is a string function that combines the connectionId +and clientId to ensure multiple connected clients with the same clientId are uniquely +identifiable. + +### Test Steps +```pseudo +msg = PresenceMessage(connectionId: "conn-1", clientId: "user-1") +ASSERT msg.memberKey == "conn-1:user-1" + +msg2 = PresenceMessage(connectionId: "conn-2", clientId: "user-1") +ASSERT msg2.memberKey == "conn-2:user-1" + +# Same clientId, different connectionId — different memberKey +ASSERT msg.memberKey != msg2.memberKey +``` + +--- + +## TP3d - connectionId defaults from ProtocolMessage + +**Test ID**: `rest/unit/TP3d/connectionid-from-protocol-message-0` + +**Spec requirement:** If connectionId is not present in a received presence message, +it should be set to the connectionId of the encapsulating ProtocolMessage. + +### Test Steps +```pseudo +protocol_msg = ProtocolMessage( + action: PRESENCE, + connectionId: "proto-conn-1", + presence: [ + { "action": "enter", "clientId": "user-1" } + ] +) + +# After processing, the PresenceMessage should inherit connectionId +presence_msg = protocol_msg.presence[0] +ASSERT presence_msg.connectionId == "proto-conn-1" +``` + +--- + +## TP3a - id defaults from ProtocolMessage + +**Test ID**: `rest/unit/TP3a/id-from-protocol-message-1` + +**Spec requirement:** For Realtime messages without an id, the id should be set to +protocolMsgId:index where index is the 0-based position in the presence array. + +### Test Steps +```pseudo +protocol_msg = ProtocolMessage( + action: PRESENCE, + id: "proto-msg-42", + presence: [ + { "action": "enter", "clientId": "alice" }, + { "action": "enter", "clientId": "bob" } + ] +) + +# After processing, presence messages should have derived ids +ASSERT protocol_msg.presence[0].id == "proto-msg-42:0" +ASSERT protocol_msg.presence[1].id == "proto-msg-42:1" +``` + +--- + +## TP3g - timestamp defaults from ProtocolMessage + +**Test ID**: `rest/unit/TP3g/timestamp-from-protocol-message-0` + +**Spec requirement:** If timestamp is not present in a received presence message, +it should be set to the timestamp of the encapsulating ProtocolMessage. + +### Test Steps +```pseudo +protocol_msg = ProtocolMessage( + action: PRESENCE, + timestamp: 9999999, + presence: [ + { "action": "enter", "clientId": "user-1" } + ] +) + +presence_msg = protocol_msg.presence[0] +ASSERT presence_msg.timestamp == 9999999 +``` + +--- + +## TP3 - PresenceMessage from JSON (wire format) + +**Test ID**: `rest/unit/TP3/presence-from-json-0` + +**Spec requirement:** PresenceMessage must support deserialization from JSON wire format. + +### Test Steps +```pseudo +json_data = { + "id": "pm-123", + "action": "enter", + "clientId": "user-1", + "connectionId": "conn-1", + "data": "hello", + "encoding": null, + "timestamp": 1234567890000, + "extras": { "headers": { "x-key": "x-value" } } +} + +msg = PresenceMessage.fromJson(json_data) + +ASSERT msg.id == "pm-123" +ASSERT msg.action == ENTER +ASSERT msg.clientId == "user-1" +ASSERT msg.connectionId == "conn-1" +ASSERT msg.data == "hello" +ASSERT msg.timestamp == 1234567890000 +ASSERT msg.extras["headers"]["x-key"] == "x-value" +``` + +--- + +## TP3 - PresenceMessage with encoded data from JSON + +**Test ID**: `rest/unit/TP3/presence-encoded-data-from-json-1` + +**Spec requirement:** Deserialization must decode data based on the encoding field. + +### Test Cases + +| ID | Encoding | Wire Data | Expected Data | +|----|----------|-----------|---------------| +| 1 | `null` | `"plain text"` | `"plain text"` | +| 2 | `"json"` | `"{\"status\":\"online\"}"` | `{ "status": "online" }` | +| 3 | `"base64"` | `"SGVsbG8="` | `bytes("Hello")` | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + json_data = { + "action": "enter", + "clientId": "user-1", + "data": test_case.wire_data, + "encoding": test_case.encoding + } + + msg = PresenceMessage.fromJson(json_data) + + ASSERT msg.data == test_case.expected_data + ASSERT msg.encoding IS null # Encoding consumed +``` + +--- + +## TP3 - PresenceMessage to JSON (wire format) + +**Test ID**: `rest/unit/TP3/presence-to-json-2` + +**Spec requirement:** PresenceMessage must support serialization to JSON wire format. + +### Test Steps +```pseudo +msg = PresenceMessage( + action: ENTER, + clientId: "user-1", + data: "hello", + extras: { "headers": { "x-key": "x-value" } } +) + +json_data = msg.toJson() + +ASSERT json_data["action"] == "enter" +ASSERT json_data["clientId"] == "user-1" +ASSERT json_data["data"] == "hello" +ASSERT json_data["extras"]["headers"]["x-key"] == "x-value" +``` + +--- + +## TP3 - Null/missing attributes omitted from serialization + +**Test ID**: `rest/unit/TP3/null-attributes-omitted-3` + +**Spec requirement:** Null or missing optional attributes should be omitted from +serialized output. + +### Test Steps +```pseudo +msg = PresenceMessage(action: ENTER, clientId: "user-1") + +json_data = msg.toJson() + +ASSERT json_data["action"] == "enter" +ASSERT json_data["clientId"] == "user-1" +ASSERT "data" NOT IN json_data OR json_data["data"] IS null +ASSERT "encoding" NOT IN json_data OR json_data["encoding"] IS null +ASSERT "extras" NOT IN json_data OR json_data["extras"] IS null +ASSERT "id" NOT IN json_data OR json_data["id"] IS null +``` + +--- + +## TP4 - fromEncoded and fromEncodedArray + +**Test ID**: `rest/unit/TP4/from-encoded-presence-0` + +**Spec requirement:** fromEncoded and fromEncodedArray are alternative constructors +that take an already-deserialized PresenceMessage-like object (or array) and return +decoded and decrypted PresenceMessage(s). Behavior is the same as TM3. + +### Test Steps +```pseudo +# fromEncoded — single message +raw = { + "action": "enter", + "clientId": "user-1", + "data": "{\"status\":\"online\"}", + "encoding": "json" +} + +msg = PresenceMessage.fromEncoded(raw) + +ASSERT msg.action == ENTER +ASSERT msg.clientId == "user-1" +ASSERT msg.data == { "status": "online" } +ASSERT msg.encoding IS null + +# fromEncodedArray — array of messages +raw_array = [ + { "action": "enter", "clientId": "alice", "data": "hello" }, + { "action": "enter", "clientId": "bob", "data": "world" } +] + +messages = PresenceMessage.fromEncodedArray(raw_array) + +ASSERT messages.length == 2 +ASSERT messages[0].clientId == "alice" +ASSERT messages[0].data == "hello" +ASSERT messages[1].clientId == "bob" +ASSERT messages[1].data == "world" +``` + +--- + +## TP5 - PresenceMessage size calculation + +**Test ID**: `rest/unit/TP5/presence-message-size-0` + +**Spec requirement:** The size of the PresenceMessage is calculated in the same way +as for Message (see TM6). This is used for TO3l8 (maxMessageSize) enforcement. + +### Test Steps +```pseudo +# Size includes clientId + data + extras (same formula as TM6) +msg = PresenceMessage( + action: ENTER, + clientId: "user-1", + data: "hello" +) + +size = msg.size + +# Size should account for clientId (6 bytes) + data (5 bytes) = 11 +ASSERT size == 11 + +# Size with object data (JSON-encoded size) +msg2 = PresenceMessage( + action: ENTER, + clientId: "u", + data: { "key": "value" } +) + +# clientId (1) + JSON-encoded data length +ASSERT msg2.size > 1 +``` diff --git a/uts/rest/unit/types/token_types.md b/uts/rest/unit/types/token_types.md new file mode 100644 index 000000000..9f422916d --- /dev/null +++ b/uts/rest/unit/types/token_types.md @@ -0,0 +1,360 @@ +# Token Types Tests + +Spec points: `TD1`, `TD2`, `TD3`, `TD4`, `TD5`, `TK1`, `TK2`, `TK3`, `TK4`, `TK5`, `TK6`, `TE1`, `TE2`, `TE3`, `TE4`, `TE5`, `TE6` + +## Test Type +Unit test - pure type/model validation + +## Mock Configuration +No mocks required for most tests - these verify type structure and serialization. + +--- + +## TD1-TD5 - TokenDetails structure + +**Test ID**: `rest/unit/TD1/token-details-attributes-0` + +**Spec requirement:** TokenDetails type must provide all required attributes according to TD1-TD5 specifications. + +| Spec | Attribute | Description | +|------|-----------|-------------| +| TD1 | token | The token string | +| TD2 | expires | Expiry time in milliseconds since epoch | +| TD3 | issued | Issue time in milliseconds since epoch | +| TD4 | capability | Capability JSON string | +| TD5 | clientId | Client ID associated with the token | + +Tests that `TokenDetails` has all required attributes. + +### Test Steps +```pseudo +# TD1 - token attribute +token_details = TokenDetails( + token: "test-token", + expires: 1234567890000 +) +ASSERT token_details.token == "test-token" + +# TD2 - expires attribute (milliseconds since epoch) +ASSERT token_details.expires == 1234567890000 + +# TD3 - issued attribute +token_with_issued = TokenDetails( + token: "test-token", + expires: 1234567890000, + issued: 1234567800000 +) +ASSERT token_with_issued.issued == 1234567800000 + +# TD4 - capability attribute (JSON string) +token_with_capability = TokenDetails( + token: "test-token", + expires: 1234567890000, + capability: "{\"*\":[\"*\"]}" +) +ASSERT token_with_capability.capability == "{\"*\":[\"*\"]}" + +# TD5 - clientId attribute +token_with_client = TokenDetails( + token: "test-token", + expires: 1234567890000, + clientId: "my-client" +) +ASSERT token_with_client.clientId == "my-client" +``` + +--- + +## TD - TokenDetails from JSON + +**Test ID**: `rest/unit/TD/token-details-from-json-0` + +**Spec requirement:** TokenDetails must support deserialization from JSON responses containing token information. + +Tests that `TokenDetails` can be deserialized from JSON response. + +### Test Steps +```pseudo +json_data = { + "token": "deserialized-token", + "expires": 1234567890000, + "issued": 1234567800000, + "capability": "{\"channel-1\":[\"publish\"]}", + "clientId": "json-client", + "keyName": "appId.keyId" +} + +token_details = TokenDetails.fromJson(json_data) + +ASSERT token_details.token == "deserialized-token" +ASSERT token_details.expires == 1234567890000 +ASSERT token_details.issued == 1234567800000 +ASSERT token_details.capability == "{\"channel-1\":[\"publish\"]}" +ASSERT token_details.clientId == "json-client" +``` + +--- + +## TK1-TK6 - TokenParams structure + +**Test ID**: `rest/unit/TK1/token-params-attributes-0` + +**Spec requirement:** TokenParams type must provide all required attributes according to TK1-TK6 specifications. + +| Spec | Attribute | Description | +|------|-----------|-------------| +| TK1 | ttl | Time to live in milliseconds | +| TK2 | capability | Capability JSON string | +| TK3 | clientId | Client ID for the token | +| TK4 | timestamp | Timestamp in milliseconds since epoch | +| TK5 | nonce | Unique nonce value | +| TK6 | (all) | All attributes combined | + +Tests that `TokenParams` has all required attributes. + +### Test Steps +```pseudo +# TK1 - ttl attribute (milliseconds, nullable) +params = TokenParams(ttl: 3600000) +ASSERT params.ttl == 3600000 + +# TK1 - ttl defaults to null when not specified (RSA5 depends on this) +params = TokenParams() +ASSERT params.ttl IS null + +# TK2 - capability attribute (nullable) +params = TokenParams(capability: "{\"*\":[\"subscribe\"]}") +ASSERT params.capability == "{\"*\":[\"subscribe\"]}" + +# TK2 - capability defaults to null when not specified (RSA6 depends on this) +params = TokenParams() +ASSERT params.capability IS null + +# TK3 - clientId attribute +params = TokenParams(clientId: "param-client") +ASSERT params.clientId == "param-client" + +# TK4 - timestamp attribute (milliseconds since epoch) +params = TokenParams(timestamp: 1234567890000) +ASSERT params.timestamp == 1234567890000 + +# TK5 - nonce attribute +params = TokenParams(nonce: "unique-nonce-value") +ASSERT params.nonce == "unique-nonce-value" + +# TK6 - All attributes together +params = TokenParams( + ttl: 7200000, + capability: "{\"*\":[\"*\"]}", + clientId: "full-client", + timestamp: 1234567890000, + nonce: "full-nonce" +) +ASSERT params.ttl == 7200000 +ASSERT params.capability == "{\"*\":[\"*\"]}" +ASSERT params.clientId == "full-client" +ASSERT params.timestamp == 1234567890000 +ASSERT params.nonce == "full-nonce" +``` + +--- + +## TK - TokenParams to query string + +**Test ID**: `rest/unit/TK/token-params-to-query-string-0` + +**Spec requirement:** TokenParams must support conversion to query parameters for token request URLs. + +Tests that `TokenParams` are correctly converted to query parameters. + +### Test Steps +```pseudo +params = TokenParams( + ttl: 3600000, + clientId: "query-client", + capability: "{\"ch\":[\"pub\"]}" +) + +query_map = params.toQueryParams() + +ASSERT query_map["ttl"] == "3600000" +ASSERT query_map["clientId"] == "query-client" +ASSERT query_map["capability"] == "{\"ch\":[\"pub\"]}" +``` + +--- + +## TE1-TE6 - TokenRequest structure + +**Test ID**: `rest/unit/TE1/token-request-attributes-0` + +**Spec requirement:** TokenRequest type must provide all required attributes according to TE1-TE6 specifications. + +| Spec | Attribute | Description | +|------|-----------|-------------| +| TE1 | keyName | API key name (appId.keyId) | +| TE2 | ttl | Time to live in milliseconds | +| TE3 | capability | Capability JSON string | +| TE4 | clientId | Client ID for the token | +| TE5 | timestamp | Timestamp in milliseconds since epoch | +| TE6 | nonce | Unique nonce value | + +Tests that `TokenRequest` has all required attributes. + +### Test Steps +```pseudo +# TE1 - keyName attribute +request = TokenRequest( + keyName: "appId.keyId", + timestamp: 1234567890000, + nonce: "nonce-1" +) +ASSERT request.keyName == "appId.keyId" + +# TE2 - ttl attribute (nullable) +request = TokenRequest( + keyName: "appId.keyId", + ttl: 3600000, + timestamp: 1234567890000, + nonce: "nonce-2" +) +ASSERT request.ttl == 3600000 + +# TE2 - ttl defaults to null when not specified (RSA5 depends on this) +request = TokenRequest( + keyName: "appId.keyId", + timestamp: 1234567890000, + nonce: "nonce-2b" +) +ASSERT request.ttl IS null + +# TE3 - capability attribute (nullable) +request = TokenRequest( + keyName: "appId.keyId", + capability: "{\"*\":[\"*\"]}", + timestamp: 1234567890000, + nonce: "nonce-3" +) +ASSERT request.capability == "{\"*\":[\"*\"]}" + +# TE3 - capability defaults to null when not specified (RSA6 depends on this) +request = TokenRequest( + keyName: "appId.keyId", + timestamp: 1234567890000, + nonce: "nonce-3b" +) +ASSERT request.capability IS null + +# TE4 - clientId attribute +request = TokenRequest( + keyName: "appId.keyId", + clientId: "request-client", + timestamp: 1234567890000, + nonce: "nonce-4" +) +ASSERT request.clientId == "request-client" + +# TE5 - timestamp attribute +request = TokenRequest( + keyName: "appId.keyId", + timestamp: 1234567890000, + nonce: "nonce-5" +) +ASSERT request.timestamp == 1234567890000 + +# TE6 - nonce attribute +request = TokenRequest( + keyName: "appId.keyId", + timestamp: 1234567890000, + nonce: "unique-nonce" +) +ASSERT request.nonce == "unique-nonce" +``` + +--- + +## TE - TokenRequest with mac (signature) + +**Test ID**: `rest/unit/TE/token-request-mac-signature-0` + +**Spec requirement:** TokenRequest must include a mac (signature) field for authentication. + +Tests that `TokenRequest` includes the mac signature. + +### Test Steps +```pseudo +request = TokenRequest( + keyName: "appId.keyId", + timestamp: 1234567890000, + nonce: "nonce-value", + mac: "signature-base64" +) + +ASSERT request.mac == "signature-base64" +``` + +--- + +## TE - TokenRequest to JSON + +**Test ID**: `rest/unit/TE/token-request-to-json-1` + +**Spec requirement:** TokenRequest must support serialization to JSON for transmission to the token endpoint. + +Tests that `TokenRequest` serializes correctly for transmission. + +### Test Steps +```pseudo +request = TokenRequest( + keyName: "appId.keyId", + ttl: 3600000, + capability: "{\"*\":[\"*\"]}", + clientId: "json-client", + timestamp: 1234567890000, + nonce: "json-nonce", + mac: "json-mac" +) + +json_data = request.toJson() + +ASSERT json_data["keyName"] == "appId.keyId" +ASSERT json_data["ttl"] == 3600000 +ASSERT json_data["capability"] == "{\"*\":[\"*\"]}" +ASSERT json_data["clientId"] == "json-client" +ASSERT json_data["timestamp"] == 1234567890000 +ASSERT json_data["nonce"] == "json-nonce" +ASSERT json_data["mac"] == "json-mac" +``` + +--- + +## TE - TokenRequest from JSON + +**Test ID**: `rest/unit/TE/token-request-from-json-2` + +**Spec requirement:** TokenRequest must support deserialization from JSON. + +Tests that `TokenRequest` can be deserialized from JSON. + +### Test Steps +```pseudo +json_data = { + "keyName": "appId.keyId", + "ttl": 7200000, + "capability": "{\"ch\":[\"sub\"]}", + "clientId": "from-json-client", + "timestamp": 1234567899999, + "nonce": "from-json-nonce", + "mac": "from-json-mac" +} + +request = TokenRequest.fromJson(json_data) + +ASSERT request.keyName == "appId.keyId" +ASSERT request.ttl == 7200000 +ASSERT request.capability == "{\"ch\":[\"sub\"]}" +ASSERT request.clientId == "from-json-client" +ASSERT request.timestamp == 1234567899999 +ASSERT request.nonce == "from-json-nonce" +ASSERT request.mac == "from-json-mac" +```