-
Notifications
You must be signed in to change notification settings - Fork 3
Add UTS test specs for LiveObjects path-based API #473
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
paddybyers
wants to merge
16
commits into
main
Choose a base branch
from
uts-liveobjects
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
a77f75a
Add UTS test specs for LiveObjects path-based API (~330 tests)
paddybyers 3de4a4f
Delegate proxy port assignment to uts-proxy in all test specs
paddybyers 2fba05e
Update UTS test specs to match LiveObjects path-based API spec (a397e34)
paddybyers 86e9636
UTS: add missing assertions
paddybyers e57d340
UTS: correct sandbox endpoint domain name
paddybyers add9398
UTS: remove spurious Map.clear() test
paddybyers 3ad5a41
UTS: remove unnecessary test
paddybyers e77bdb8
UTS: delete integration GC test — duplicates unit tests
paddybyers eaa6983
UTS: add Protocol Variants to LiveObjects integration tests
paddybyers 99421b7
UTS: fix root.increment() → root.get("score").increment()
paddybyers 8f52447
UTS: fix InstanceSubscriptionEvent assertions in live_object_subscribe
paddybyers 0b15af8
UTS: move appliedOnAckSerials/bufferedObjectOperations to RealtimeObject
paddybyers f7ace4c
UTS: fix RTPO19b subscribe-on-detached test
paddybyers d7d75e7
UTS: rewrite RTO20f to use observable behaviour
paddybyers e3614cd
UTS: rewrite RTO5c9 re-sync test to use observable behaviour
paddybyers 6bcf593
UTS: add OBJECT, OBJECT_SYNC, ANNOTATION to proxy action numbers table
paddybyers File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,367 @@ | ||
| # 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. | ||
|
|
||
| ### Expected parentReferences after sync | ||
|
|
||
| After `setup_synced_channel` completes (including the RTO5c10 rebuild), each object's `parentReferences` should be: | ||
|
|
||
| | Object | parentReferences | | ||
| |--------|-----------------| | ||
| | `root` | `{}` (empty -- root is not referenced by any parent) | | ||
| | `counter:score@1000` | `{ "root": {"score"} }` | | ||
| | `map:profile@1000` | `{ "root": {"profile"} }` | | ||
| | `counter:nested@1000` | `{ "map:profile@1000": {"nested_counter"} }` | | ||
| | `map:prefs@1000` | `{ "map:profile@1000": {"prefs"} }` | | ||
|
|
||
| Only entries whose value is a `LiveObject` (i.e. `data.objectId` is present) contribute to parentReferences. Primitive-valued entries ("name", "age", "active", "data", "avatar", "email", "theme") do not. | ||
|
|
||
| --- | ||
|
|
||
| ## 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) | ||
| ``` | ||
|
|
||
| ### ObjectMessage Builder (State wrapper) | ||
|
|
||
| Wraps an existing `ObjectState` in an `ObjectMessage` with the `object` field populated. Used when `replaceData` (RTLC6, RTLM6) needs an `ObjectMessage` rather than a bare `ObjectState`. | ||
|
|
||
| ```pseudo | ||
| build_object_message_with_state(objectState): | ||
| RETURN ObjectMessage(object: objectState) | ||
| ``` | ||
|
|
||
| ### PublicAPI::ObjectMessage Builder | ||
|
|
||
| Constructs a `PublicAPI::ObjectMessage` from an internal `ObjectMessage` and a channel name, per PAOM3. Used by subscription tests that verify the user-facing message delivered to listeners. | ||
|
|
||
| ```pseudo | ||
| build_public_object_message(objectMessage, channelName): | ||
| pub = PublicAPI::ObjectMessage() | ||
| pub.channel = channelName | ||
| pub.id = objectMessage.id | ||
| pub.clientId = objectMessage.clientId | ||
| pub.connectionId = objectMessage.connectionId | ||
| pub.timestamp = objectMessage.timestamp | ||
| pub.serial = objectMessage.serial | ||
| pub.serialTimestamp = objectMessage.serialTimestamp | ||
| pub.siteCode = objectMessage.siteCode | ||
| pub.extras = objectMessage.extras | ||
| pub.operation = PublicAPI::ObjectOperation from objectMessage.operation per PAOOP3 | ||
| RETURN pub | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Standard Synced-Channel Setup | ||
|
|
||
| Used by all mock WebSocket test files. Creates a connected client with a synced channel containing the standard test pool. | ||
|
|
||
| After the OBJECT_SYNC sequence completes, the SDK rebuilds parentReferences per RTO5c10: reset all LiveObject parentReferences to empty (RTLO3f2), then iterate all LiveMap entries calling addParentReference (RTLO4g) for each entry whose value is a LiveObject. See "Expected parentReferences after sync" above for the resulting state. | ||
|
|
||
| ```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 | ||
|
paddybyers marked this conversation as resolved.
|
||
| WITH Authorization: Basic {base64(api_key)} | ||
| WITH Content-Type: application/json | ||
| WITH body: { "messages": operations } | ||
| ``` | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.