diff --git a/uts/docs/integration-testing.md b/uts/docs/integration-testing.md index fa7fd0a6c..74464f5cc 100644 --- a/uts/docs/integration-testing.md +++ b/uts/docs/integration-testing.md @@ -157,7 +157,6 @@ Proxy tests additionally set up a proxy session per test or group of tests. See BEFORE EACH TEST: session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, rules: [ ...initial rules... ] ) @@ -296,6 +295,10 @@ The following integration test specs are annotated with `## Protocol Variants`: - `realtime/integration/mutable_messages_test.md` - `realtime/integration/delta_decoding_test.md` +**LiveObjects:** +- `objects/integration/objects_lifecycle_test.md` +- `objects/integration/objects_sync_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. diff --git a/uts/docs/writing-test-specs.md b/uts/docs/writing-test-specs.md index 1102181a8..4abce6575 100644 --- a/uts/docs/writing-test-specs.md +++ b/uts/docs/writing-test-specs.md @@ -344,7 +344,6 @@ Tests that [behaviour] when the proxy injects [fault]. ```pseudo session = create_proxy_session( target: TargetConfig(realtimeHost: "sandbox.realtime.ably-nonprod.net", restHost: "sandbox.realtime.ably-nonprod.net"), - port: allocated_port, rules: [{ "match": { ... }, "action": { ... }, diff --git a/uts/objects/PLAN.md b/uts/objects/PLAN.md new file mode 100644 index 000000000..a95734829 --- /dev/null +++ b/uts/objects/PLAN.md @@ -0,0 +1,385 @@ +# 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` — the path-based API version squashed as commit `a397e34` ("LiveObjects path-based API spec"). + +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), PublicAPI::ObjectMessage/ObjectOperation (user-facing event metadata) + +**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-6, RTLO4b4d-e | ~23 | +| `unit/live_map.md` | RTLM1-9, RTLM14-16, RTLM18-19, RTLM22-25, RTLO3-6, RTLO4g-h, RTLO4e9 | ~38 | +| `unit/objects_pool.md` | RTO3-9, RTO5c10 | ~28 | +| `unit/object_id.md` | RTO14 | ~5 | +| `unit/value_types.md` | RTLCV1-4, RTLMV1-4 (evaluation generates ObjectMessages with v6 wire format) | ~19 | +| `unit/parent_references.md` | RTLO3f, RTLO4f-h, RTO5c10 (parentReferences, getFullPaths, add/remove/rebuild) | ~20 | +| `unit/public_object_message.md` | PAOM1-3, PAOOP1-3 (PublicAPI::ObjectMessage/ObjectOperation construction) | ~13 | + +### Mock WebSocket Unit Tests +| File | Spec Points | ~Tests | +|------|-------------|--------| +| `unit/realtime_object.md` | RTO2, RTO10, RTO15-20, RTO22-26 (sync events, publish, publishAndApply, GC, RTO24/25/26 preconditions) | ~36 | +| `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, RTLCV4, RTLMV4 (reads + mutations, value type evaluation) | ~20 | +| `unit/live_object_subscribe.md` | RTLO4b, RTLO4b4c3, RTLO4b4d-e, RTLO4b7 (subscribe, dispatch chain, tombstone cleanup, Subscription) | ~11 | +| `unit/path_object.md` | RTPO1-14, RTO25 (navigation, value, instance, entries, compact, compactJson, access preconditions) | ~27 | +| `unit/path_object_mutations.md` | RTPO15-18, RTPO3c2, RTO26 (set, remove, increment, decrement, write preconditions) | ~14 | +| `unit/path_object_subscribe.md` | RTPO19, RTO24 (path subscriptions, depth filtering, dispatch, PAOM delivery) | ~22 | +| `unit/instance.md` | RTINS1-16 (id, value, get, entries, size, compact, set, remove, increment, subscribe, RTO25/26) | ~21 | + +### 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`~~ | ~~Batch API not in current spec revision~~ | — | +| `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: ~20 files, ~310 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 evaluation procedure. + +**LiveCounterValueType (RTLCV1-4):** +1. `LiveCounter.create(42)` -> immutable LiveCounterValueType with count=42 +2. `LiveCounter.create()` -> count defaults to 0 +3. Evaluation: validates count, builds CounterCreate, generates objectId, returns ObjectMessage with `counterCreateWithObjectId.{nonce, initialValue}` +4. Non-number count throws 40003 during evaluation + +**LiveMapValueType (RTLMV1-4):** +1. `LiveMap.create({entries})` -> immutable LiveMapValueType +2. Evaluation: 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 (RTPO19d), listener receives PathObjectSubscriptionEvent (RTPO19e) +- **RTPO19b:** checks RTO25 access API preconditions +- **RTPO19c1:** depth filtering -- depth=1 (self only), depth=2 (self+children), undefined (all) +- **RTPO19c1a:** non-positive depth throws 40003 +- **RTPO19e2:** event.message carries PublicAPI::ObjectMessage when operation present +- **RTPO19f:** follows path not identity -- object replacement at path -> subscription tracks new object +- **RTO24b2a:** candidate path construction includes map update keys +- **RTO24c1:** coverage rule: prefix match + depth constraint +- **RTO24b2c:** listener exception caught, doesn't affect other listeners +- **RTO24b1:** multi-path dispatch via getFullPaths + +### `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 evaluates 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, returns Subscription (RTLO4b7) +- **RTLO4b4c3:** dispatch chain: direct listeners → path dispatch → tombstone cleanup +- **RTLO4b4d/e:** LiveObjectUpdate carries objectMessage and tombstone fields +- Subscription#unsubscribe deregisters (idempotent) +- Tombstone update deregisters all direct listeners (RTLO4b4c3c) + +### `unit/parent_references.md` -- parentReferences Tracking + +- **RTLO3f:** parentReferences initialized to empty Dict> +- **RTLO4g/RTLO4h:** addParentReference/removeParentReference methods +- **RTLO4f:** getFullPaths — DFS traversal of inverse parentReferences graph, simple paths only +- **RTO5c10:** post-sync parentReferences rebuild from LiveMap entries + +### `unit/public_object_message.md` -- User-Facing Event Types + +- **PAOM1-3:** PublicAPI::ObjectMessage construction from internal ObjectMessage +- **PAOOP1-3:** PublicAPI::ObjectOperation construction, mapCreate/counterCreate resolution from *WithObjectId variants + +--- + +## 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/parent_references.md` -- foundational for graph tracking +3. `unit/public_object_message.md` -- standalone type construction +4. `unit/live_counter.md` -- no dependencies +5. `unit/live_map.md` -- no dependencies +6. `unit/object_id.md` -- no dependencies +7. `unit/objects_pool.md` -- uses LiveCounter/LiveMap concepts +8. `unit/value_types.md` -- uses objectId generation +9. `unit/realtime_object.md` -- uses helper, tests orchestration +10. `unit/live_counter_api.md` -- uses helper +11. `unit/live_map_api.md` -- uses helper +12. `unit/live_object_subscribe.md` -- uses helper +13. `unit/path_object.md` -- uses helper +14. `unit/instance.md` -- uses helper +15. `unit/path_object_mutations.md` -- uses helper +16. `unit/path_object_subscribe.md` -- uses helper +17. `integration/objects_lifecycle_test.md` +18. `integration/objects_sync_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 moved to RTO26 | RTO26c checks echoMessages=false; callers (PathObject/Instance) enforce via RTO26 | +| Batch API deferred | Not included in current spec revision (a397e34); may be added in a future spec update | +| 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..093b1e996 --- /dev/null +++ b/uts/objects/helpers/standard_test_pool.md @@ -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 + 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..b3fd4d849 --- /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.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} +``` + +### 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_lifecycle_test.md b/uts/objects/integration/objects_lifecycle_test.md new file mode 100644 index 000000000..a72ae3b68 --- /dev/null +++ b/uts/objects/integration/objects_lifecycle_test.md @@ -0,0 +1,324 @@ +# Objects Lifecycle Integration Tests + +Spec points: `RTO23`, `RTPO15`, `RTPO17` + +## 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 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.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} +``` + +### 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, useBinaryProtocol: PROTOCOL == "msgpack" }) +client_b = Realtime(options: { key: api_key, useBinaryProtocol: PROTOCOL == "msgpack" }) + +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, useBinaryProtocol: PROTOCOL == "msgpack" }) +client_b = Realtime(options: { key: api_key, useBinaryProtocol: PROTOCOL == "msgpack" }) + +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, useBinaryProtocol: PROTOCOL == "msgpack" }) +client_b = Realtime(options: { key: api_key, useBinaryProtocol: PROTOCOL == "msgpack" }) + +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, useBinaryProtocol: PROTOCOL == "msgpack" }) +client_b = Realtime(options: { key: api_key, useBinaryProtocol: PROTOCOL == "msgpack" }) + +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, useBinaryProtocol: PROTOCOL == "msgpack" }) +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, useBinaryProtocol: PROTOCOL == "msgpack" }) +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..16af7ecee --- /dev/null +++ b/uts/objects/integration/objects_sync_test.md @@ -0,0 +1,207 @@ +# Objects Sync Integration Tests + +Spec points: `RTO4`, `RTO5`, `RTO17` + +## 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 + +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.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} +``` + +### 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, useBinaryProtocol: PROTOCOL == "msgpack" }) +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, useBinaryProtocol: PROTOCOL == "msgpack" }) +client_b = Realtime(options: { key: api_key, useBinaryProtocol: PROTOCOL == "msgpack" }) + +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, useBinaryProtocol: PROTOCOL == "msgpack" }) +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, useBinaryProtocol: PROTOCOL == "msgpack" }) +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..b4e08fab8 --- /dev/null +++ b/uts/objects/integration/proxy/objects_faults.md @@ -0,0 +1,461 @@ +# 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 +ASSERT error.cause IS NOT null +ASSERT error.cause.code == 90000 +``` + +--- + +## 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/instance.md b/uts/objects/unit/instance.md new file mode 100644 index 000000000..bf8a67b47 --- /dev/null +++ b/uts/objects/unit/instance.md @@ -0,0 +1,582 @@ +# Instance Tests + +Spec points: `RTINS1`–`RTINS16` + +## 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 | Checks access API preconditions per RTO25 | +| RTINS4b | LiveCounter -> delegates to LiveCounter#value | +| RTINS4c | Primitive -> returns value directly | +| RTINS4d | 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 | Checks access API preconditions per RTO25 | +| RTINS5c | LiveMap -> look up key, wrap result in Instance | +| RTINS5d | 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() returns array of [key, Instance] pairs + +**Test ID**: `objects/unit/RTINS6/entries-yields-instances-0` + +| Spec | Requirement | +|------|-------------| +| RTINS6a | Checks access API preconditions per RTO25 | +| RTINS6b | LiveMap -> array of [key, Instance] pairs | +| RTINS6c | Non-LiveMap -> empty array | + +### 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 | Checks access API preconditions per RTO25 | +| RTINS9b | LiveMap -> non-tombstoned entry count | +| RTINS9c | 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 | +|------|-------------| +| RTINS10a | Checks access API preconditions per RTO25 | +| RTINS10b | 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 | Checks write API preconditions per RTO26 | +| RTINS12c | LiveMap -> delegate to LiveMap#set | +| RTINS12d | 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" +``` + +--- + +## RTINS12d - set() on non-LiveMap throws 92007 + +**Test ID**: `objects/unit/RTINS12d/set-non-map-throws-0` + +**Spec requirement:** If the wrapped value is not a LiveMap, throw ErrorInfo with code 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.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` + +| Spec | Requirement | +|------|-------------| +| RTINS13b | Checks write API preconditions per RTO26 | +| RTINS13c | LiveMap -> delegate to LiveMap#remove | +| RTINS13d | 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.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 | Checks write API preconditions per RTO26 | +| RTINS14c | LiveCounter -> delegate to increment | +| RTINS14d | 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 +``` + +--- + +## RTINS14d - increment() on non-LiveCounter throws 92007 + +**Test ID**: `objects/unit/RTINS14d/increment-non-counter-throws-0` + +**Spec requirement:** If the wrapped value is not a LiveCounter, throw ErrorInfo with code 92007. + +### 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` + +| Spec | Requirement | +|------|-------------| +| RTINS15b | Checks write API preconditions per RTO26 | +| RTINS15c | LiveCounter -> delegate to decrement | +| RTINS15d | 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.decrement(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 90 +``` + +--- + +## RTINS14a - increment() defaults to 1 + +**Test ID**: `objects/unit/RTINS14a/increment-default-0` + +**Spec requirement:** amount defaults to 1 (RTINS14a1). + +### 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 (RTINS15a1). + +### 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 - subscribe() receives InstanceSubscriptionEvent + +**Test ID**: `objects/unit/RTINS16/subscribe-receives-events-0` + +| Spec | Requirement | +|------|-------------| +| RTINS16b | Checks access API preconditions per RTO25 | +| RTINS16d | Subscribes via LiveObject#subscribe (RTLO4b) | +| RTINS16e1 | Event.object is an Instance wrapping the LiveObject | +| RTINS16f | Returns Subscription | +| RTINS16g | 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" +``` + +--- + +## RTINS16c - subscribe() on primitive throws 92007 + +**Test ID**: `objects/unit/RTINS16c/subscribe-primitive-throws-0` + +**Spec requirement:** If wrapped value is not a LiveObject (i.e. it is a primitive), throw ErrorInfo with code 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 +``` + +--- + +## RTINS16e2 - InstanceSubscriptionEvent contains PublicAPI::ObjectMessage + +**Test ID**: `objects/unit/RTINS16e2/subscription-event-message-0` + +| Spec | Requirement | +|------|-------------| +| RTINS16e1 | Event.object is an Instance wrapping the LiveObject | +| RTINS16e2 | Event.message is a PublicAPI::ObjectMessage derived from the triggering ObjectMessage | + +Tests that the InstanceSubscriptionEvent includes both the `object` (Instance) and `message` (PublicAPI::ObjectMessage) fields when a data update arrives. + +### 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.channel == "test" +ASSERT events[0].message.operation.action == "MAP_SET" +ASSERT events[0].message.operation.objectId == "root" +ASSERT events[0].message.operation.mapSet.key == "name" +``` + +--- + +## RTINS16f - subscribe() returns Subscription for deregistration + +**Test ID**: `objects/unit/RTINS16f/subscribe-returns-subscription-0` + +**Spec requirement:** Returns a Subscription object (RTINS16f). Deregistration is via Subscription#unsubscribe. + +### 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 +``` + +--- + +## RTINS16g - Instance subscription follows identity not path + +**Test ID**: `objects/unit/RTINS16g/subscription-follows-identity-0` + +**Spec requirement:** The subscription is identity-based: it follows the specific LiveObject instance, regardless of where it sits in the graph. + +### 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" +``` + +--- + +## RTINS16h - subscribe() has no side effects + +**Test ID**: `objects/unit/RTINS16h/subscribe-no-side-effects-0` + +**Spec requirement:** The subscribe operation must not have any side effects on RealtimeObject, the underlying channel, or their status. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +channel_state_before = channel.state +``` + +### Test Steps +```pseudo +sub = counter_inst.subscribe((event) => {}) +``` + +### Assertions +```pseudo +ASSERT channel.state == channel_state_before +``` diff --git a/uts/objects/unit/live_counter.md b/uts/objects/unit/live_counter.md new file mode 100644 index 000000000..d5f2c3401 --- /dev/null +++ b/uts/objects/unit/live_counter.md @@ -0,0 +1,841 @@ +# LiveCounter Tests + +Spec points: `RTLC1`, `RTLC3`, `RTLC4`, `RTLC6`, `RTLC7`, `RTLC8`, `RTLC9`, `RTLC14`, `RTLC16`, `RTLO3`, `RTLO4a`, `RTLO4b4d`, `RTLO4b4e`, `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 and objectMessage set to the provided ObjectMessage | + +### 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 +ASSERT update.objectMessage == msg +``` + +--- + +## 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 +ASSERT update.objectMessage == msg +``` + +--- + +## 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 and objectMessage set to the provided ObjectMessage | + +### 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 +ASSERT update.objectMessage == msg +``` + +--- + +## 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 | +| RTLO5c | Return the LiveObjectUpdate returned by tombstone | +| RTLO4e2 | Set isTombstone to true | +| RTLO4e4 | Set data to zero-value | +| RTLO4e5 | Compute diff for the tombstone update | +| RTLO4e6 | Set tombstone flag on the update | +| RTLO4e7 | Set objectMessage on the update | +| RTLC7d4c | Emit LiveCounterUpdate returned by RTLO5 | + +### 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 +ASSERT update.tombstone == true +ASSERT update.objectMessage == msg +``` + +--- + +## 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 with objectMessage set to the provided ObjectMessage | + +### 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 +ASSERT update.objectMessage == state_msg +``` + +--- + +## 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 +ASSERT update.objectMessage == state_msg +``` + +--- + +## 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 via LiveObject.tombstone | +| RTLC6f2 | Return the LiveCounterUpdate returned by LiveObject.tombstone | +| RTLO4e6 | Tombstone flag set on the update | +| RTLO4e7 | objectMessage set on the update | + +### 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 +ASSERT update.tombstone == true +ASSERT update.objectMessage == state_msg +``` + +--- + +## 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 +ASSERT update.objectMessage == state_msg +``` + +--- + +## 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 +ASSERT update.objectMessage == state_msg +``` + +--- + +## 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..f55bd589c --- /dev/null +++ b/uts/objects/unit/live_counter_api.md @@ -0,0 +1,259 @@ +# 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 | + +Note: RTLC5a and RTLC5b have been replaced by RTO25. The access API preconditions (OBJECT_SUBSCRIBE mode check and channel state check) are now the caller's responsibility and are tested separately in `objects/unit/rto25_access_preconditions.md`. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +counter = root.get("score") +ASSERT counter.value() == 100 +``` + +--- + +## 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.get("score").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.get("score").increment(50) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 150 +``` + +--- + +## RTLC12b/c/d - increment write preconditions (replaced by RTO26) + +**Test ID**: `objects/unit/RTLC12b/increment-requires-publish-0` + +Note: RTLC12b, RTLC12c, and RTLC12d have been replaced by RTO26. The write API preconditions (OBJECT_PUBLISH mode check, channel state check, and echoMessages check) are now the caller's responsibility and are tested separately in `objects/unit/rto26_write_preconditions.md`. + +--- + +## 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.get("score").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.get("score").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.get("score").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..0186570bb --- /dev/null +++ b/uts/objects/unit/live_map.md @@ -0,0 +1,1378 @@ +# LiveMap Tests + +Spec points: `RTLM1`–`RTLM9`, `RTLM14`–`RTLM16`, `RTLM18`–`RTLM19`, `RTLM22`–`RTLM25`, `RTLO3`, `RTLO4a`, `RTLO4e`, `RTLO4g`, `RTLO4h`, `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, diff calculation, and parentReferences maintenance. + +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" and objectMessage set to the provided ObjectMessage | + +### 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" } +ASSERT update.objectMessage == msg +``` + +--- + +## 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 | +| RTLM7f | Return LiveMapUpdate with objectMessage set to the provided ObjectMessage | + +### 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" } +ASSERT update.objectMessage == msg +``` + +--- + +## 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" } +ASSERT update.objectMessage == msg +``` + +--- + +## 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" and objectMessage set to the provided ObjectMessage | + +### 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" } +ASSERT update.objectMessage == msg +``` + +--- + +## 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 | +| RTLM8e | Return LiveMapUpdate with objectMessage set to the provided ObjectMessage | + +### 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" } +ASSERT update.objectMessage == msg +``` + +--- + +## 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 and objectMessage set to the provided ObjectMessage | + +### 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" } +ASSERT update.objectMessage == msg +``` + +--- + +## 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 | +| RTLM23c | Return LiveMapUpdate with merged update map and objectMessage set to the provided ObjectMessage | + +### 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" } +ASSERT update.objectMessage == msg +``` + +--- + +## 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 | +|------|-------------| +| RTLM15d5c | Emit LiveMapUpdate returned by RTLO5 | +| RTLM15d5b | Return true | +| RTLO4e5 | Compute diff for the tombstone update | +| RTLO4e6 | Set tombstone flag on the update | +| RTLO4e7 | Set objectMessage on the update | + +### 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" } +ASSERT update.tombstone == true +ASSERT update.objectMessage == msg +``` + +--- + +## 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 with objectMessage set to the provided ObjectMessage | + +### 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" } +ASSERT update.objectMessage == state_msg +``` + +--- + +## 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, passing in the ObjectMessage. + +### 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 +``` + +--- + +## RTLM6f - replaceData with tombstone flag tombstones map + +**Test ID**: `objects/unit/RTLM6f/replace-data-tombstone-flag-0` + +| Spec | Requirement | +|------|-------------| +| RTLM6f | If ObjectState.tombstone is true, tombstone the map via LiveObject.tombstone | +| RTLM6f2 | Return the LiveMapUpdate returned by LiveObject.tombstone | +| RTLO4e6 | Tombstone flag set on the update | +| RTLO4e7 | objectMessage set on the update | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +state_msg = build_object_state("root", {"site1": "01"}, { + map: { semantics: "LWW", entries: {} }, + tombstone: true +}) +update = map.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT map.isTombstone == true +ASSERT map.data == {} +ASSERT update.update == { "name": "removed" } +ASSERT update.tombstone == true +ASSERT update.objectMessage == state_msg +``` + +--- + +## 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 | +| RTLM7f | Return LiveMapUpdate with objectMessage set to the provided ObjectMessage | + +### 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" } +ASSERT update.objectMessage == msg +``` + +--- + +## 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 +ASSERT update.objectMessage == msg +``` + +--- + +## RTLM7a3, RTLM7g2 - parentReferences: MAP_SET overwrites entry referencing LiveObject + +**Test ID**: `objects/unit/RTLM7a3/map-set-overwrite-objectid-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7a3a | Before overwriting, check if existing entry has objectId | +| RTLM7a3b | If old entry references a LiveObject, call removeParentReference on old child | +| RTLM7g2 | After setting new objectId value, call addParentReference on new child | + +Tests that when MAP_SET overwrites an entry whose value is a LiveObject with a new LiveObject value, removeParentReference is called on the old child and addParentReference is called on the new child. + +### Setup +```pseudo +pool = ObjectsPool() +old_counter = LiveCounter(objectId: "counter:old@1000") +new_counter = LiveCounter(objectId: "counter:new@2000") +pool["counter:old@1000"] = old_counter +pool["counter:new@2000"] = new_counter + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "ref": { data: { objectId: "counter:old@1000" }, timeserial: "01", tombstone: false } +} +// Simulate existing parentReference +old_counter.parentReferences = { "root": {"ref"} } +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "ref", { objectId: "counter:new@2000" }, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["ref"].data == { objectId: "counter:new@2000" } +// removeParentReference was called on the old child +ASSERT "root" NOT IN old_counter.parentReferences OR "ref" NOT IN old_counter.parentReferences["root"] +// addParentReference was called on the new child +ASSERT "root" IN new_counter.parentReferences +ASSERT "ref" IN new_counter.parentReferences["root"] +ASSERT update.update == { "ref": "updated" } +ASSERT update.objectMessage == msg +``` + +--- + +## RTLM7g2 - parentReferences: MAP_SET new entry referencing LiveObject + +**Test ID**: `objects/unit/RTLM7g2/map-set-new-entry-add-parent-ref-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7g2 | After setting new objectId value, call addParentReference on the new child | + +Tests that when MAP_SET creates a new entry whose value is a LiveObject, addParentReference is called on the child. + +### Setup +```pseudo +pool = ObjectsPool() +child_counter = LiveCounter(objectId: "counter:child@1000") +pool["counter:child@1000"] = child_counter + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "score", { objectId: "counter:child@1000" }, "01", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["score"].data == { objectId: "counter:child@1000" } +ASSERT "root" IN child_counter.parentReferences +ASSERT "score" IN child_counter.parentReferences["root"] +ASSERT update.objectMessage == msg +``` + +--- + +## RTLM7 - parentReferences: MAP_SET with non-LiveObject value does not affect parentReferences + +**Test ID**: `objects/unit/RTLM7/map-set-primitive-no-parent-refs-0` + +**Spec requirement:** parentReferences operations only apply when the entry value contains an objectId. Primitive values do not trigger addParentReference or removeParentReference. + +### Setup +```pseudo +pool = ObjectsPool() +old_counter = LiveCounter(objectId: "counter:old@1000") +pool["counter:old@1000"] = old_counter + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "ref": { data: { objectId: "counter:old@1000" }, timeserial: "01", tombstone: false } +} +old_counter.parentReferences = { "root": {"ref"} } +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "ref", { string: "plain_value" }, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["ref"].data == { string: "plain_value" } +// removeParentReference was called on old child (entry previously had objectId) +ASSERT "root" NOT IN old_counter.parentReferences OR "ref" NOT IN old_counter.parentReferences["root"] +// No addParentReference call because new value is a primitive +ASSERT update.update == { "ref": "updated" } +ASSERT update.objectMessage == msg +``` + +--- + +## RTLM8a3 - parentReferences: MAP_REMOVE entry referencing LiveObject + +**Test ID**: `objects/unit/RTLM8a3/map-remove-objectid-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTLM8a3a | Before tombstoning, check if existing entry has objectId | +| RTLM8a3b | If entry references a LiveObject, call removeParentReference on the child | + +Tests that when MAP_REMOVE tombstones an entry whose value is a LiveObject, removeParentReference is called on the child. + +### Setup +```pseudo +pool = ObjectsPool() +child_counter = LiveCounter(objectId: "counter:child@1000") +pool["counter:child@1000"] = child_counter + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "score": { data: { objectId: "counter:child@1000" }, timeserial: "01", tombstone: false } +} +child_counter.parentReferences = { "root": {"score"} } +``` + +### Test Steps +```pseudo +msg = build_map_remove("root", "score", "02", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["score"].tombstone == true +// removeParentReference was called on the child +ASSERT "root" NOT IN child_counter.parentReferences OR "score" NOT IN child_counter.parentReferences["root"] +ASSERT update.update == { "score": "removed" } +ASSERT update.objectMessage == msg +``` + +--- + +## RTLM8 - parentReferences: MAP_REMOVE entry with non-LiveObject value + +**Test ID**: `objects/unit/RTLM8/map-remove-primitive-no-parent-refs-0` + +**Spec requirement:** MAP_REMOVE on a primitive-valued entry does not call removeParentReference because there is no objectId. + +### Setup +```pseudo +pool = ObjectsPool() +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +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"].tombstone == true +ASSERT update.update == { "name": "removed" } +ASSERT update.objectMessage == msg +// No parentReference calls needed -- test passes without errors +``` + +--- + +## RTLM24e1c - parentReferences: MAP_CLEAR removes parent references for cleared entries + +**Test ID**: `objects/unit/RTLM24e1c/map-clear-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTLM24e1c1 | Before removing entry, check if it has objectId | +| RTLM24e1c2 | If entry references a LiveObject, call removeParentReference on the child | + +Tests that when MAP_CLEAR removes entries that reference LiveObjects, removeParentReference is called for each. + +### Setup +```pseudo +pool = ObjectsPool() +counter_a = LiveCounter(objectId: "counter:a@1000") +counter_b = LiveCounter(objectId: "counter:b@1000") +pool["counter:a@1000"] = counter_a +pool["counter:b@1000"] = counter_b + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "ref_a": { data: { objectId: "counter:a@1000" }, timeserial: "02", tombstone: false }, + "ref_b": { data: { objectId: "counter:b@1000" }, timeserial: "02", tombstone: false }, + "primitive": { data: { string: "hello" }, timeserial: "02", tombstone: false }, + "newer": { data: { string: "kept" }, timeserial: "09", tombstone: false } +} +counter_a.parentReferences = { "root": {"ref_a"} } +counter_b.parentReferences = { "root": {"ref_b"} } +``` + +### Test Steps +```pseudo +msg = build_map_clear("root", "05", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +// ref_a and ref_b removed (timeserial "02" < "05"), newer kept (timeserial "09" > "05") +ASSERT "ref_a" NOT IN map.data +ASSERT "ref_b" NOT IN map.data +ASSERT "primitive" NOT IN map.data +ASSERT "newer" IN map.data +// removeParentReference was called on both child counters +ASSERT "root" NOT IN counter_a.parentReferences OR "ref_a" NOT IN counter_a.parentReferences["root"] +ASSERT "root" NOT IN counter_b.parentReferences OR "ref_b" NOT IN counter_b.parentReferences["root"] +ASSERT update.update == { "ref_a": "removed", "ref_b": "removed", "primitive": "removed" } +ASSERT update.objectMessage == msg +``` + +--- + +## RTLO4e9 - parentReferences: tombstone LiveMap removes parent references for all entries + +**Test ID**: `objects/unit/RTLO4e9/tombstone-map-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4e9a | Before clearing data, for each entry check if it has objectId | +| RTLO4e9b | If entry references a LiveObject, call removeParentReference on the child | + +Tests that when a LiveMap is tombstoned (via OBJECT_DELETE), removeParentReference is called for each entry that references a LiveObject before the data is cleared. + +### Setup +```pseudo +pool = ObjectsPool() +child_counter = LiveCounter(objectId: "counter:child@1000") +child_map = LiveMap(objectId: "map:child@1000", semantics: "LWW") +pool["counter:child@1000"] = child_counter +pool["map:child@1000"] = child_map + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "counter_ref": { data: { objectId: "counter:child@1000" }, timeserial: "01", tombstone: false }, + "map_ref": { data: { objectId: "map:child@1000" }, timeserial: "01", tombstone: false }, + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +map.siteTimeserials = { "site1": "00" } +child_counter.parentReferences = { "root": {"counter_ref"} } +child_map.parentReferences = { "root": {"map_ref"} } +``` + +### 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 == {} +// removeParentReference was called on both children +ASSERT "root" NOT IN child_counter.parentReferences OR "counter_ref" NOT IN child_counter.parentReferences["root"] +ASSERT "root" NOT IN child_map.parentReferences OR "map_ref" NOT IN child_map.parentReferences["root"] +ASSERT update.update == { "counter_ref": "removed", "map_ref": "removed", "name": "removed" } +ASSERT update.tombstone == true +ASSERT update.objectMessage == msg +``` + +--- + +## RTLM7a3, RTLM7g2 - parentReferences: MAP_SET overwriting LiveObject with LiveObject calls both remove and add + +**Test ID**: `objects/unit/RTLM7a3/map-set-replace-objectid-both-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7a3b | removeParentReference called on old child before overwrite | +| RTLM7g2 | addParentReference called on new child after set | + +Tests that both removeParentReference and addParentReference are called in the correct order when replacing one LiveObject reference with another. + +### Setup +```pseudo +pool = ObjectsPool() +old_map = LiveMap(objectId: "map:old@1000", semantics: "LWW") +new_map = LiveMap(objectId: "map:new@2000", semantics: "LWW") +pool["map:old@1000"] = old_map +pool["map:new@2000"] = new_map + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "child": { data: { objectId: "map:old@1000" }, timeserial: "01", tombstone: false } +} +old_map.parentReferences = { "root": {"child"} } +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "child", { objectId: "map:new@2000" }, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["child"].data == { objectId: "map:new@2000" } +// Old child no longer references root +ASSERT "root" NOT IN old_map.parentReferences OR "child" NOT IN old_map.parentReferences["root"] +// New child references root +ASSERT "root" IN new_map.parentReferences +ASSERT "child" IN new_map.parentReferences["root"] +ASSERT update.update == { "child": "updated" } +ASSERT update.objectMessage == msg +``` diff --git a/uts/objects/unit/live_map_api.md b/uts/objects/unit/live_map_api.md new file mode 100644 index 000000000..3babfcb53 --- /dev/null +++ b/uts/objects/unit/live_map_api.md @@ -0,0 +1,501 @@ +# LiveMap API Tests + +Spec points: `RTLM5`, `RTLM10`–`RTLM13`, `RTLM20`–`RTLM21`, `RTLM24`, `RTLMV4`, `RTLCV4` + +## 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 | +|------|-------------| +| RTLM5d2 | Returns value at key, resolved per RTLM5d2 | + +Note: RTLM5b and RTLM5c have been replaced by RTO25. The access API preconditions (OBJECT_SUBSCRIBE mode check and channel state check) are now the caller's responsibility and are tested separately in `objects/unit/rto25_access_preconditions.md`. + +### 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 | +|------|-------------| +| RTLM10d | Returns number of non-tombstoned entries | + +Note: RTLM10b and RTLM10c have been replaced by RTO25. The access API preconditions are now the caller's responsibility and are tested separately in `objects/unit/rto25_access_preconditions.md`. + +### 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 | +|------|-------------| +| RTLM11d | Returns non-tombstoned key-value pairs | + +Note: RTLM11b and RTLM11c have been replaced by RTO25. The access API preconditions are now the caller's responsibility and are tested separately in `objects/unit/rto25_access_preconditions.md`. + +### 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 | +|------|-------------| +| RTLM20a3 | value parameter accepts Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounterValueType, or LiveMapValueType | +| RTLM20e1 | Validates key and value per RTLMV4b and RTLMV4c | +| RTLM20e2 | action set to MAP_SET | +| RTLM20e3 | objectId set to LiveMap's objectId | +| RTLM20e6 | mapSet.key set | +| RTLM20e7c | mapSet.value.string for string value | +| RTLM20h2 | For non-value-type values, MAP_SET ObjectMessage is passed as single element | + +Note: RTLM20b, RTLM20c, and RTLM20d have been replaced by RTO26. The write API preconditions (OBJECT_PUBLISH mode check, channel state check, and echoMessages check) are now the caller's responsibility and are tested separately in `objects/unit/rto26_write_preconditions.md`. + +### 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 generates COUNTER_CREATE + MAP_SET + +**Test ID**: `objects/unit/RTLM20e7g/set-counter-value-type-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20e7g1 | Evaluate LiveCounterValueType per RTLCV4 to generate COUNTER_CREATE ObjectMessage | +| RTLM20e7g2 | Set mapSet.value.objectId to the objectId from the generated ObjectMessage | +| RTLM20h1 | Array contains *_CREATE ObjectMessages followed by MAP_SET ObjectMessage | + +### 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 +``` + +--- + +## RTLM20e7g - set() with LiveMapValueType generates nested CREATE messages + MAP_SET + +**Test ID**: `objects/unit/RTLM20e7g/set-map-value-type-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20e7g1 | Evaluate LiveMapValueType per RTLMV4 to generate ordered list of ObjectMessages | +| RTLM20e7g2 | Set mapSet.value.objectId to the objectId from the final ObjectMessage in the list | +| RTLM20h1 | Array contains *_CREATE ObjectMessages followed by MAP_SET ObjectMessage | + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as RTLM20 set-sends-map-set-0, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.set("nested_map", LiveMap.create({ "key1": "value1" })) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +state = captured_messages[0].state +ASSERT state.length == 2 +ASSERT state[0].operation.action == "MAP_CREATE" +ASSERT state[0].operation.objectId STARTS WITH "map:" +ASSERT state[1].operation.action == "MAP_SET" +ASSERT state[1].operation.mapSet.key == "nested_map" +ASSERT state[1].operation.mapSet.value.objectId == state[0].operation.objectId +``` + +--- + +## RTLM20h1 - set() with nested LiveMapValueType containing LiveCounterValueType + +**Test ID**: `objects/unit/RTLM20h1/set-nested-value-types-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20h1 | Array contains all *_CREATE ObjectMessages followed by MAP_SET | +| RTLMV4d1 | Nested LiveCounterValueType is evaluated per RTLCV4 | +| RTLMV4d2 | Nested LiveMapValueType is recursively evaluated per RTLMV4 | + +Tests that when a LiveMapValueType contains a nested LiveCounterValueType, all CREATE messages appear before the MAP_SET in depth-first order. + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as RTLM20 set-sends-map-set-0, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.set("stats", LiveMap.create({ + "count": LiveCounter.create(0), + "label": "test" +})) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +state = captured_messages[0].state +# Expect: COUNTER_CREATE, MAP_CREATE, MAP_SET (depth-first, then the MAP_SET at root) +ASSERT state.length == 3 +ASSERT state[0].operation.action == "COUNTER_CREATE" +ASSERT state[0].operation.objectId STARTS WITH "counter:" +ASSERT state[1].operation.action == "MAP_CREATE" +ASSERT state[1].operation.objectId STARTS WITH "map:" +ASSERT state[2].operation.action == "MAP_SET" +ASSERT state[2].operation.mapSet.key == "stats" +ASSERT state[2].operation.mapSet.value.objectId == state[1].operation.objectId +``` + +--- + +## RTLM21 - remove() sends MAP_REMOVE message + +**Test ID**: `objects/unit/RTLM21/remove-sends-map-remove-0` + +| Spec | Requirement | +|------|-------------| +| RTLM21e1 | Validates key per RTLMV4b | +| RTLM21e2 | action set to MAP_REMOVE | +| RTLM21e5 | mapRemove.key set | + +Note: RTLM21b, RTLM21c, and RTLM21d have been replaced by RTO26. The write API preconditions are now the caller's responsibility and are tested separately in `objects/unit/rto26_write_preconditions.md`. + +### 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/RTLM21d - set()/remove() write preconditions (replaced by RTO26) + +**Test ID**: `objects/unit/RTLM20d/echo-messages-false-0` + +Note: RTLM20d and RTLM21d have been replaced by RTO26. The write API preconditions (including the echoMessages check) are now the caller's responsibility and are tested separately in `objects/unit/rto26_write_preconditions.md`. + +--- + +## 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" +``` + +--- + +## RTLM20 - Table-driven invalid set value types + +**Test ID**: `objects/unit/RTLM20/set-invalid-values-table-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20e1 | Validates value per RTLMV4c | +| RTLMV4c | Unsupported value types throw 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..db1989b57 --- /dev/null +++ b/uts/objects/unit/live_object_subscribe.md @@ -0,0 +1,385 @@ +# LiveObject Subscribe Tests + +Spec points: `RTLO4b`, `RTLO4b3`, `RTLO4b4c1`, `RTLO4b4c3a`, `RTLO4b4c3c`, `RTLO4b4d`, `RTLO4b4e`, `RTLO4b6`, `RTLO4b7` + +## 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 | +| RTLO4b4c3a | Registered listeners 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 +``` + +--- + +## RTLO4b7 - subscribe returns Subscription with unsubscribe method + +**Test ID**: `objects/unit/RTLO4b7/subscribe-returns-subscription-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4b7 | Returns a Subscription object | + +Tests that `subscribe` returns a `Subscription` object that has an `unsubscribe` method. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +instance = root.get("score").instance() +``` + +### Test Steps +```pseudo +sub = instance.subscribe((event) => {}) +``` + +### Assertions +```pseudo +ASSERT sub IS Subscription +ASSERT sub.unsubscribe IS Function +``` + +--- + +## RTLO4b7 - Subscription#unsubscribe stops delivery + +**Test ID**: `objects/unit/RTLO4b7/subscription-unsubscribe-stops-delivery-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4b7 | Returns a Subscription object | +| RTLO4b4c3a | Registered listeners called with LiveObjectUpdate | + +Tests that calling `unsubscribe()` on the returned `Subscription` deregisters the listener so that subsequent updates do not trigger it. + +### 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 +``` + +--- + +## RTLO4b7 - Subscription#unsubscribe is idempotent + +**Test ID**: `objects/unit/RTLO4b7/subscription-unsubscribe-idempotent-0` + +**Spec requirement:** Calling `Subscription#unsubscribe()` multiple times must not throw or produce errors. + +### 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() +sub.unsubscribe() +``` + +### Assertions +```pseudo +// No error thrown — both calls complete without error +``` + +--- + +## 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) + +# Serial "02" passes the newness check (RTLO4a6); the zero increment is the noop +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 0, "02", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 +``` + +--- + +## 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 +``` + +--- + +## RTLO4b4c3c - tombstone update deregisters all Instance#subscribe listeners + +**Test ID**: `objects/unit/RTLO4b4c3c/tombstone-deregisters-listeners-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4b4c3c | If LiveObjectUpdate.tombstone is true, deregister all listeners | +| RTLO4b4c3a | Listeners are called with the tombstone update itself before deregistration | + +Tests that when a tombstone update is emitted, all registered listeners are called with the update, but subsequent updates do not fire any listener because they have been deregistered. Tested through Instance#subscribe (RTINS16); the tombstone is identified by `message.operation.action == "OBJECT_DELETE"`. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates_a = [] +updates_b = [] +instance = root.get("score").instance() +instance.subscribe((event) => updates_a.append(event)) +instance.subscribe((event) => updates_b.append(event)) +``` + +### Test Steps +```pseudo +# Send an OBJECT_DELETE which causes a tombstone +mock_ws.send_to_client(build_object_message("test", [ + build_object_delete("counter:score@1000", "50", "remote") +])) +poll_until(updates_a.length >= 1, timeout: 5s) + +# Both listeners should have received the tombstone update +ASSERT updates_a.length == 1 +ASSERT updates_a[0].message.operation.action == "OBJECT_DELETE" +ASSERT updates_b.length == 1 +ASSERT updates_b[0].message.operation.action == "OBJECT_DELETE" + +# Send another update — listeners should have been deregistered by tombstone +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 3, "51", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT updates_a.length == 1 +ASSERT updates_b.length == 1 +``` + +--- + +## RTLO4b4d - InstanceSubscriptionEvent.message is populated from source ObjectMessage + +**Test ID**: `objects/unit/RTLO4b4d/update-has-object-message-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4b4d | The update carries the source ObjectMessage | +| RTINS16e | InstanceSubscriptionEvent.message is a PublicAPI::ObjectMessage | + +Tests that when an update is triggered by an incoming ObjectMessage, the `InstanceSubscriptionEvent.message` field is populated with the public ObjectMessage. Tested through Instance#subscribe (RTINS16). + +### 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") +])) +poll_until(updates.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 +ASSERT updates[0].message IS NOT null +ASSERT updates[0].message.serial == "99" +ASSERT updates[0].message.siteCode == "remote" +ASSERT updates[0].message.operation.action == "COUNTER_INC" +ASSERT updates[0].message.operation.objectId == "counter:score@1000" +``` + +--- + +## RTLO4b4e - tombstone update identified by OBJECT_DELETE action + +**Test ID**: `objects/unit/RTLO4b4e/tombstone-flag-true-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4b4e | Tombstone update emitted when LiveObject is tombstoned | + +Tests that when a `LiveObject` is tombstoned (e.g. via OBJECT_DELETE), the emitted event carries an OBJECT_DELETE operation. Tested through Instance#subscribe (RTINS16). + +### 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_object_delete("counter:score@1000", "50", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 +ASSERT updates[0].message.operation.action == "OBJECT_DELETE" +``` + +--- + +## RTLO4b4e - normal update carries non-tombstone action + +**Test ID**: `objects/unit/RTLO4b4e/tombstone-flag-false-0` + +**Spec requirement:** Normal (non-tombstone) updates carry a regular operation action. + +Tests that for a normal update, the event carries a COUNTER_INC action (not OBJECT_DELETE). Tested through Instance#subscribe (RTINS16). + +### 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") +])) +poll_until(updates.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 +ASSERT updates[0].message.operation.action == "COUNTER_INC" +``` 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..092fe6307 --- /dev/null +++ b/uts/objects/unit/objects_pool.md @@ -0,0 +1,1141 @@ +# 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, without populating objectMessage | +| 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" } +ASSERT updates[0].objectMessage IS null +``` + +--- + +## 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() +realtime_object = RealtimeObject(pool: pool) +realtime_object.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 realtime_object.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() +realtime_object = RealtimeObject(pool: pool) +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 realtime_object.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() +realtime_object = RealtimeObject(pool: pool) +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 realtime_object.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() +realtime_object = RealtimeObject(pool: pool) +pool.syncState = SYNCED +pool["counter:abc@1000"] = LiveCounter(objectId: "counter:abc@1000") +pool["counter:abc@1000"].data = 10 +realtime_object.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 realtime_object.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() +realtime_object = RealtimeObject(pool: pool) +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 realtime_object.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() +realtime_object = RealtimeObject(pool: pool) +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 realtime_object.bufferedObjectOperations.length == 1 +``` + +### Test Steps +```pseudo +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +``` + +### Assertions +```pseudo +ASSERT realtime_object.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() +realtime_object = RealtimeObject(pool: pool) +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 realtime_object.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() +realtime_object = RealtimeObject(pool: pool) +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 realtime_object.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" } +``` + +--- + +## RTO5c10 - Sync completion rebuilds parentReferences + +**Test ID**: `objects/unit/RTO5c10/sync-rebuilds-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c10 | Rebuild every parentReferences map after sync completion | +| RTO5c10a | For each LiveObject in ObjectsPool, reset parentReferences to empty map (RTLO3f2) | +| RTO5c10b | For each LiveMap, iterate entries (RTLM11); for each entry whose value is a LiveObject, call addParentReference(parent, key) per RTLO4g | + +Tests that after a normal sync, each LiveObject in the pool has correct parentReferences matching its position in the synced tree. + +### 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: { + "score": { data: { objectId: "counter:score@1000" }, timeserial: "t:0" }, + "profile": { data: { objectId: "map:profile@1000" }, timeserial: "t:0" }, + "name": { data: { string: "Alice" }, 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: { + "nested_counter": { data: { objectId: "counter:nested@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 } } + }) +])) +``` + +### Assertions +```pseudo +# root is not referenced by any parent +ASSERT pool["root"].parentReferences == {} + +# counter:score@1000 is referenced by root at key "score" +ASSERT pool["counter:score@1000"].parentReferences == { "root": {"score"} } + +# map:profile@1000 is referenced by root at key "profile" +ASSERT pool["map:profile@1000"].parentReferences == { "root": {"profile"} } + +# counter:nested@1000 is referenced by map:profile@1000 at key "nested_counter" +ASSERT pool["counter:nested@1000"].parentReferences == { "map:profile@1000": {"nested_counter"} } + +# Primitive-valued entries ("name") do not appear in any parentReferences +``` + +--- + +## RTO5c10 - Re-sync rebuilds parentReferences with new tree structure + +**Test ID**: `objects/unit/RTO5c10/resync-rebuilds-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c10a | Reset parentReferences to empty map before rebuilding | +| RTO5c10b | Rebuild from current LiveMap entries after sync completion | + +Tests that after a second sync sequence with a different tree structure, parentReferences are reset then rebuilt to reflect the new tree, not the old one. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) + +# First sync: counter:abc@1000 is a child of root +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "counter_key": { data: { objectId: "counter:abc@1000" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { + counter: { count: 10 }, + createOp: { counterCreate: { count: 10 } } + }) +])) + +# Verify first sync parentReferences +ASSERT pool["counter:abc@1000"].parentReferences == { "root": {"counter_key"} } +``` + +### Test Steps +```pseudo +# Second sync: counter:abc@1000 is now a child of map:wrapper@1000, not root +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:1"}, { + map: { + semantics: "LWW", + entries: { + "wrapper": { data: { objectId: "map:wrapper@1000" }, timeserial: "t:1" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("map:wrapper@1000", {"aaa": "t:1"}, { + map: { + semantics: "LWW", + entries: { + "moved_counter": { data: { objectId: "counter:abc@1000" }, timeserial: "t:1" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:1"}, { + counter: { count: 20 }, + createOp: { counterCreate: { count: 20 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED + +# root is not referenced by any parent +ASSERT pool["root"].parentReferences == {} + +# map:wrapper@1000 is now a child of root at key "wrapper" +ASSERT pool["map:wrapper@1000"].parentReferences == { "root": {"wrapper"} } + +# counter:abc@1000 is now a child of map:wrapper@1000, NOT of root +ASSERT pool["counter:abc@1000"].parentReferences == { "map:wrapper@1000": {"moved_counter"} } +``` + +--- + +## RTO5c10 - Empty sync leaves root with empty parentReferences + +**Test ID**: `objects/unit/RTO5c10/empty-sync-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c10a | Reset parentReferences to empty map | +| RTO4b | ATTACHED without HAS_OBJECTS performs immediate sync completion | + +Tests that after an empty sync (no HAS_OBJECTS flag), root has empty parentReferences because there are no children to reference it. + +### Setup +```pseudo +pool = ObjectsPool() + +# First, do a normal sync to populate parentReferences +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "child": { data: { objectId: "counter:child@1000" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:child@1000", {"aaa": "t:0"}, { + counter: { count: 1 }, + createOp: { counterCreate: { count: 1 } } + }) +])) + +# Verify parentReferences are populated after first sync +ASSERT pool["counter:child@1000"].parentReferences == { "root": {"child"} } +``` + +### Test Steps +```pseudo +# Empty sync: ATTACHED without HAS_OBJECTS +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", flags: 0 +)) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED + +# counter:child@1000 was removed from pool (RTO4b1) +ASSERT "counter:child@1000" NOT IN pool + +# root exists with empty data and empty parentReferences +ASSERT "root" IN pool +ASSERT pool["root"].data == {} +ASSERT pool["root"].parentReferences == {} +``` diff --git a/uts/objects/unit/parent_references.md b/uts/objects/unit/parent_references.md new file mode 100644 index 000000000..33d4d74e6 --- /dev/null +++ b/uts/objects/unit/parent_references.md @@ -0,0 +1,734 @@ +# Parent References Tests + +Spec points: `RTLO3f`, `RTLO4g`, `RTLO4h`, `RTLO4f`, `RTO5c10` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +Tests the `parentReferences` tracking on `LiveObject`, the `addParentReference` and `removeParentReference` methods, the `getFullPaths` graph traversal, and the post-sync rebuild of parentReferences by the ObjectsPool. + +`parentReferences` is a `Dict>` keyed by parent LiveMap objectId, with each value being the set of keys at which that LiveMap references this LiveObject. These references allow `getFullPaths` to determine every key-path from root to a given object in the LiveObjects graph. + +Tests operate directly on LiveObject/LiveCounter/LiveMap instances and on ObjectsPool for the post-sync rebuild tests. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for builder functions and STANDARD_POOL_OBJECTS. + +--- + +## RTLO3f2 - parentReferences initialized to empty map on LiveCounter + +**Test ID**: `objects/unit/RTLO3f2/init-empty-counter-0` + +**Spec requirement:** parentReferences is set to an empty map when the LiveObject is initialized. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Assertions +```pseudo +ASSERT counter.parentReferences == {} +``` + +--- + +## RTLO3f2 - parentReferences initialized to empty map on LiveMap + +**Test ID**: `objects/unit/RTLO3f2/init-empty-map-0` + +**Spec requirement:** parentReferences is set to an empty map when the LiveObject is initialized. + +### Setup +```pseudo +map = LiveMap(objectId: "map:abc@1000", semantics: "LWW") +``` + +### Assertions +```pseudo +ASSERT map.parentReferences == {} +``` + +--- + +## RTLO4g2 - addParentReference creates new entry for first reference + +**Test ID**: `objects/unit/RTLO4g2/first-reference-new-entry-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4g2 | If parentReferences does not contain an entry for parent.objectId, insert a new entry with a set containing only key | + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +``` + +### Test Steps +```pseudo +child.addParentReference(parent, "score") +``` + +### Assertions +```pseudo +ASSERT "map:parent@1000" IN child.parentReferences +ASSERT child.parentReferences["map:parent@1000"] == {"score"} +``` + +--- + +## RTLO4g1 - addParentReference adds key to existing entry for same parent + +**Test ID**: `objects/unit/RTLO4g1/second-key-same-parent-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4g1 | If parentReferences already contains an entry for parent.objectId, add key to that entry's set | + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +child.parentReferences = { "map:parent@1000": {"score"} } +``` + +### Test Steps +```pseudo +child.addParentReference(parent, "points") +``` + +### Assertions +```pseudo +ASSERT child.parentReferences["map:parent@1000"] == {"score", "points"} +``` + +--- + +## RTLO4g - addParentReference with different parent creates separate entry + +**Test ID**: `objects/unit/RTLO4g/different-parent-separate-entry-0` + +**Spec requirement:** Each parent LiveMap gets its own entry in parentReferences. + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent_a = LiveMap(objectId: "map:a@1000", semantics: "LWW") +parent_b = LiveMap(objectId: "map:b@1000", semantics: "LWW") +``` + +### Test Steps +```pseudo +child.addParentReference(parent_a, "x") +child.addParentReference(parent_b, "y") +``` + +### Assertions +```pseudo +ASSERT child.parentReferences["map:a@1000"] == {"x"} +ASSERT child.parentReferences["map:b@1000"] == {"y"} +``` + +--- + +## RTLO4g - addParentReference with multiple parents and multiple keys + +**Test ID**: `objects/unit/RTLO4g/multiple-parents-multiple-keys-0` + +**Spec requirement:** parentReferences correctly tracks multiple keys across multiple parents. + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent_a = LiveMap(objectId: "map:a@1000", semantics: "LWW") +parent_b = LiveMap(objectId: "map:b@1000", semantics: "LWW") +``` + +### Test Steps +```pseudo +child.addParentReference(parent_a, "x") +child.addParentReference(parent_a, "y") +child.addParentReference(parent_b, "p") +child.addParentReference(parent_b, "q") +``` + +### Assertions +```pseudo +ASSERT child.parentReferences["map:a@1000"] == {"x", "y"} +ASSERT child.parentReferences["map:b@1000"] == {"p", "q"} +``` + +--- + +## RTLO4h1 - removeParentReference no-op for non-existent parent + +**Test ID**: `objects/unit/RTLO4h1/nonexistent-parent-noop-0` + +**Spec requirement:** If parentReferences does not contain an entry for parent.objectId, do nothing. + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +``` + +### Test Steps +```pseudo +child.removeParentReference(parent, "score") +``` + +### Assertions +```pseudo +ASSERT child.parentReferences == {} +``` + +--- + +## RTLO4h2 - removeParentReference removes key but leaves other keys + +**Test ID**: `objects/unit/RTLO4h2/remove-key-leaves-others-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4h2 | Remove key from that entry's set | + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +child.parentReferences = { "map:parent@1000": {"score", "points"} } +``` + +### Test Steps +```pseudo +child.removeParentReference(parent, "score") +``` + +### Assertions +```pseudo +ASSERT child.parentReferences["map:parent@1000"] == {"points"} +``` + +--- + +## RTLO4h3 - removeParentReference removes entry when set becomes empty + +**Test ID**: `objects/unit/RTLO4h3/remove-last-key-removes-entry-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4h2 | Remove key from that entry's set | +| RTLO4h3 | If the entry's set is empty after removal, remove the entry from parentReferences | + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +child.parentReferences = { "map:parent@1000": {"score"} } +``` + +### Test Steps +```pseudo +child.removeParentReference(parent, "score") +``` + +### Assertions +```pseudo +ASSERT "map:parent@1000" NOT IN child.parentReferences +ASSERT child.parentReferences == {} +``` + +--- + +## RTLO4h - removeParentReference for non-existent key in existing parent + +**Test ID**: `objects/unit/RTLO4h/remove-nonexistent-key-0` + +**Spec requirement:** Removing a key that does not exist in the parent's set does not alter the existing keys. + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +child.parentReferences = { "map:parent@1000": {"score"} } +``` + +### Test Steps +```pseudo +child.removeParentReference(parent, "nonexistent") +``` + +### Assertions +```pseudo +ASSERT child.parentReferences["map:parent@1000"] == {"score"} +``` + +--- + +## RTLO4f2 - getFullPaths for root returns empty key-path + +**Test ID**: `objects/unit/RTLO4f2/root-returns-empty-path-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4f2 | The empty simple path (which exists only when this LiveObject is itself root) contributes the empty key-path [] | + +### Setup +```pseudo +pool = ObjectsPool() +root = pool["root"] +``` + +### Assertions +```pseudo +paths = root.getFullPaths() +ASSERT paths.length == 1 +ASSERT paths CONTAINS [] +``` + +--- + +## RTLO4f - getFullPaths for direct child of root + +**Test ID**: `objects/unit/RTLO4f/direct-child-single-path-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4f1 | Graph G has directed edges from parent to child labelled with key, derived from parentReferences | +| RTLO4f2 | Each simple path from root to this LiveObject contributes one key-path | + +Tests that a LiveObject referenced directly from root at key "score" returns [["score"]]. + +### Setup +```pseudo +pool = ObjectsPool() +counter = LiveCounter(objectId: "counter:score@1000") +pool["counter:score@1000"] = counter + +root = pool["root"] +counter.addParentReference(root, "score") +``` + +### Assertions +```pseudo +paths = counter.getFullPaths() +ASSERT paths.length == 1 +ASSERT paths CONTAINS ["score"] +``` + +--- + +## RTLO4f - getFullPaths for deeply nested object + +**Test ID**: `objects/unit/RTLO4f/deep-nesting-0` + +**Spec requirement:** getFullPaths traverses multiple levels of parentReferences to find all key-paths from root. + +Tests the path root --"profile"--> map:profile --"prefs"--> map:prefs --"theme_counter"--> counter:theme. + +### Setup +```pseudo +pool = ObjectsPool() +root = pool["root"] + +profile = LiveMap(objectId: "map:profile@1000", semantics: "LWW") +pool["map:profile@1000"] = profile +profile.addParentReference(root, "profile") + +prefs = LiveMap(objectId: "map:prefs@1000", semantics: "LWW") +pool["map:prefs@1000"] = prefs +prefs.addParentReference(profile, "prefs") + +theme_counter = LiveCounter(objectId: "counter:theme@1000") +pool["counter:theme@1000"] = theme_counter +theme_counter.addParentReference(prefs, "theme_counter") +``` + +### Assertions +```pseudo +paths = theme_counter.getFullPaths() +ASSERT paths.length == 1 +ASSERT paths CONTAINS ["profile", "prefs", "theme_counter"] +``` + +--- + +## RTLO4f - getFullPaths with multiple parents (diamond graph) + +**Test ID**: `objects/unit/RTLO4f/diamond-graph-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4f2 | Each simple path from root to this LiveObject contributes one key-path | +| RTLO4f3 | Each key-path appears exactly once; order is unspecified | + +Tests a diamond: root --"a"--> map:A --"x"--> counter:leaf, and root --"b"--> map:B --"y"--> counter:leaf. + +### Setup +```pseudo +pool = ObjectsPool() +root = pool["root"] + +map_a = LiveMap(objectId: "map:a@1000", semantics: "LWW") +pool["map:a@1000"] = map_a +map_a.addParentReference(root, "a") + +map_b = LiveMap(objectId: "map:b@1000", semantics: "LWW") +pool["map:b@1000"] = map_b +map_b.addParentReference(root, "b") + +leaf = LiveCounter(objectId: "counter:leaf@1000") +pool["counter:leaf@1000"] = leaf +leaf.addParentReference(map_a, "x") +leaf.addParentReference(map_b, "y") +``` + +### Assertions +```pseudo +paths = leaf.getFullPaths() +ASSERT paths.length == 2 +ASSERT paths CONTAINS ["a", "x"] +ASSERT paths CONTAINS ["b", "y"] +``` + +--- + +## RTLO4f - getFullPaths with single parent referencing at multiple keys + +**Test ID**: `objects/unit/RTLO4f/single-parent-multiple-keys-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4f2 | Each simple path from root contributes one key-path | +| RTLO4f3 | Each key-path appears exactly once | + +Tests that when a parent map references the same child at two different keys, two distinct key-paths are returned. + +### Setup +```pseudo +pool = ObjectsPool() +root = pool["root"] + +child = LiveCounter(objectId: "counter:child@1000") +pool["counter:child@1000"] = child +child.addParentReference(root, "primary") +child.addParentReference(root, "alias") +``` + +### Assertions +```pseudo +paths = child.getFullPaths() +ASSERT paths.length == 2 +ASSERT paths CONTAINS ["primary"] +ASSERT paths CONTAINS ["alias"] +``` + +--- + +## RTLO4f - getFullPaths for orphan returns empty list + +**Test ID**: `objects/unit/RTLO4f/orphan-returns-empty-0` + +**Spec requirement:** An object with no parentReferences path leading to root has no key-paths. + +### Setup +```pseudo +pool = ObjectsPool() + +orphan = LiveCounter(objectId: "counter:orphan@1000") +pool["counter:orphan@1000"] = orphan +``` + +### Assertions +```pseudo +paths = orphan.getFullPaths() +ASSERT paths.length == 0 +``` + +--- + +## RTLO4f - getFullPaths suppresses cycles + +**Test ID**: `objects/unit/RTLO4f/cycle-suppression-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4f2 | A simple path visits each node at most once | +| RTLO4f4 | (non-normative) Typical approach skips branches that would revisit a node | + +Tests that a cycle in parentReferences does not cause infinite traversal. Graph: root --"a"--> map:A --"b"--> map:B --"a"--> map:A (cycle). The only valid simple path to map:B is ["a", "b"]. + +### Setup +```pseudo +pool = ObjectsPool() +root = pool["root"] + +map_a = LiveMap(objectId: "map:a@1000", semantics: "LWW") +pool["map:a@1000"] = map_a +map_a.addParentReference(root, "a") + +map_b = LiveMap(objectId: "map:b@1000", semantics: "LWW") +pool["map:b@1000"] = map_b +map_b.addParentReference(map_a, "b") + +# Create a cycle: map:A also has map:B as a parent +map_a.addParentReference(map_b, "a") +``` + +### Assertions +```pseudo +paths_b = map_b.getFullPaths() +ASSERT paths_b.length == 1 +ASSERT paths_b CONTAINS ["a", "b"] + +paths_a = map_a.getFullPaths() +ASSERT paths_a.length == 1 +ASSERT paths_a CONTAINS ["a"] +``` + +--- + +## RTLO4f - getFullPaths with complex diamond and deep nesting + +**Test ID**: `objects/unit/RTLO4f/complex-diamond-deep-0` + +**Spec requirement:** getFullPaths returns all distinct simple paths from root, including through multiple intermediate nodes. + +Tests a graph where root has two branches that converge on a deeply nested object: +- root --"left"--> map:L --"mid"--> map:M --"target"--> counter:T +- root --"right"--> map:R --"target"--> counter:T + +### Setup +```pseudo +pool = ObjectsPool() +root = pool["root"] + +map_l = LiveMap(objectId: "map:l@1000", semantics: "LWW") +pool["map:l@1000"] = map_l +map_l.addParentReference(root, "left") + +map_r = LiveMap(objectId: "map:r@1000", semantics: "LWW") +pool["map:r@1000"] = map_r +map_r.addParentReference(root, "right") + +map_m = LiveMap(objectId: "map:m@1000", semantics: "LWW") +pool["map:m@1000"] = map_m +map_m.addParentReference(map_l, "mid") + +target = LiveCounter(objectId: "counter:t@1000") +pool["counter:t@1000"] = target +target.addParentReference(map_m, "target") +target.addParentReference(map_r, "target") +``` + +### Assertions +```pseudo +paths = target.getFullPaths() +ASSERT paths.length == 2 +ASSERT paths CONTAINS ["left", "mid", "target"] +ASSERT paths CONTAINS ["right", "target"] +``` + +--- + +## RTO5c10 - Post-sync rebuild populates parentReferences from LiveMap entries + +**Test ID**: `objects/unit/RTO5c10/rebuild-from-sync-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c10a | For each LiveObject in the ObjectsPool, reset parentReferences to empty map | +| RTO5c10b | For each LiveMap, iterate entries; for each entry whose value is a LiveObject, call addParentReference on that LiveObject | + +Tests that after a sync completes, parentReferences are rebuilt from the LiveMap entries received during sync. + +### 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: { + "score": { data: { objectId: "counter:score@1000" }, timeserial: "t:0" }, + "profile": { data: { objectId: "map:profile@1000" }, 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: { + "nested": { data: { objectId: "counter:nested@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 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED + +# counter:score@1000 is referenced by root at key "score" +score = pool["counter:score@1000"] +ASSERT score.parentReferences["root"] == {"score"} + +# map:profile@1000 is referenced by root at key "profile" +profile = pool["map:profile@1000"] +ASSERT profile.parentReferences["root"] == {"profile"} + +# counter:nested@1000 is referenced by map:profile@1000 at key "nested" +nested = pool["counter:nested@1000"] +ASSERT nested.parentReferences["map:profile@1000"] == {"nested"} + +# root has no parent references +ASSERT pool["root"].parentReferences == {} + +# getFullPaths works correctly after rebuild +ASSERT score.getFullPaths() CONTAINS ["score"] +ASSERT nested.getFullPaths() CONTAINS ["profile", "nested"] +``` + +--- + +## RTO5c10a - Post-sync rebuild clears stale parentReferences + +**Test ID**: `objects/unit/RTO5c10a/rebuild-clears-stale-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c10a | For each LiveObject, reset parentReferences to the initial value (empty map) | +| RTO5c10b | Then rebuild from current LiveMap entries | + +Tests that parentReferences from a previous sync are cleared and rebuilt from the new sync data, even when objects are reused across syncs. + +### Setup +```pseudo +pool = ObjectsPool() + +# First sync: root --"score"--> counter:abc@1000 +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "score": { data: { objectId: "counter:abc@1000" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { + counter: { count: 10 }, + createOp: { counterCreate: { count: 10 } } + }) +])) +ASSERT pool["counter:abc@1000"].parentReferences["root"] == {"score"} +``` + +### Test Steps +```pseudo +# Second sync: root --"points"--> counter:abc@1000 (key changed from "score" to "points") +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:1"}, { + map: { + semantics: "LWW", + entries: { + "points": { data: { objectId: "counter:abc@1000" }, timeserial: "t:1" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:1"}, { + counter: { count: 20 }, + createOp: { counterCreate: { count: 20 } } + }) +])) +``` + +### Assertions +```pseudo +counter = pool["counter:abc@1000"] + +# Old "score" reference should be gone, replaced by "points" +ASSERT counter.parentReferences["root"] == {"points"} +ASSERT counter.getFullPaths() CONTAINS ["points"] + +paths = counter.getFullPaths() +ASSERT paths.length == 1 +``` + +--- + +## RTO5c10 - Post-sync unreferenced objects have empty parentReferences + +**Test ID**: `objects/unit/RTO5c10/unreferenced-empty-refs-0` + +**Spec requirement:** Objects that exist in the pool but are not referenced by any LiveMap entry have empty parentReferences after rebuild. + +### 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:orphan@1000", {"aaa": "t:0"}, { + counter: { count: 42 }, + createOp: { counterCreate: { count: 42 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED + +# The counter exists in the pool but no LiveMap entry points to it +orphan = pool["counter:orphan@1000"] +ASSERT orphan.parentReferences == {} + +# getFullPaths returns empty list for unreferenced object +ASSERT orphan.getFullPaths().length == 0 +``` diff --git a/uts/objects/unit/path_object.md b/uts/objects/unit/path_object.md new file mode 100644 index 000000000..96d989754 --- /dev/null +++ b/uts/objects/unit/path_object.md @@ -0,0 +1,765 @@ +# 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 | +|------|-------------| +| RTPO7a | Checks access API preconditions per RTO25 | +| RTPO7c | LiveCounter -> delegates to LiveCounter#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 | +|------|-------------| +| RTPO7a | Checks access API preconditions per RTO25 | +| RTPO7d | Primitive -> returns 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 | +|------|-------------| +| RTPO7a | Checks access API preconditions per RTO25 | +| RTPO7e | 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 | +|------|-------------| +| RTPO7a | Checks access API preconditions per RTO25 | +| RTPO7f | Resolution failure -> returns null per RTPO3c1 | + +### 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 | +|------|-------------| +| RTPO8a | Checks access API preconditions per RTO25 | +| RTPO8c | LiveObject -> 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 | +|------|-------------| +| RTPO8a | Checks access API preconditions per RTO25 | +| RTPO8d | 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() returns array of [key, PathObject] pairs + +**Test ID**: `objects/unit/RTPO9/entries-yields-pairs-0` + +| Spec | Requirement | +|------|-------------| +| RTPO9a | Checks access API preconditions per RTO25 | +| RTPO9c | Uses LiveMap#keys (RTLM12) to get keys, returns array of [key, PathObject] pairs | +| RTPO9d | Only non-tombstoned entries (tombstoned excluded by LiveMap#keys) | + +### 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 array for non-LiveMap + +**Test ID**: `objects/unit/RTPO9d/entries-non-map-empty-0` + +| Spec | Requirement | +|------|-------------| +| RTPO9a | Checks access API preconditions per RTO25 | +| RTPO9d | Not LiveMap or resolution failure -> returns empty array | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +entries = root.get("score").entries() +``` + +### Assertions +```pseudo +ASSERT entries.length == 0 +``` + +--- + +## RTPO10 - keys() returns array of key strings + +**Test ID**: `objects/unit/RTPO10/keys-returns-array-0` + +| Spec | Requirement | +|------|-------------| +| RTPO10a | Checks access API preconditions per RTO25 | +| RTPO10c | LiveMap -> delegates to LiveMap#keys (RTLM12) | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +keys = root.keys() +``` + +### Assertions +```pseudo +ASSERT keys IS Array +ASSERT keys.length == 7 +ASSERT "name" IN keys +ASSERT "profile" IN keys +ASSERT "score" IN keys +``` + +--- + +## RTPO10d - keys() returns empty array for non-LiveMap + +**Test ID**: `objects/unit/RTPO10d/keys-non-map-empty-0` + +| Spec | Requirement | +|------|-------------| +| RTPO10a | Checks access API preconditions per RTO25 | +| RTPO10d | Not LiveMap or resolution failure -> returns empty array | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +keys = root.get("score").keys() +``` + +### Assertions +```pseudo +ASSERT keys IS Array +ASSERT keys.length == 0 +``` + +--- + +## RTPO11 - values() returns array of PathObjects + +**Test ID**: `objects/unit/RTPO11/values-returns-array-0` + +| Spec | Requirement | +|------|-------------| +| RTPO11a | Checks access API preconditions per RTO25 | +| RTPO11c | LiveMap -> uses LiveMap#keys (RTLM12) and returns array of PathObjects | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +vals = root.values() +``` + +### Assertions +```pseudo +ASSERT vals IS Array +ASSERT vals.length == 7 +// Each element is a PathObject whose path is the key +paths = {} +FOR v IN vals: + paths[v.path()] = true +ASSERT paths["name"] == true +ASSERT paths["profile"] == true +ASSERT paths["score"] == true +``` + +--- + +## RTPO11d - values() returns empty array for non-LiveMap + +**Test ID**: `objects/unit/RTPO11d/values-non-map-empty-0` + +| Spec | Requirement | +|------|-------------| +| RTPO11a | Checks access API preconditions per RTO25 | +| RTPO11d | Not LiveMap or resolution failure -> returns empty array | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +vals = root.get("score").values() +``` + +### Assertions +```pseudo +ASSERT vals IS Array +ASSERT vals.length == 0 +``` + +--- + +## RTPO12 - size() returns non-tombstoned count + +**Test ID**: `objects/unit/RTPO12/size-count-0` + +| Spec | Requirement | +|------|-------------| +| RTPO12a | Checks access API preconditions per RTO25 | +| RTPO12c | LiveMap -> delegates to LiveMap#size (RTLM10) | + +### 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` + +| Spec | Requirement | +|------|-------------| +| RTPO12a | Checks access API preconditions per RTO25 | +| RTPO12d | Not LiveMap or resolution failure -> returns null | + +### 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 | +|------|-------------| +| RTPO13a | Checks access API preconditions per RTO25 | +| RTPO13c1 | Each entry included, tombstoned excluded | +| RTPO13c2 | Nested LiveMap recursively compacted | +| RTPO13c3 | Nested LiveCounter resolved to number | +| RTPO13c4 | 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 | +|------|-------------| +| RTPO13a | Checks access API preconditions per RTO25 | +| RTPO13c5 | Cyclic references reuse 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` + +| Spec | Requirement | +|------|-------------| +| RTPO13a | Checks access API preconditions per RTO25 | +| RTPO13d | LiveCounter -> returns numeric value | + +### 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 | +|------|-------------| +| RTPO14a | Checks access API preconditions per RTO25 | +| RTPO14b1 | Binary as base64 strings | +| RTPO14b2 | 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 | +|------|-------------| +| RTPO7a | Checks access API preconditions per RTO25 | +| RTPO7d | Primitive (Binary) -> returns 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 | +|------|-------------| +| RTPO14a | Checks access API preconditions per RTO25 | +| RTPO14b1 | Binary values encoded as base64 strings | + +### 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..43e8f2d59 --- /dev/null +++ b/uts/objects/unit/path_object_mutations.md @@ -0,0 +1,358 @@ +# 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 | Checks write API preconditions per RTO26 | +| RTPO15c | Resolves path, on failure throws RTPO3c2 | +| RTPO15d | LiveMap -> delegates to LiveMap#set (RTLM20) | + +### 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` + +| Spec | Requirement | +|------|-------------| +| RTPO15a2 | value accepts same types as LiveMap#set (RTLM20): primitives and LiveCounterValueType/LiveMapValueType | +| RTPO15b | Checks write API preconditions per RTO26 | + +### 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 | +|------|-------------| +| RTPO15b | Checks write API preconditions per RTO26 | +| RTPO15e | Not LiveMap -> throws 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 | Checks write API preconditions per RTO26 | +| RTPO16c | Resolves path, on failure throws RTPO3c2 | +| RTPO16d | LiveMap -> delegates to LiveMap#remove (RTLM21) | + +### 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 | +|------|-------------| +| RTPO16b | Checks write API preconditions per RTO26 | +| RTPO16e | Not LiveMap -> throws 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 | Checks write API preconditions per RTO26 | +| RTPO17c | Resolves path, on failure throws RTPO3c2 | +| RTPO17d | LiveCounter -> delegates to LiveCounter#increment (RTLC12) | + +### 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 | +|------|-------------| +| RTPO17a1 | amount defaults to 1 | +| RTPO17b | Checks write API preconditions per RTO26 | + +### 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 | +|------|-------------| +| RTPO17b | Checks write API preconditions per RTO26 | +| RTPO17e | Not LiveCounter -> throws 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 | Checks write API preconditions per RTO26 | +| RTPO18c | Resolves path, on failure throws RTPO3c2 | +| RTPO18d | LiveCounter -> delegates to LiveCounter#decrement (RTLC13) | + +### 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 | +|------|-------------| +| RTPO18a1 | amount defaults to 1 | +| RTPO18b | Checks write API preconditions per RTO26 | + +### 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 | +|------|-------------| +| RTPO18b | Checks write API preconditions per RTO26 | +| RTPO18e | Not LiveCounter -> throws 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 | +|------|-------------| +| RTPO15b | Checks write API preconditions per RTO26 | +| RTPO3c2 | Write operations on unresolvable path throw ErrorInfo with statusCode 400, code 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 +ASSERT error.statusCode == 400 +``` + +--- + +## RTPO3c2 - increment() on unresolvable path throws 92005 + +**Test ID**: `objects/unit/RTPO3c2/increment-unresolvable-throws-0` + +| Spec | Requirement | +|------|-------------| +| RTPO17b | Checks write API preconditions per RTO26 | +| RTPO3c2 | Write operations on unresolvable path throw ErrorInfo with statusCode 400, code 92005 | + +### 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 +ASSERT error.statusCode == 400 +``` diff --git a/uts/objects/unit/path_object_subscribe.md b/uts/objects/unit/path_object_subscribe.md new file mode 100644 index 000000000..ef89a6096 --- /dev/null +++ b/uts/objects/unit/path_object_subscribe.md @@ -0,0 +1,804 @@ +# PathObject Subscribe Tests + +Spec points: `RTPO19`, `RTO24`, `RTO25` + +## 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 | +|------|-------------| +| RTPO19d | Returns Subscription object | +| RTPO19e1 | Event.object is a PathObject pointing to change path | +| RTPO19e2 | Event.message is the PublicAPI::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 +ASSERT events[0].message.serial == "99" +ASSERT events[0].message.siteCode == "remote" +ASSERT events[0].message.operation IS NOT null +ASSERT events[0].message.operation.action == "COUNTER_INC" +ASSERT events[0].message.channel == "test" +``` + +--- + +## RTPO19b - subscribe() checks RTO25 access API preconditions on DETACHED channel + +**Test ID**: `objects/unit/RTPO19b/subscribe-precondition-detached-0` + +| Spec | Requirement | +|------|-------------| +| RTPO19b | Checks the access API preconditions per RTO25 | +| RTO25b | If channel is DETACHED or FAILED, throw ErrorInfo with statusCode 400 and code 90001 | + +Tests that subscribe() on a DETACHED channel throws 90001. + +### Setup +```pseudo +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 == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: { key: "fake:key", autoConnect: true }) +channel = client.channels.get("test", { + modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] +}) +root = AWAIT channel.object.get() + +AWAIT channel.detach() +AWAIT_STATE channel.state == DETACHED +``` + +### Test Steps +```pseudo +root.subscribe((event) => {}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 90001 +ASSERT error.statusCode == 400 +``` + +--- + +## RTPO19c1a - subscribe() with non-positive depth throws 40003 + +**Test ID**: `objects/unit/RTPO19c1a/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 +``` + +--- + +## RTPO19c1a - subscribe() with negative depth throws 40003 + +**Test ID**: `objects/unit/RTPO19c1a/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 +``` + +--- + +## RTPO19c1 - subscribe() with depth 1 only receives self events + +**Test ID**: `objects/unit/RTPO19c1/subscribe-depth-1-self-only-0` + +**Spec requirement:** depth=1 means only changes at the exact subscribed path trigger the listener (RTO24c2b). + +### 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 +``` + +--- + +## RTPO19c1 - subscribe() with depth 2 receives self and children + +**Test ID**: `objects/unit/RTPO19c1/subscribe-depth-2-children-0` + +**Spec requirement:** depth=2 means changes at the subscribed path and one level of children trigger the listener (RTO24c2c). + +### 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 +``` + +--- + +## RTPO19c1 - subscribe() with no depth receives all descendants + +**Test ID**: `objects/unit/RTPO19c1/subscribe-unlimited-depth-0` + +**Spec requirement:** If depth is undefined, subscription receives events at any depth (RTO24c2a). + +### 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 +``` + +--- + +## RTPO19d - subscribe() returns Subscription with unsubscribe() + +**Test ID**: `objects/unit/RTPO19d/subscribe-returns-subscription-0` + +**Spec requirement:** RTPO19d: subscribe returns a Subscription (SUB1) object. Calling unsubscribe() deregisters the listener. + +### 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 +ASSERT sub IS Subscription +sub.unsubscribe() + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 0 +``` + +--- + +## RTPO19e1 - subscribe() event provides correct PathObject + +**Test ID**: `objects/unit/RTPO19e1/event-path-object-correct-0` + +**Spec requirement:** RTPO19e1: 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 +``` + +--- + +## RTPO19e2 - subscribe() event delivers PublicAPI::ObjectMessage for operations + +**Test ID**: `objects/unit/RTPO19e2/event-message-delivery-0` + +| Spec | Requirement | +|------|-------------| +| RTPO19e2 | event.message is a PublicAPI::ObjectMessage derived from the LiveObjectUpdate.objectMessage per PAOM3 | +| RTO24b2b2 | message populated when objectMessage has an operation field | + +Tests that the event delivered to a subscription listener includes a `message` field containing a `PublicAPI::ObjectMessage` with the correct fields copied from the source ObjectMessage. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +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", 42, "serial-1", "site-a") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events[0].message IS NOT null +ASSERT events[0].message.channel == "test" +ASSERT events[0].message.serial == "serial-1" +ASSERT events[0].message.siteCode == "site-a" +ASSERT events[0].message.operation IS NOT null +ASSERT events[0].message.operation.action == "COUNTER_INC" +ASSERT events[0].message.operation.objectId == "counter:score@1000" +ASSERT events[0].message.operation.counterInc.number == 42 +``` + +--- + +## RTPO19e2 - subscribe() event omits message when objectMessage has no operation + +**Test ID**: `objects/unit/RTPO19e2/event-message-omitted-no-operation-0` + +**Spec requirement:** RTPO19e2: if the objectMessage's operation field is not populated, message is omitted. + +Tests that events triggered by non-operation updates (e.g. sync-only changes) do not include a message field. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +// Send a MAP_SET on the root that replaces "score" with a new objectId, +// which triggers a subscription event on root. +// Then send an OBJECT_SYNC that changes counter:score@1000's state +// without an operation field — this triggers an update via replaceData +// which has no objectMessage.operation +mock_ws.send_to_client(ProtocolMessage( + action: OBJECT_SYNC, + channel: "test", + channelSerial: "sync2:", + state: [ + build_object_state("counter:score@1000", {"aaa": "t:1"}, { + counter: { count: 200 }, + createOp: { counterCreate: { count: 200 } } + }) + ] +)) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +// Events from sync-triggered updates should have no message +FOR event IN events: + ASSERT event.message IS null OR event.message IS undefined +``` + +--- + +## RTPO19f - subscribe() follows path not identity + +**Test ID**: `objects/unit/RTPO19f/subscribe-follows-path-0` + +**Spec requirement:** RTPO19f: subscription is registered by path, so 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 +``` + +--- + +## 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 - 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" +``` + +--- + +## 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 - child events bubble up to parent subscription + +**Test ID**: `objects/unit/RTPO19/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 +``` + +--- + +## RTO24c1 - depth filtering formula + +**Test ID**: `objects/unit/RTO24c1/depth-filtering-formula-0` + +| Spec | Requirement | +|------|-------------| +| RTO24c1 | subPath is a prefix of eventPath AND (depth null OR eventPath.length - subPath.length + 1 <= depth) | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +// Subscribe at "profile" with depth 2: +// self (profile) -> eventPath=["profile"], subPath=["profile"], 1 - 1 + 1 = 1 <= 2 yes +// child (profile.email) -> eventPath=["profile","email"], subPath=["profile"], 2 - 1 + 1 = 2 <= 2 yes +// grandchild (profile.prefs.theme) -> eventPath=["profile","prefs","theme"], subPath=["profile"], 3 - 1 + 1 = 3 > 2 no +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 +``` + +--- + +## RTO24c1 - prefix mismatch does not trigger subscription + +**Test ID**: `objects/unit/RTO24c1/prefix-mismatch-0` + +| Spec | Requirement | +|------|-------------| +| RTO24c1 | subPath must be a prefix of eventPath | +| RTO24c2d | ["admins"] and ["userPosts"] not covered by subscription at ["users"] | + +Tests that a subscription at one path does not receive events for a sibling path that is not a prefix match. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +profile_events = [] +root.get("profile").subscribe((event) => profile_events.append(event)) +``` + +### Test Steps +```pseudo +// Change at "score" — "profile" is not a prefix of "score" +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) + +// Change at "name" — "profile" is not a prefix of "name" +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "100", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT profile_events.length == 0 +``` + +--- + +## RTO24b2a - candidate path construction includes map update keys + +**Test ID**: `objects/unit/RTO24b2a/candidate-paths-map-keys-0` + +| Spec | Requirement | +|------|-------------| +| RTO24b2a1 | First candidate is pathToThis itself | +| RTO24b2a2 | For LiveMapUpdate, append pathToThis extended by each update key | + +Tests that when a MAP_SET updates a key on a map, subscriptions on the child path (pathToThis + key) are notified, not just subscriptions on the map itself. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +score_events = [] +root_events = [] +// Subscribe at the child path "score" (pathToThis=[""] + key "score" = ["score"]) +root.get("score").subscribe((event) => score_events.append(event)) +// Subscribe at root path (pathToThis=[""]) +root.subscribe((event) => root_events.append(event)) +``` + +### Test Steps +```pseudo +// MAP_SET on root with key "score" — generates candidates: +// 1. pathToThis = [] (root itself) +// 2. [] + "score" = ["score"] (from the map update key) +// Both subscriptions should fire +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") +])) +poll_until(score_events.length >= 1, timeout: 5s) +poll_until(root_events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT score_events.length == 1 +ASSERT score_events[0].object.path() == "score" +ASSERT root_events.length == 1 +``` + +--- + +## RTO24b2c - listener exception does not affect other listeners + +**Test ID**: `objects/unit/RTO24b2c/listener-exception-caught-0` + +**Spec requirement:** If a listener throws, the error is caught and logged without affecting other subscriptions or other pathToThis iterations. + +### 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 +``` + +--- + +## RTO24b1 - dispatch via getFullPaths for multi-path objects + +**Test ID**: `objects/unit/RTO24b1/multi-path-dispatch-0` + +| Spec | Requirement | +|------|-------------| +| RTO24b1 | Let pathsToThis be the set of paths returned by getFullPaths on the LiveObject | +| RTO24b2 | For each pathToThis, construct candidates and dispatch | + +Tests that when a LiveObject is reachable via multiple paths, subscriptions on all those paths receive events. We create this by adding a second reference to the same counter. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events_score = [] +events_alias = [] + +// "score" already points to counter:score@1000. +// Add a second reference "alias" -> counter:score@1000 so it has two paths. +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "alias", { objectId: "counter:score@1000" }, "98", "remote") +])) + +root.get("score").subscribe((event) => events_score.append(event)) +root.get("alias").subscribe((event) => events_alias.append(event)) +``` + +### Test Steps +```pseudo +// Increment counter:score@1000 — getFullPaths returns ["score"] and ["alias"] +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "99", "remote") +])) +poll_until(events_score.length >= 1, timeout: 5s) +poll_until(events_alias.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events_score.length == 1 +ASSERT events_score[0].object.path() == "score" +ASSERT events_alias.length == 1 +ASSERT events_alias[0].object.path() == "alias" +``` + +--- + +## RTO24b2b - subscription fires exactly once per dispatch + +**Test ID**: `objects/unit/RTO24b2b/fires-once-per-dispatch-0` + +| Spec | Requirement | +|------|-------------| +| RTO24b2b | Find the first eventPath in candidatePaths that the subscription covers; call the listener exactly once | + +Tests that when a MAP_SET generates multiple candidate paths that a subscription covers, the listener is called exactly once with the first (most preferred) candidate. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +// Subscribe at root (unlimited depth) — covers both [] and ["score"] +root.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +// MAP_SET on root with key "score" — candidates are [] and ["score"] +// Root subscription covers both, but should fire exactly once with +// the first candidate (pathToThis = []) +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +// Exactly one event per dispatch, even though multiple candidates match +ASSERT events.length == 1 +``` diff --git a/uts/objects/unit/public_object_message.md b/uts/objects/unit/public_object_message.md new file mode 100644 index 000000000..24373f1b1 --- /dev/null +++ b/uts/objects/unit/public_object_message.md @@ -0,0 +1,555 @@ +# PublicAPI::ObjectMessage and PublicAPI::ObjectOperation Tests + +Spec points: `PAOM1`, `PAOM2`, `PAOM3`, `PAOOP1`, `PAOOP2`, `PAOOP3` + +## Test Type +Unit test — pure data structure construction, no mocks required. + +## Purpose + +Tests the construction of `PublicAPI::ObjectMessage` from an internal `ObjectMessage`, and the construction of `PublicAPI::ObjectOperation` from an internal `ObjectOperation`. These are user-facing types exposed to subscription listeners so that user code can inspect the metadata of the message that triggered an object change. + +Tests verify that all fields are correctly copied, that `channel` comes from the channel object (not from the ObjectMessage), that the `operation` is derived via PAOOP3, and that the `mapCreate`/`counterCreate` resolution logic handles direct, derived-from-WithObjectId, and absent cases correctly. + +--- + +## PAOM3 - Construction copies all fields from source ObjectMessage + +**Test ID**: `objects/unit/PAOM3/construction-all-fields-0` + +| Spec | Requirement | +|------|-------------| +| PAOM3b | Set channel attribute to channel.name | +| PAOM3c | Copy id, clientId, connectionId, timestamp, serial, serialTimestamp, siteCode, extras from source | +| PAOM3d | Set operation to PublicAPI::ObjectOperation derived per PAOOP3 | + +Tests that constructing a PublicAPI::ObjectMessage from a source ObjectMessage with all fields populated correctly copies every attribute and derives the operation. + +### Setup +```pseudo +source = ObjectMessage( + id: "msg-id-1", + clientId: "client-1", + connectionId: "conn-1", + timestamp: 1700000000000, + serial: "01", + serialTimestamp: 1700000001000, + siteCode: "site1", + extras: { "key": "value" }, + operation: { + action: "MAP_SET", + objectId: "map:abc@1000", + mapSet: { key: "name", value: { string: "Alice" } } + } +) + +channel = { name: "test-channel" } +``` + +### Test Steps +```pseudo +public_msg = PublicObjectMessage.fromObjectMessage(source, channel) +``` + +### Assertions +```pseudo +ASSERT public_msg.id == "msg-id-1" +ASSERT public_msg.clientId == "client-1" +ASSERT public_msg.connectionId == "conn-1" +ASSERT public_msg.timestamp == 1700000000000 +ASSERT public_msg.channel == "test-channel" +ASSERT public_msg.serial == "01" +ASSERT public_msg.serialTimestamp == 1700000001000 +ASSERT public_msg.siteCode == "site1" +ASSERT public_msg.extras == { "key": "value" } +ASSERT public_msg.operation IS NOT null +ASSERT public_msg.operation.action == "MAP_SET" +ASSERT public_msg.operation.objectId == "map:abc@1000" +ASSERT public_msg.operation.mapSet.key == "name" +``` + +--- + +## PAOM3 - Construction with optional fields missing + +**Test ID**: `objects/unit/PAOM3/construction-optional-fields-missing-0` + +| Spec | Requirement | +|------|-------------| +| PAOM2a | id is optional | +| PAOM2b | clientId is optional | +| PAOM2c | connectionId is optional | +| PAOM2d | timestamp is optional | +| PAOM2g | serial is optional | +| PAOM2h | serialTimestamp is optional | +| PAOM2i | siteCode is optional | +| PAOM2j | extras is optional | +| PAOM3c | Copy fields from source; absent fields remain null/undefined | + +Tests that constructing a PublicAPI::ObjectMessage from a source ObjectMessage with only required fields works correctly, and optional fields are null/undefined. + +### Setup +```pseudo +source = ObjectMessage( + operation: { + action: "COUNTER_INC", + objectId: "counter:abc@1000", + counterInc: { number: 5 } + } +) + +channel = { name: "my-channel" } +``` + +### Test Steps +```pseudo +public_msg = PublicObjectMessage.fromObjectMessage(source, channel) +``` + +### Assertions +```pseudo +ASSERT public_msg.id == null +ASSERT public_msg.clientId == null +ASSERT public_msg.connectionId == null +ASSERT public_msg.timestamp == null +ASSERT public_msg.channel == "my-channel" +ASSERT public_msg.serial == null +ASSERT public_msg.serialTimestamp == null +ASSERT public_msg.siteCode == null +ASSERT public_msg.extras == null +ASSERT public_msg.operation IS NOT null +ASSERT public_msg.operation.action == "COUNTER_INC" +``` + +--- + +## PAOM3b - Channel is set from channel.name, not from ObjectMessage + +**Test ID**: `objects/unit/PAOM3/channel-from-channel-name-0` + +**Spec requirement:** The `channel` attribute is set to `channel.name`, not derived from any field on the ObjectMessage itself. + +Tests that the channel field on the PublicAPI::ObjectMessage comes from the channel object's name property. + +### Setup +```pseudo +source = ObjectMessage( + operation: { + action: "OBJECT_DELETE", + objectId: "counter:abc@1000" + } +) + +channel = { name: "different-channel-name" } +``` + +### Test Steps +```pseudo +public_msg = PublicObjectMessage.fromObjectMessage(source, channel) +``` + +### Assertions +```pseudo +ASSERT public_msg.channel == "different-channel-name" +``` + +--- + +## PAOOP3a - MAP_SET operation copies mapSet, omits unrelated fields + +**Test ID**: `objects/unit/PAOOP3/map-set-copies-fields-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3a | Copy action, objectId, mapSet, mapRemove, counterInc, objectDelete, mapClear directly | +| PAOOP2d | mapSet is the mapSet of the source ObjectOperation | + +Tests that constructing a PublicAPI::ObjectOperation from a MAP_SET source copies action, objectId, and mapSet, and omits all other operation-specific fields. + +### Setup +```pseudo +source_operation = { + action: "MAP_SET", + objectId: "map:abc@1000", + mapSet: { key: "color", value: { string: "blue" } } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "MAP_SET" +ASSERT public_op.objectId == "map:abc@1000" +ASSERT public_op.mapSet.key == "color" +ASSERT public_op.mapSet.value.string == "blue" +ASSERT public_op.mapCreate == null +ASSERT public_op.mapRemove == null +ASSERT public_op.counterCreate == null +ASSERT public_op.counterInc == null +ASSERT public_op.objectDelete == null +ASSERT public_op.mapClear == null +``` + +--- + +## PAOOP3a - MAP_REMOVE operation copies mapRemove, omits unrelated fields + +**Test ID**: `objects/unit/PAOOP3/map-remove-copies-fields-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3a | Copy mapRemove directly from source | +| PAOOP2e | mapRemove is the mapRemove of the source ObjectOperation | + +Tests that constructing a PublicAPI::ObjectOperation from a MAP_REMOVE source copies action, objectId, and mapRemove, and omits all other operation-specific fields. + +### Setup +```pseudo +source_operation = { + action: "MAP_REMOVE", + objectId: "map:abc@1000", + mapRemove: { key: "old-key" } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "MAP_REMOVE" +ASSERT public_op.objectId == "map:abc@1000" +ASSERT public_op.mapRemove.key == "old-key" +ASSERT public_op.mapCreate == null +ASSERT public_op.mapSet == null +ASSERT public_op.counterCreate == null +ASSERT public_op.counterInc == null +ASSERT public_op.objectDelete == null +ASSERT public_op.mapClear == null +``` + +--- + +## PAOOP3a - COUNTER_INC operation copies counterInc, omits unrelated fields + +**Test ID**: `objects/unit/PAOOP3/counter-inc-copies-fields-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3a | Copy counterInc directly from source | +| PAOOP2g | counterInc is the counterInc of the source ObjectOperation | + +Tests that constructing a PublicAPI::ObjectOperation from a COUNTER_INC source copies action, objectId, and counterInc, and omits all other operation-specific fields. + +### Setup +```pseudo +source_operation = { + action: "COUNTER_INC", + objectId: "counter:abc@1000", + counterInc: { number: 42 } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "COUNTER_INC" +ASSERT public_op.objectId == "counter:abc@1000" +ASSERT public_op.counterInc.number == 42 +ASSERT public_op.mapCreate == null +ASSERT public_op.mapSet == null +ASSERT public_op.mapRemove == null +ASSERT public_op.counterCreate == null +ASSERT public_op.objectDelete == null +ASSERT public_op.mapClear == null +``` + +--- + +## PAOOP3a - OBJECT_DELETE operation copies objectDelete, omits unrelated fields + +**Test ID**: `objects/unit/PAOOP3/object-delete-copies-fields-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3a | Copy objectDelete directly from source | +| PAOOP2h | objectDelete is the objectDelete of the source ObjectOperation | + +Tests that constructing a PublicAPI::ObjectOperation from an OBJECT_DELETE source copies action, objectId, and objectDelete, and omits all other operation-specific fields. + +### Setup +```pseudo +source_operation = { + action: "OBJECT_DELETE", + objectId: "counter:abc@1000", + objectDelete: {} +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "OBJECT_DELETE" +ASSERT public_op.objectId == "counter:abc@1000" +ASSERT public_op.objectDelete IS NOT null +ASSERT public_op.mapCreate == null +ASSERT public_op.mapSet == null +ASSERT public_op.mapRemove == null +ASSERT public_op.counterCreate == null +ASSERT public_op.counterInc == null +ASSERT public_op.mapClear == null +``` + +--- + +## PAOOP3a - MAP_CLEAR operation copies mapClear, omits unrelated fields + +**Test ID**: `objects/unit/PAOOP3/map-clear-copies-fields-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3a | Copy mapClear directly from source | +| PAOOP2i | mapClear is the mapClear of the source ObjectOperation | + +Tests that constructing a PublicAPI::ObjectOperation from a MAP_CLEAR source copies action, objectId, and mapClear, and omits all other operation-specific fields. + +### Setup +```pseudo +source_operation = { + action: "MAP_CLEAR", + objectId: "map:abc@1000", + mapClear: {} +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "MAP_CLEAR" +ASSERT public_op.objectId == "map:abc@1000" +ASSERT public_op.mapClear IS NOT null +ASSERT public_op.mapCreate == null +ASSERT public_op.mapSet == null +ASSERT public_op.mapRemove == null +ASSERT public_op.counterCreate == null +ASSERT public_op.counterInc == null +ASSERT public_op.objectDelete == null +``` + +--- + +## PAOOP3b1 - MAP_CREATE with mapCreate directly present + +**Test ID**: `objects/unit/PAOOP3/map-create-direct-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3b1 | If mapCreate is present on the source, set mapCreate to that value | + +Tests that when the source ObjectOperation has a `mapCreate` field, the PublicAPI::ObjectOperation uses it directly. + +### Setup +```pseudo +source_operation = { + action: "MAP_CREATE", + objectId: "map:new@2000", + mapCreate: { semantics: "LWW", entries: { "key1": { data: { string: "val1" } } } } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "MAP_CREATE" +ASSERT public_op.objectId == "map:new@2000" +ASSERT public_op.mapCreate IS NOT null +ASSERT public_op.mapCreate.semantics == "LWW" +ASSERT public_op.mapCreate.entries["key1"].data.string == "val1" +ASSERT public_op.counterCreate == null +``` + +--- + +## PAOOP3b2 - MAP_CREATE resolved from mapCreateWithObjectId + +**Test ID**: `objects/unit/PAOOP3/map-create-from-with-object-id-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3b2 | If mapCreateWithObjectId is present on the source, set mapCreate to the MapCreate from which it was derived | + +Tests that when the source ObjectOperation has `mapCreateWithObjectId` but not `mapCreate`, the PublicAPI::ObjectOperation resolves `mapCreate` to the derived MapCreate. + +### Setup +```pseudo +derived_map_create = { semantics: "LWW", entries: { "x": { data: { number: 10 } } } } + +source_operation = { + action: "MAP_CREATE", + objectId: "map:derived@3000", + mapCreateWithObjectId: { + objectId: "map:derived@3000", + semantics: "LWW", + entries: { "x": { data: { number: 10 } } }, + _derivedFrom: derived_map_create + } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "MAP_CREATE" +ASSERT public_op.objectId == "map:derived@3000" +ASSERT public_op.mapCreate IS NOT null +ASSERT public_op.mapCreate.semantics == "LWW" +ASSERT public_op.mapCreate.entries["x"].data.number == 10 +ASSERT public_op.counterCreate == null +``` + +--- + +## PAOOP3c2 - COUNTER_CREATE resolved from counterCreateWithObjectId + +**Test ID**: `objects/unit/PAOOP3/counter-create-from-with-object-id-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3c2 | If counterCreateWithObjectId is present on the source, set counterCreate to the CounterCreate from which it was derived | + +Tests that when the source ObjectOperation has `counterCreateWithObjectId` but not `counterCreate`, the PublicAPI::ObjectOperation resolves `counterCreate` to the derived CounterCreate. + +### Setup +```pseudo +derived_counter_create = { count: 100 } + +source_operation = { + action: "COUNTER_CREATE", + objectId: "counter:derived@3000", + counterCreateWithObjectId: { + objectId: "counter:derived@3000", + count: 100, + _derivedFrom: derived_counter_create + } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "COUNTER_CREATE" +ASSERT public_op.objectId == "counter:derived@3000" +ASSERT public_op.counterCreate IS NOT null +ASSERT public_op.counterCreate.count == 100 +ASSERT public_op.mapCreate == null +``` + +--- + +## PAOOP3b3, PAOOP3c3 - Create payloads omitted when neither variant is present + +**Test ID**: `objects/unit/PAOOP3/create-payloads-omitted-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3b3 | If neither mapCreate nor mapCreateWithObjectId is present, omit mapCreate | +| PAOOP3c3 | If neither counterCreate nor counterCreateWithObjectId is present, omit counterCreate | + +Tests that when the source ObjectOperation has no create payloads (neither direct nor WithObjectId variants), both `mapCreate` and `counterCreate` are omitted on the resulting PublicAPI::ObjectOperation. + +### Setup +```pseudo +source_operation = { + action: "MAP_SET", + objectId: "map:abc@1000", + mapSet: { key: "k", value: { string: "v" } } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.mapCreate == null +ASSERT public_op.counterCreate == null +``` + +--- + +## PAOOP3 - Only the relevant operation field is present per action type + +**Test ID**: `objects/unit/PAOOP3/only-relevant-field-per-action-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3a | Copy only the fields that exist on the source; unrelated fields are omitted | +| PAOOP2c | mapCreate is optional | +| PAOOP2d | mapSet is optional | +| PAOOP2e | mapRemove is optional | +| PAOOP2f | counterCreate is optional | +| PAOOP2g | counterInc is optional | +| PAOOP2h | objectDelete is optional | +| PAOOP2i | mapClear is optional | + +Tests that for a COUNTER_CREATE operation with `counterCreate` directly present, only `counterCreate` is set and all other operation-specific fields are null. + +### Setup +```pseudo +source_operation = { + action: "COUNTER_CREATE", + objectId: "counter:new@2000", + counterCreate: { count: 50 } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "COUNTER_CREATE" +ASSERT public_op.objectId == "counter:new@2000" +ASSERT public_op.counterCreate IS NOT null +ASSERT public_op.counterCreate.count == 50 +ASSERT public_op.mapCreate == null +ASSERT public_op.mapSet == null +ASSERT public_op.mapRemove == null +ASSERT public_op.counterInc == null +ASSERT public_op.objectDelete == null +ASSERT public_op.mapClear == null +``` diff --git a/uts/objects/unit/realtime_object.md b/uts/objects/unit/realtime_object.md new file mode 100644 index 000000000..6ab753cd5 --- /dev/null +++ b/uts/objects/unit/realtime_object.md @@ -0,0 +1,1378 @@ +# RealtimeObject Tests + +Spec points: `RTO2`, `RTO10`, `RTO15`, `RTO17`–`RTO20`, `RTO22`–`RTO26` + +## 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 with path set to empty list and root set to root LiveMap | + +### 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 channel + +**Test ID**: `objects/unit/RTO23b/get-throws-detached-0` + +| Spec | Requirement | +|------|-------------| +| RTO23b | If channel is DETACHED or FAILED, throw ErrorInfo with statusCode 400 and code 90001 | + +Tests that get() on a DETACHED channel throws 90001 per the RTO25 access API preconditions. + +### 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 + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, channel: msg.channel + )) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE"] }) +``` + +### Test Steps +```pseudo +// Attach and sync first, then detach +AWAIT channel.object.get() +AWAIT channel.detach() +AWAIT_STATE channel.state == DETACHED + +AWAIT channel.object.get() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 90001 +ASSERT error.statusCode == 400 +``` + +--- + +## 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 +ASSERT root.path == [] +``` + +--- + +## 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.get("score").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.get("score").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.get("score").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.get("score").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.get("score").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 +``` + +--- + +## RTO25a - Access API precondition requires OBJECT_SUBSCRIBE mode + +**Test ID**: `objects/unit/RTO25a/access-requires-subscribe-mode-0` + +| Spec | Requirement | +|------|-------------| +| RTO25a | Require OBJECT_SUBSCRIBE channel mode per RTO2 | + +Tests that a read operation (e.g. PathObject value()) without OBJECT_SUBSCRIBE mode throws error 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_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_PUBLISH"] }) +``` + +### Test Steps +```pseudo +AWAIT channel.object.get() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO25b - Access API precondition throws on DETACHED channel + +**Test ID**: `objects/unit/RTO25b/access-throws-detached-0` + +| Spec | Requirement | +|------|-------------| +| RTO25b | If channel is DETACHED or FAILED, throw ErrorInfo with statusCode 400 and code 90001 | + +Tests that calling get() on a DETACHED channel throws 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" + }) + ), + 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 == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, channel: msg.channel + )) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE"] }) +``` + +### Test Steps +```pseudo +// Attach, sync, then detach to get channel into DETACHED state +AWAIT channel.object.get() +AWAIT channel.detach() +AWAIT_STATE channel.state == DETACHED + +AWAIT channel.object.get() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 90001 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO25b - Access API precondition throws on FAILED channel + +**Test ID**: `objects/unit/RTO25b/access-throws-failed-0` + +| Spec | Requirement | +|------|-------------| +| RTO25b | If channel is DETACHED or FAILED, throw ErrorInfo with statusCode 400 and code 90001 | + +Tests that calling get() on a FAILED channel throws 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" + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, channel: msg.channel, + error: { code: 90000, statusCode: 400, message: "Channel error" } + )) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE"] }) +``` + +### Test Steps +```pseudo +// Trigger attach which will fail, putting channel into FAILED state +channel.attach() +AWAIT_STATE channel.state == FAILED + +AWAIT channel.object.get() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 90001 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO26a - Write API precondition requires OBJECT_PUBLISH mode + +**Test ID**: `objects/unit/RTO26a/write-requires-publish-mode-0` + +| Spec | Requirement | +|------|-------------| +| RTO26a | Require OBJECT_PUBLISH channel mode per RTO2 | + +Tests that a write operation without OBJECT_PUBLISH mode throws error 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"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO26b - Write API precondition throws on DETACHED channel + +**Test ID**: `objects/unit/RTO26b/write-throws-detached-0` + +| Spec | Requirement | +|------|-------------| +| RTO26b | If channel is DETACHED, FAILED, or SUSPENDED, throw ErrorInfo with statusCode 400 and code 90001 | + +Tests that a write operation on a DETACHED channel throws 90001. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +// Detach the channel after sync +mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, channel: "test", + error: { code: 90000, statusCode: 400, message: "Channel detached" } +)) +AWAIT_STATE channel.state == DETACHED + +AWAIT root.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 90001 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO26b - Write API precondition throws on FAILED channel + +**Test ID**: `objects/unit/RTO26b/write-throws-failed-0` + +| Spec | Requirement | +|------|-------------| +| RTO26b | If channel is DETACHED, FAILED, or SUSPENDED, throw ErrorInfo with statusCode 400 and code 90001 | + +Tests that a write operation on a FAILED channel throws 90001. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +// Force channel to FAILED state +mock_ws.send_to_client(ProtocolMessage( + action: ERROR, channel: "test", + error: { code: 90000, statusCode: 400, message: "Channel error" } +)) +AWAIT_STATE channel.state == FAILED + +AWAIT root.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 90001 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO26c - Write API precondition throws when echoMessages is false + +**Test ID**: `objects/unit/RTO26c/write-throws-echo-disabled-0` + +| Spec | Requirement | +|------|-------------| +| RTO26c | If echoMessages is false, throw ErrorInfo with statusCode 400 and code 40000 | + +Tests that a write operation with echoMessages disabled throws 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 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO24a - RealtimeObject maintains a single PathObjectSubscriptionRegister + +**Test ID**: `objects/unit/RTO24a/single-register-instance-0` + +**Spec requirement:** The RealtimeObject instance maintains a single PathObjectSubscriptionRegister that manages all path-based subscriptions for the channel. + +Tests that subscriptions registered via different PathObjects on the same channel share a single register, so updates are dispatched to all matching subscriptions regardless of which PathObject was used to subscribe. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +events_root = [] +events_score = [] + +// Subscribe via root PathObject at path [] +root.subscribe((event) => events_root.append(event)) + +// Subscribe via a deeper PathObject at path ["score"] +score_path = root.get("score") +score_path.subscribe((event) => events_score.append(event)) +``` + +### Test Steps +```pseudo +// Trigger an update on the score counter +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "s:1", "aaa") +])) + +poll_until(events_score.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +// Both subscriptions are managed by the same register and both fire +ASSERT events_root.length >= 1 +ASSERT events_score.length >= 1 +``` + +--- + +## RTO24c1 - Subscription coverage: prefix match with depth constraint + +**Test ID**: `objects/unit/RTO24c1/coverage-prefix-depth-0` + +| Spec | Requirement | +|------|-------------| +| RTO24c1 | Subscription covers eventPath if subPath is prefix and depth constraint satisfied | + +Tests that a subscription with a depth constraint only receives events within the specified depth. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +shallow_events = [] +deep_events = [] + +// Subscribe at root with depth 1 — covers root and immediate children only +root.subscribe({ depth: 1 }, (event) => shallow_events.append(event)) + +// Subscribe at root with no depth limit — covers everything +root.subscribe((event) => deep_events.append(event)) +``` + +### Test Steps +```pseudo +// Update a direct child of root (path ["score"]) — depth 1 from root +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "s:1", "aaa") +])) +poll_until(deep_events.length >= 1, timeout: 5s) + +// Update a nested object (path ["profile", "nested_counter"]) — depth 2 from root +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:nested@1000", 1, "s:2", "aaa") +])) +poll_until(deep_events.length >= 2, timeout: 5s) +``` + +### Assertions +```pseudo +// Shallow subscription (depth 1) only sees the direct child update +ASSERT shallow_events.length == 1 + +// Deep subscription (no depth limit) sees both updates +ASSERT deep_events.length >= 2 +``` + +--- + +## 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.get("score").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 | + +Verified through observable behaviour: after a local increment (applied via ACK +with source LOCAL), an inbound COUNTER_INC from the same siteCode and serial as +the ACK should still apply. If LOCAL had incorrectly written to siteTimeserials, +the newness check would reject the inbound message as stale. + +The mock's ACK serial for the first publish is `"t:1:0"` with siteCode `"test"` +(from ConnectionDetails). The inbound message reuses that siteCode and serial. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").increment(10) +ASSERT root.get("score").value() == 110 + +# Send inbound COUNTER_INC from siteCode "test" with serial "t:1:0" +# (same siteCode and serial as the ACK). If LOCAL incorrectly set +# siteTimeserials["test"] = "t:1:0", this would fail the newness check. +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "t:1:0", "test") +])) +poll_until(root.get("score").value() == 120, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 120 +``` + +--- + +## 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.get("score").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.get("score").increment(10) +ASSERT root.get("score").value() == 110 + +// Trigger re-sync — appliedOnAckSerials should be cleared per RTO5c9 +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)) +ASSERT root.get("score").value() == 100 + +// Replay the same serial ("t:1:0") that was used for apply-on-ACK. +// If appliedOnAckSerials was cleared, this applies normally. +// If NOT cleared, dedup (RTO9a3) would reject it and score stays 100. +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "t:1:0", "test") +])) +poll_until(root.get("score").value() == 110, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 110 +``` + +--- + +## 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.get("score").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 root.path == [] +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..3a4033a8c --- /dev/null +++ b/uts/objects/unit/value_types.md @@ -0,0 +1,433 @@ +# Value Types Tests + +Spec points: `RTLCV1`–`RTLCV4`, `RTLMV1`–`RTLMV4` + +## Test Type +Unit test — pure construction and evaluation, no mocks required. + +## Purpose + +Tests `LiveCounterValueType` and `LiveMapValueType` — immutable blueprints created via `LiveCounter.create()` and `LiveMap.create()` static factories. When evaluated 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 +``` + +--- + +## RTLCV4 - Evaluation generates COUNTER_CREATE ObjectMessage + +**Test ID**: `objects/unit/RTLCV4/evaluate-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 = evaluate(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 - Evaluation retains local CounterCreate + +**Test ID**: `objects/unit/RTLCV4g5/retains-local-counter-create-0` + +**Spec requirement:** Client must retain CounterCreate alongside CounterCreateWithObjectId for local use (RTLCV4g5). Needed for message size calculation and local application. + +### Test Steps +```pseudo +vt = LiveCounter.create(42) +messages = evaluate(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +ASSERT msg.operation.counterCreate IS NOT null +ASSERT msg.operation.counterCreate.count == 42 +``` + +--- + +## RTLCV4a - Evaluation validates count type + +**Test ID**: `objects/unit/RTLCV4a/evaluate-validates-count-0` + +**Spec requirement:** If count is not undefined and (not a Number or not finite), throw 40003 (RTLCV4a). Validation happens during evaluation, not at creation time. + +### Test Steps +```pseudo +vt = LiveCounter.create("not_a_number") +evaluate(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLCV4 - Evaluation with count 0 + +**Test ID**: `objects/unit/RTLCV4/evaluate-zero-count-0` + +**Spec requirement:** count=0 is valid and should be included in CounterCreate. + +### Test Steps +```pseudo +vt = LiveCounter.create(0) +messages = evaluate(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 - Evaluation generates MAP_CREATE ObjectMessage + +**Test ID**: `objects/unit/RTLMV4/evaluate-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 = evaluate(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 - Evaluation retains local MapCreate + +**Test ID**: `objects/unit/RTLMV4j5/retains-local-map-create-0` + +**Spec requirement:** Client must retain MapCreate alongside MapCreateWithObjectId for local use (RTLMV4j5). Needed for message size calculation and local application. + +### Test Steps +```pseudo +vt = LiveMap.create({ "name": "Alice" }) +messages = evaluate(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 = evaluate(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 evaluated, ObjectMessage collected, objectId set | +| RTLMV4d2 | LiveMapValueType recursively evaluated, 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 = evaluate(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 - Evaluation validates entries type + +**Test ID**: `objects/unit/RTLMV4a/evaluate-validates-entries-0` + +**Spec requirement:** If entries is not undefined and (is null or not Dict), throw 40003 (RTLMV4a). Validation happens during evaluation, not at creation time. + +### Test Steps +```pseudo +vt = LiveMap.create(null) +evaluate(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLMV4b - Evaluation validates key types + +**Test ID**: `objects/unit/RTLMV4b/evaluate-validates-keys-0` + +**Spec requirement:** If any key is not String, throw 40003 (RTLMV4b). + +### Test Steps +```pseudo +vt = LiveMap.create({ 123: "value" }) +evaluate(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLMV4c - Evaluation validates value types + +**Test ID**: `objects/unit/RTLMV4c/evaluate-validates-values-0` + +**Spec requirement:** If any value is not an expected type, throw 40013 (RTLMV4c). + +### Test Steps +```pseudo +vt = LiveMap.create({ "fn": some_function }) +evaluate(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 = evaluate(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 = evaluate(vt) + entry = messages[0].operation.mapCreate.entries["test_key"] + ASSERT entry.data[scenario.expected_field] == scenario.expected_value +``` diff --git a/uts/realtime/integration/helpers/proxy.md b/uts/realtime/integration/helpers/proxy.md index 18274311e..786929604 100644 --- a/uts/realtime/integration/helpers/proxy.md +++ b/uts/realtime/integration/helpers/proxy.md @@ -20,7 +20,6 @@ Proxy integration tests use this to verify fault-handling behaviour against the # 1. Create a proxy session with rules session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, rules: [ ...rules... ] ) @@ -58,7 +57,7 @@ session.close() interface ProxySession: session_id: String proxy_host: String # Always "localhost" - proxy_port: Int # Assigned from port pool + proxy_port: Int # Auto-assigned by proxy, or explicit if specified add_rules(rules: List, position?: "append"|"prepend") trigger_action(action: ActionRequest) @@ -67,7 +66,7 @@ interface ProxySession: function create_proxy_session( endpoint: String, # e.g. "nonprod:sandbox" → resolves to sandbox.realtime.ably-nonprod.net - port: Int, + port?: Int, # Optional; proxy auto-assigns a free port if omitted rules?: List, timeoutMs?: Int # Session auto-cleanup timeout (default 30000) ): ProxySession @@ -203,6 +202,9 @@ ASSERT attach_frames.length == 1 | MESSAGE | 15 | Both | | SYNC | 16 | Server → Client | | AUTH | 17 | Client → Server | +| OBJECT | 19 | Both | +| OBJECT_SYNC | 20 | Server → Client | +| ANNOTATION | 21 | Both | ## SDK ClientOptions for Proxy Tests diff --git a/uts/realtime/integration/proxy/auth_reauth.md b/uts/realtime/integration/proxy/auth_reauth.md index 6d704c966..edb4d9894 100644 --- a/uts/realtime/integration/proxy/auth_reauth.md +++ b/uts/realtime/integration/proxy/auth_reauth.md @@ -31,16 +31,6 @@ AFTER ALL TESTS: WITH Authorization: Basic {api_key} ``` -## Port Allocation - -Each test allocates a unique proxy port to avoid conflicts: - -```pseudo -BEFORE ALL TESTS: - port_base = allocate_port_range(count: 1) - # Tests use port_base + 0 -``` - --- ## Test 26: RTN22/RTC8a -- Server-initiated re-authentication @@ -63,7 +53,6 @@ Tests that when the proxy injects a server-initiated AUTH ProtocolMessage (actio ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 0, rules: [] ) ``` @@ -83,7 +72,7 @@ auth_callback = FUNCTION(params, callback): client = Realtime(options: ClientOptions( authCallback: auth_callback, endpoint: "localhost", - port: port_base + 0, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false diff --git a/uts/realtime/integration/proxy/channel_faults.md b/uts/realtime/integration/proxy/channel_faults.md index 1035d6772..35b2da0fd 100644 --- a/uts/realtime/integration/proxy/channel_faults.md +++ b/uts/realtime/integration/proxy/channel_faults.md @@ -71,7 +71,7 @@ channel_name = "test-RTL4f-${random_id()}" # Create proxy session that suppresses ATTACH messages for our channel session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_server", "action": "ATTACH", "channel": channel_name }, "action": { "type": "suppress" }, @@ -177,7 +177,7 @@ 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", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": "ATTACHED", "channel": channel_name }, "action": { @@ -274,7 +274,7 @@ channel_name = "test-RTL5f-${random_id()}" # Phase 1: Create proxy session with NO fault rules (clean passthrough) session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -374,7 +374,7 @@ channel_name = "test-RTL13a-${random_id()}" # Create proxy session with clean passthrough (no fault rules initially) session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -471,7 +471,7 @@ channel_name = "test-RTL14-${random_id()}" # Create proxy session with clean passthrough session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -563,7 +563,7 @@ channel_name = "test-RTL12-${random_id()}" # Create proxy session with clean passthrough session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -666,7 +666,7 @@ channel_b_name = "test-RTL3d-b-${random_id()}" # Create proxy session with clean passthrough session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) diff --git a/uts/realtime/integration/proxy/connection_open_failures.md b/uts/realtime/integration/proxy/connection_open_failures.md index 44b65ae79..0c79673db 100644 --- a/uts/realtime/integration/proxy/connection_open_failures.md +++ b/uts/realtime/integration/proxy/connection_open_failures.md @@ -66,7 +66,7 @@ Tests that when the server responds with a fatal ERROR (non-token error code) du # Create proxy session that replaces the first CONNECTED with a fatal ERROR session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, "action": { @@ -151,7 +151,7 @@ auth_callback_count = 0 # Create proxy session that injects token error on first CONNECTED only session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, "action": { @@ -246,7 +246,7 @@ Tests that when the first WebSocket connection is refused at the transport level # Create proxy session that refuses the first WebSocket connection session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_connect", "count": 1 }, "action": { "type": "refuse_connection" }, @@ -325,7 +325,7 @@ Tests that when the server responds with a connection-level ERROR (no channel fi # Create proxy session that replaces the first CONNECTED with a server ERROR session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, "action": { @@ -408,7 +408,7 @@ Tests that when the server accepts the WebSocket but never sends a CONNECTED mes # Create proxy session that suppresses all CONNECTED messages session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, "action": { "type": "suppress" }, diff --git a/uts/realtime/integration/proxy/connection_resume.md b/uts/realtime/integration/proxy/connection_resume.md index 3c908366a..1621137a1 100644 --- a/uts/realtime/integration/proxy/connection_resume.md +++ b/uts/realtime/integration/proxy/connection_resume.md @@ -28,16 +28,6 @@ AFTER ALL TESTS: WITH Authorization: Basic {api_key} ``` -## Port Allocation - -Each test allocates a unique proxy port to avoid conflicts: - -```pseudo -BEFORE ALL TESTS: - port_base = allocate_port_range(count: 11) - # Tests use port_base + 0 through port_base + 10 -``` - --- ## Test 6: RTN15a - Unexpected disconnect triggers resume @@ -59,7 +49,6 @@ Tests that an unexpected transport disconnect causes the SDK to reconnect and at ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 0, rules: [ { match: { type: "delay_after_ws_connect", delayMs: 1000 }, @@ -79,7 +68,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 0, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -163,7 +152,6 @@ frame) after a 1-second delay. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 0, rules: [ { match: { type: "delay_after_ws_connect", delayMs: 1000 }, @@ -211,7 +199,6 @@ Tests that after an unexpected disconnect and successful resume, the connection ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 1, rules: [ { match: { type: "delay_after_ws_connect", delayMs: 1000 }, @@ -231,7 +218,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 1, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -309,7 +296,6 @@ Tests that when a resume fails (simulated by the proxy replacing the server's se ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 2, rules: [ { match: { type: "delay_after_ws_connect", delayMs: 1000 }, @@ -358,7 +344,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 2, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -442,7 +428,6 @@ Tests that when the proxy injects a DISCONNECTED message with a token error (cod ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 3, rules: [ { "match": { "type": "delay_after_ws_connect", "delayMs": 1000 }, @@ -479,7 +464,7 @@ token_string = token_details.token client = Realtime(options: ClientOptions( token: token_string, endpoint: "localhost", - port: port_base + 3, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -553,7 +538,6 @@ Tests that when the proxy injects a DISCONNECTED message with a non-token error ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 4, rules: [ { "match": { "type": "delay_after_ws_connect", "delayMs": 1000 }, @@ -583,7 +567,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 4, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -669,7 +653,6 @@ Tests that a connection-level ERROR ProtocolMessage (no channel field) causes th ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 5, rules: [] ) ``` @@ -682,7 +665,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 5, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -799,7 +782,6 @@ Tests that when the client has been disconnected for longer than connectionState ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 6, rules: [ { "match": { "type": "ws_frame_to_client", "action": "CONNECTED", "count": 1 }, @@ -849,7 +831,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 6, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false, @@ -932,7 +914,6 @@ Tests that a message awaiting ACK on the old transport is resent after reconnect ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 7, rules: [ { "match": { "type": "ws_frame_to_client", "action": "ACK" }, @@ -954,7 +935,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 7, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -1067,7 +1048,6 @@ Use a direct proxy session (passthrough, no rules) to connect to the sandbox, at ```pseudo session_1 = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 8, rules: [] ) @@ -1076,7 +1056,7 @@ client_1 = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 8, + port: session_1.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -1090,7 +1070,6 @@ A second proxy session is used so we can inspect the `recover` query parameter i ```pseudo session_2 = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 9, rules: [] ) ``` @@ -1140,7 +1119,7 @@ client_2 = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 9, + port: session_2.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false, @@ -1206,7 +1185,6 @@ Tests that when a recovery attempt fails (the server responds with a new connect ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 10, rules: [ { "match": { "type": "ws_frame_to_client", "action": "CONNECTED", "count": 1 }, @@ -1257,7 +1235,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 10, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false, diff --git a/uts/realtime/integration/proxy/heartbeat.md b/uts/realtime/integration/proxy/heartbeat.md index 8f6e1e2b3..e213436b7 100644 --- a/uts/realtime/integration/proxy/heartbeat.md +++ b/uts/realtime/integration/proxy/heartbeat.md @@ -66,7 +66,7 @@ The proxy closes the WebSocket connection after a 2s delay from ws_connect, simu # Create proxy session that closes the WebSocket after 2s to simulate transport failure session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "delay_after_ws_connect", "delayMs": 2000 }, "action": { "type": "close" }, diff --git a/uts/realtime/integration/proxy/rest_faults.md b/uts/realtime/integration/proxy/rest_faults.md index 7fdeb4592..e93ce3cf9 100644 --- a/uts/realtime/integration/proxy/rest_faults.md +++ b/uts/realtime/integration/proxy/rest_faults.md @@ -89,7 +89,7 @@ auth_callback_count = 0 # Create proxy session that returns 401 on the first channel request session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/channels/" }, "action": { @@ -175,7 +175,7 @@ Tests that when a REST request receives an HTTP 503 (Service Unavailable) and th # Create proxy session that returns 503 on the first channel request session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/channels/" }, "action": { @@ -251,7 +251,7 @@ Tests that the proxy transparently forwards both WebSocket and HTTP traffic with # Create proxy session with no rules (pure passthrough) session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) diff --git a/uts/rest/integration/proxy/rest_fallback.md b/uts/rest/integration/proxy/rest_fallback.md index bd3b13232..51ffc5261 100644 --- a/uts/rest/integration/proxy/rest_fallback.md +++ b/uts/rest/integration/proxy/rest_fallback.md @@ -100,7 +100,7 @@ a fallback host (also routed through the proxy) and succeeds. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/time" }, "action": { @@ -160,7 +160,7 @@ fallback host. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/time" }, "action": { @@ -273,7 +273,7 @@ on the retry. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/time" }, "action": { @@ -328,7 +328,7 @@ are configured, so the error propagates directly to the caller. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/time" }, "action": { @@ -382,7 +382,7 @@ non-parseable body while still returning valid JSON. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/time" }, "action": { @@ -435,7 +435,7 @@ should trigger fallback; 4xx errors indicate a client-side problem. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/time" }, "action": { @@ -502,7 +502,7 @@ on the library-generated message `id`. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "method": "POST", "pathContains": "/channels/" }, "action": {