From 1f11dff9fbb3b3a062118ca2e6f46e604931e43d Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Wed, 13 May 2026 11:12:04 +0100 Subject: [PATCH 1/2] Add UTS test specs for LiveObjects path-based API (~330 tests) Complete portable test suite covering the LiveObjects path-based API: 21 files across unit tests (pure + mock WebSocket), integration tests (sandbox), and proxy integration tests. Covers PathObject, Instance, BatchContext, LiveCounter/LiveMap CRDTs, ObjectsPool sync state machine, value types, subscriptions, and GC. Includes table-driven validation tests, bytes/binary data coverage, and REST fixture provisioning. Co-Authored-By: Claude Opus 4.6 --- uts/objects/PLAN.md | 379 +++++++ uts/objects/helpers/standard_test_pool.md | 322 ++++++ uts/objects/integration/objects_batch_test.md | 201 ++++ uts/objects/integration/objects_gc_test.md | 138 +++ .../integration/objects_lifecycle_test.md | 317 ++++++ uts/objects/integration/objects_sync_test.md | 200 ++++ .../integration/proxy/objects_faults.md | 459 ++++++++ uts/objects/unit/batch.md | 782 ++++++++++++++ uts/objects/unit/instance.md | 524 ++++++++++ uts/objects/unit/live_counter.md | 824 +++++++++++++++ uts/objects/unit/live_counter_api.md | 343 ++++++ uts/objects/unit/live_map.md | 980 ++++++++++++++++++ uts/objects/unit/live_map_api.md | 483 +++++++++ uts/objects/unit/live_object_subscribe.md | 244 +++++ uts/objects/unit/object_id.md | 159 +++ uts/objects/unit/objects_pool.md | 910 ++++++++++++++++ uts/objects/unit/path_object.md | 603 +++++++++++ uts/objects/unit/path_object_mutations.md | 321 ++++++ uts/objects/unit/path_object_subscribe.md | 618 +++++++++++ uts/objects/unit/realtime_object.md | 927 +++++++++++++++++ uts/objects/unit/value_types.md | 451 ++++++++ 21 files changed, 10185 insertions(+) create mode 100644 uts/objects/PLAN.md create mode 100644 uts/objects/helpers/standard_test_pool.md create mode 100644 uts/objects/integration/objects_batch_test.md create mode 100644 uts/objects/integration/objects_gc_test.md create mode 100644 uts/objects/integration/objects_lifecycle_test.md create mode 100644 uts/objects/integration/objects_sync_test.md create mode 100644 uts/objects/integration/proxy/objects_faults.md create mode 100644 uts/objects/unit/batch.md create mode 100644 uts/objects/unit/instance.md create mode 100644 uts/objects/unit/live_counter.md create mode 100644 uts/objects/unit/live_counter_api.md create mode 100644 uts/objects/unit/live_map.md create mode 100644 uts/objects/unit/live_map_api.md create mode 100644 uts/objects/unit/live_object_subscribe.md create mode 100644 uts/objects/unit/object_id.md create mode 100644 uts/objects/unit/objects_pool.md create mode 100644 uts/objects/unit/path_object.md create mode 100644 uts/objects/unit/path_object_mutations.md create mode 100644 uts/objects/unit/path_object_subscribe.md create mode 100644 uts/objects/unit/realtime_object.md create mode 100644 uts/objects/unit/value_types.md diff --git a/uts/objects/PLAN.md b/uts/objects/PLAN.md new file mode 100644 index 00000000..3cc54785 --- /dev/null +++ b/uts/objects/PLAN.md @@ -0,0 +1,379 @@ +# UTS Test Specs for LiveObjects Path-Based API + +## Context + +The LiveObjects feature lets clients store shared CRDT data on realtime channels. The specification is at `specification/specifications/objects-features.md` — specifically the path-based API version on branch `origin/AIT-30/liveobjects-path-based-api-spec` (with batch API additions on `origin/AIT-30/liveobjects-batch-api`). + +An earlier attempt at UTS test specs exists in `uts/test/realtime/unit/objects/` (14 files). It was written against a different spec namespace (PO* vs RTPO*/RTINS*/RTLCV*/RTLMV*), used v5 wire format field names, had apply-on-ACK contradictions, and duplicated setup across files. We're doing a clean rewrite using the correct spec, informed by that earlier work. + +All new test files go in `specification/uts/objects/`. + +## Spec Architecture Summary + +**Internal (not user-facing):** LiveObject, LiveCounter (CRDT counter), LiveMap (LWW map), ObjectsPool (sync state machine), RealtimeObject (channel orchestrator with publishAndApply) + +**Public (user-facing):** PathObject (lazy path reference), Instance (identity-bound reference), LiveCounterValueType/LiveMapValueType (creation descriptors via static `create()` factories), BatchContext (atomic multi-op publish) + +**Wire protocol v6:** `counterInc.number`, `mapSet.{key,value}`, `mapRemove.key`, `mapCreate.{semantics,entries}`, `counterCreateWithObjectId.{nonce,initialValue}`, `mapCreateWithObjectId.{nonce,initialValue}` + +**REST API:** Not specified in objects-features.md. ably-js has REST object tests but those are implementation-specific, not spec'd. No REST test files needed. + +--- + +## File Organization + +### Helper +| File | Purpose | +|------|---------| +| `helpers/standard_test_pool.md` | Shared: standard ObjectsPool fixture, protocol message builders, synced-channel setup pattern | + +### Pure Unit Tests (no mocks) +| File | Spec Points | ~Tests | +|------|-------------|--------| +| `unit/live_counter.md` | RTLC1-4, RTLC6-9, RTLC14, RTLC16, RTLO3, RTLO4a, RTLO4e, RTLO5, RTLO6 | ~28 | +| `unit/live_map.md` | RTLM1-9, RTLM14-16, RTLM18-19, RTLM22-25, RTLO3, RTLO4a, RTLO4e, RTLO5, RTLO6 | ~42 | +| `unit/objects_pool.md` | RTO3-9 | ~35 | +| `unit/object_id.md` | RTO14 | ~5 | +| `unit/value_types.md` | RTLCV1-4, RTLMV1-4 (consumption generates ObjectMessages with v6 wire format) | ~19 | + +### Mock WebSocket Unit Tests +| File | Spec Points | ~Tests | +|------|-------------|--------| +| `unit/realtime_object.md` | RTO2, RTO10, RTO15-20, RTO22-24 (sync events, publish, publishAndApply, mode checks, GC) | ~33 | +| `unit/live_counter_api.md` | RTLC5, RTLC11-13 (value, increment, decrement through channel) | ~13 | +| `unit/live_map_api.md` | RTLM5, RTLM10-13, RTLM20-21, RTLM24 (reads + mutations through channel, echoMessages check) | ~18 | +| `unit/live_object_subscribe.md` | RTLO4b, RTLO4c (subscribe/unsubscribe on internal LiveObject) | ~8 | +| `unit/path_object.md` | RTPO1-14 (navigation, value, instance, entries, compact, compactJson) | ~33 | +| `unit/path_object_mutations.md` | RTPO15-18, RTPO3c2 (set, remove, increment, decrement, error on unresolvable path) | ~12 | +| `unit/path_object_subscribe.md` | RTPO19-21, RTO24 (path subscriptions, depth filtering, path-following semantics, subscribeIterator) | ~20 | +| `unit/instance.md` | RTINS1-18 (id, value, get, entries, size, compact, set, remove, increment, subscribe) | ~26 | +| `unit/batch.md` | RTPO22, RTINS19, RTBC1-16 (batch entry, BatchContext methods, RootBatchContext flush/close) | ~20 | + +### Integration Tests (sandbox) +| File | Spec Points | ~Tests | +|------|-------------|--------| +| `integration/objects_lifecycle_test.md` | RTO23, RTPO15, RTPO17 (create objects, mutate via PathObject, read back, REST provisioning) | ~6 | +| `integration/objects_sync_test.md` | RTO4, RTO5, RTO17 (attach, sync sequence, re-attach) | ~4 | +| `integration/objects_batch_test.md` | RTPO22, RTBC12-15 (batch publish, atomic delivery) | ~3 | +| `integration/objects_gc_test.md` | RTO10, RTLM19 (behavioral GC verification with ADVANCE_TIME) | ~2 | + +### Proxy Integration Tests +| File | Spec Points | ~Tests | +|------|-------------|--------| +| `integration/proxy/objects_faults.md` | RTO5a2, RTO7, RTO8, RTO17, RTO20e (sync interruption, mutation buffering during re-sync, server-initiated detach, publish failure on FAILED channel, publish during delayed sync) | ~5 | + +**Totals: ~21 files, ~330 tests** + +--- + +## Helper Spec Design + +### `helpers/standard_test_pool.md` + +**Standard test tree:** +``` +root (LiveMap, objectId: "root") + +-- "name" -> string "Alice" + +-- "age" -> number 30 + +-- "active" -> boolean true + +-- "score" -> objectId "counter:score@1000" + +-- "profile" -> objectId "map:profile@1000" + +-- "data" -> json {"tags": ["a", "b"]} + +-- "avatar" -> bytes base64("AQID") (raw bytes: [1, 2, 3]) + +counter:score@1000 (LiveCounter, data: 100) + +map:profile@1000 (LiveMap) + +-- "email" -> string "alice@example.com" + +-- "nested_counter" -> objectId "counter:nested@1000" + +-- "prefs" -> objectId "map:prefs@1000" + +counter:nested@1000 (LiveCounter, data: 5) + +map:prefs@1000 (LiveMap) + +-- "theme" -> string "dark" +``` + +**Builder functions:** +- `build_object_sync_message(channel, channelSerial, objectMessages[])` -> OBJECT_SYNC ProtocolMessage +- `build_object_message(channel, objectMessages[])` -> OBJECT ProtocolMessage +- `build_ack_message(msgSerial, serials[])` -> ACK ProtocolMessage with `res: [{ serials }]` +- `build_counter_inc(objectId, number, serial, siteCode)` -> ObjectMessage +- `build_map_set(objectId, key, value, serial, siteCode)` -> ObjectMessage +- `build_map_remove(objectId, key, serial, siteCode, serialTimestamp?)` -> ObjectMessage +- `build_map_clear(objectId, serial, siteCode)` -> ObjectMessage +- `build_object_delete(objectId, serial, siteCode, serialTimestamp?)` -> ObjectMessage +- `build_counter_create(objectId, counterCreate, serial, siteCode)` -> ObjectMessage +- `build_map_create(objectId, mapCreate, serial, siteCode)` -> ObjectMessage +- `build_object_state(objectId, siteTimeserials, {map?, counter?, tombstone?, createOp?})` -> ObjectMessage wrapping ObjectState + +**Standard synced-channel pattern** (referenced by all mock-WS test files): +```pseudo +setup_synced_channel(channel_name): + mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", + siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, + channelSerial: "attach-serial-1", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message( + msg.channel, "sync1:", STANDARD_POOL_OBJECTS + )) + ELSE IF msg.action == OBJECT: + // Auto-ACK with generated serials + serials = msg.state.map((_, i) => "ack-serial-" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } + ) + install_mock(mock_ws) + client = Realtime(options: {key: "fake:key", autoConnect: true}) + channel = client.channels.get(channel_name, {modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"]}) + root = AWAIT channel.object.get() + RETURN {client, channel, root, mock_ws} +``` + +--- + +## Pure Unit Test Design + +### `unit/live_counter.md` -- CRDT Counter Data Structure + +Directly construct `LiveCounter`, call `applyOperation()` and `replaceData()`, assert internal state. + +**Key test groups:** +1. **Zero value (RTLC4):** data=0, siteTimeserials={}, createOperationIsMerged=false, isTombstone=false +2. **COUNTER_INC (RTLC9):** adds `counterInc.number` to data; noop when number missing +3. **COUNTER_CREATE (RTLC8/RTLC16):** merges `counterCreate.count`; noop when already merged +4. **Newness check (RTLO4a):** empty siteSerial allows apply; stale serial rejected; empty serial/siteCode logs warning +5. **siteTimeserials (RTLC7c):** CHANNEL source updates map; LOCAL source does not +6. **applyOperation returns bool (RTLC7g):** true on success, false on rejection/tombstone +7. **Tombstone (RTLC7e, RTLO4e, RTLO5):** OBJECT_DELETE tombstones; ops on tombstoned counter rejected +8. **replaceData (RTLC6):** full replacement; tombstone handling; createOp merge; diff calculation +9. **tombstonedAt (RTLO6):** from serialTimestamp if present, else local clock + +### `unit/live_map.md` -- LWW Map Data Structure + +Same pattern. Key additional concerns: + +1. **MAP_SET (RTLM7):** new entry, existing entry update, LWW rejection, clearTimeserial floor (RTLM7h), objectId creates zero-value object (RTLM7g) +2. **MAP_REMOVE (RTLM8):** tombstones entry, sets tombstonedAt via RTLO6, clearTimeserial floor (RTLM8g) +3. **MAP_CLEAR (RTLM24):** sets clearTimeserial, removes entries with serial <= clear serial, preserves newer entries +4. **Entry-level LWW (RTLM9):** 5 serial comparison cases +5. **MAP_CREATE (RTLM16/RTLM23):** merges entries via individual MAP_SET/MAP_REMOVE calls +6. **replaceData (RTLM6):** sets clearTimeserial from ObjectState.map.clearTimeserial (RTLM6i) +7. **get/size/entries (RTLM5/RTLM10/RTLM11):** value resolution, tombstone filtering, objectId reference resolution +8. **GC (RTLM19):** removes tombstoned entries past grace period +9. **Diff (RTLM22):** non-tombstoned entry comparison + +### `unit/objects_pool.md` -- Pool + Sync State Machine + +Directly construct ObjectsPool, call `processAttached()`, `processObjectSync()`, `processObjectMessage()`. + +1. **Initialization (RTO3):** root LiveMap always present +2. **ATTACHED handling (RTO4):** HAS_OBJECTS -> SYNCING; no flag -> clear pool + immediate SYNCED +3. **OBJECT_SYNC sequence (RTO5/RTO5f):** accumulate in SyncObjectsPool; partial merge (RTO5f2a); cursor parsing; new sequence discards old (RTO5a2) +4. **Sync completion (RTO5c):** replace existing (RTO5c1a), create new (RTO5c1b), remove absent (RTO5c2), emit updates (RTO5c7), apply buffered ops (RTO5c6), clear appliedOnAckSerials (RTO5c9), transition to SYNCED (RTO5c8) +5. **Buffering (RTO7/RTO8):** OBJECT messages buffered during SYNCING, applied when SYNCED +6. **Operation application (RTO9):** appliedOnAckSerials dedup (RTO9a3), LOCAL source adds to set (RTO9a2a4), null op warning (RTO9a1), unsupported action warning (RTO9a2b) +7. **Zero-value creation (RTO6):** infer type from objectId prefix +8. **GC (RTO10):** tombstoned objects removed after grace period + +### `unit/object_id.md` -- ObjectId Generation (RTO14) + +Pure function tests: +1. Format: `{type}:{base64url(SHA-256(initialValue:nonce))}@{timestamp}` +2. SHA-256 of UTF-8 `{initialValue}:{nonce}` -> base64url (RFC 4648 s.5) +3. `map` and `counter` type prefixes +4. Deterministic: same inputs -> same objectId +5. Different nonce -> different objectId + +### `unit/value_types.md` -- LiveCounterValueType / LiveMapValueType + +Tests the static `create()` factories and consumption procedure. + +**LiveCounterValueType (RTLCV1-4):** +1. `LiveCounter.create(42)` -> immutable LiveCounterValueType with count=42 +2. `LiveCounter.create()` -> count defaults to 0 +3. Consumption: validates count, builds CounterCreate, generates objectId, returns ObjectMessage with `counterCreateWithObjectId.{nonce, initialValue}` +4. Non-number count throws 40003 during consumption + +**LiveMapValueType (RTLMV1-4):** +1. `LiveMap.create({entries})` -> immutable LiveMapValueType +2. Consumption: validates keys/values, builds entries, generates objectId, returns ObjectMessage with `mapCreateWithObjectId.{nonce, initialValue}` +3. Nested value types: LiveMapValueType containing LiveCounterValueType -> depth-first ObjectMessage array (inner creates before outer) +4. Retains local MapCreate/CounterCreate alongside wire format (RTLMV4j5/RTLCV4g5) + +--- + +## Mock WebSocket Test Design + +### `unit/realtime_object.md` -- Orchestration + +Uses `setup_synced_channel()` from helper. + +**Key tests:** +- **RTO23:** get() requires OBJECT_SUBSCRIBE, throws on DETACHED/FAILED, waits for SYNCED, returns PathObject +- **RTO2:** channel mode enforcement (granted vs requested modes) +- **RTO15/RTO15h:** publish sends OBJECT PM, returns PublishResult from ACK res array +- **RTO20:** publishAndApply: publishes, constructs synthetic messages with siteCode from ConnectionDetails, applies with source=LOCAL, adds to appliedOnAckSerials +- **RTO20c:** fails gracefully when siteCode or serials missing +- **RTO20d1:** null serial in PublishResult (conflated op) is skipped +- **RTO20e:** waits for SYNCED during SYNCING; fails with 92008 if channel enters DETACHED/SUSPENDED/FAILED +- **RTO17/RTO18/RTO19:** sync state events, on/off registration +- **RTO10:** GC with fake timers + ADVANCE_TIME + +### `unit/path_object.md` -- Read Operations + +- **RTPO4:** path() string representation with dot escaping +- **RTPO5/RTPO6:** get(key) / at("a.b.c") -- pure navigation, no resolution +- **RTPO7:** value() -- counter returns number, primitive returns value, LiveMap returns null, unresolvable returns null +- **RTPO8:** instance() -- LiveObject returns Instance, primitive returns null +- **RTPO9-11:** entries/keys/values -- yields [key, PathObject] pairs for LiveMap entries +- **RTPO12:** size() -- non-tombstoned entry count +- **RTPO13:** compact() -- recursive, cycle detection with shared object references +- **RTPO14:** compactJson() -- binary as base64, cycles as {objectId: ...} +- **RTPO3:** path resolution (RTPO3a): walk segments through LiveMaps; fail if intermediate not LiveMap + +### `unit/path_object_mutations.md` -- Write Operations + +- **RTPO15:** set(value) -- constructs ObjectMessages, calls publishAndApply +- **RTPO16:** remove() -- constructs MAP_REMOVE ObjectMessage +- **RTPO17:** increment(n) -- constructs COUNTER_INC ObjectMessage +- **RTPO18:** decrement(n) -- delegates to increment(-n) +- **RTPO3c2:** mutation on unresolvable path throws 92007 + +### `unit/path_object_subscribe.md` -- Path-Based Subscriptions + +- **RTPO19:** subscribe returns Subscription, listener receives PathObjectSubscriptionEvent +- **RTPO19b1:** depth filtering -- depth=1 (self only), depth=2 (self+children), undefined (all) +- **RTPO19b1d:** non-positive depth throws 40003 +- **RTPO19e:** follows path not identity -- object replacement at path -> subscription tracks new object +- **RTPO19f:** child events bubble up to parent subscription +- **RTO24b3:** depth formula: `eventPath.length - subscriptionPath.length + 1 <= depth` +- **RTO24b5:** listener exception caught, doesn't affect other listeners +- **RTPO20:** unsubscribe deregisters + +### `unit/instance.md` -- Identity-Bound Reference + +- **RTINS1:** id property returns objectId +- **RTINS2:** value() -- counter returns number, map returns null +- **RTINS3-5:** get(key), entries(), keys(), values() -- delegate to underlying LiveMap +- **RTINS6:** size() -- non-tombstoned entry count +- **RTINS7:** compact() -- recursive with cycle detection +- **RTINS8:** compactJson() +- **RTINS9-12:** set, remove, increment, decrement -- construct ObjectMessages, call publishAndApply +- **RTINS13-16:** subscribe/unsubscribe with depth filtering +- **RTINS17:** instance follows identity not path -- object replacement at path doesn't affect Instance +- **RTINS18:** operations on tombstoned Instance throw error + +### `unit/live_counter_api.md` -- Counter Through Channel + +- **RTLC5:** value property returns current data +- **RTLC11/RTLC12:** increment/decrement construct correct v6 wire ObjectMessage +- **RTLC12d:** echoMessages=false skips publishAndApply, uses publish +- **RTLC13:** increment with non-number throws 40003 + +### `unit/live_map_api.md` -- Map Through Channel + +- **RTLM5:** get(key) returns resolved value +- **RTLM10/RTLM11:** entries/keys/values iterate non-tombstoned entries +- **RTLM12/RTLM13:** set/remove construct correct v6 wire ObjectMessages +- **RTLM20:** set with LiveCounterValueType/LiveMapValueType consumes value type +- **RTLM20d/RTLM21d:** echoMessages=false uses publish instead of publishAndApply +- **RTLM24:** clear constructs MAP_CLEAR ObjectMessage + +### `unit/live_object_subscribe.md` -- Internal Subscription + +- **RTLO4b:** subscribe(listener) registers on internal LiveObject +- **RTLO4c:** unsubscribe removes listener +- Events fire on applyOperation with update details + +### `unit/batch.md` -- Batch API + +- **RTPO22/RTINS19:** batch entry points -- resolve to LiveObject, create RootBatchContext, execute fn, flush +- **RTPO22c/RTINS19c:** unresolvable path / non-LiveObject throws 92007 +- **RTBC3-11:** read methods delegate to Instance (id, value, get, entries, keys, values, size, compact, compactJson) +- **RTBC4d:** get() wraps result via RootBatchContext#wrapInstance (memoized by objectId -- RTBC16c) +- **RTBC12-15:** write methods (set, remove, increment, decrement) queue message constructors synchronously +- **RTBC16d:** flush executes constructors, publishes all as single array via RTO15 (NOT publishAndApply) +- **RTBC16e:** closed batch throws 40000 on any method call +- **RTBC16f:** RootBatchContext closed after flush regardless of success/failure + +--- + +## Apply-on-ACK Testing Strategy + +The RTO20 publishAndApply flow: +1. Client publishes OBJECT PM +2. Server returns ACK with `res: [{ serials: [...] }]` +3. Client constructs synthetic inbound ObjectMessages (serial + siteCode from ConnectionDetails) +4. Applies via RTO9 with source=LOCAL -> adds serials to `appliedOnAckSerials` +5. When echoed OBJECT PM arrives with same serial -> RTO9a3 deduplicates and removes from set + +**Mock WS handler for mutation tests:** +```pseudo +onMessageFromClient: (msg) => { + IF msg.action == OBJECT: + serials = [] + FOR i IN 0..msg.state.length-1: + serials.append("ack-" + msg.msgSerial + "-" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) +} +``` + +**Tests verify:** +1. After `AWAIT pathObject.set(...)`, local state reflects the change +2. The correct OBJECT PM was sent (v6 wire format) +3. When echo arrives with same serial, no double-application +4. If ACK arrives during SYNCING (RTO20e), publishAndApply waits for SYNCED + +--- + +## Dependency Ordering (write order) + +1. `helpers/standard_test_pool.md` +2. `unit/live_counter.md` -- no dependencies +3. `unit/live_map.md` -- no dependencies +4. `unit/object_id.md` -- no dependencies +5. `unit/objects_pool.md` -- uses LiveCounter/LiveMap concepts +6. `unit/value_types.md` -- uses objectId generation +7. `unit/realtime_object.md` -- uses helper, tests orchestration +8. `unit/live_counter_api.md` -- uses helper +9. `unit/live_map_api.md` -- uses helper +10. `unit/live_object_subscribe.md` -- uses helper +11. `unit/path_object.md` -- uses helper +12. `unit/instance.md` -- uses helper +13. `unit/path_object_mutations.md` -- uses helper +14. `unit/path_object_subscribe.md` -- uses helper +15. `unit/batch.md` -- uses helper, depends on PathObject/Instance concepts +16. `integration/objects_lifecycle_test.md` +17. `integration/objects_sync_test.md` +18. `integration/objects_batch_test.md` +19. `integration/objects_gc_test.md` +20. `integration/proxy/objects_faults.md` + +--- + +## Key Decisions + +| Decision | Rationale | +|----------|-----------| +| Wire format v6 everywhere | Spec branch uses v6 field names; old v5 names are "replaced by" stubs | +| `appliedOnAckSerials` on RealtimeObject (RTO7b), not on pool | Matches spec's placement; cleared at sync completion (RTO5c9) | +| No REST test files | objects-features.md has no REST API spec points; REST used only for integration fixture provisioning | +| `echoMessages` check retained on mutations | Spec retains RTLC12d, RTLM20d, RTLM21d | +| Batch uses RTO15 (publish), NOT RTO20 (publishAndApply) | RTBC16d says "publishes ... using `RealtimeObject#publish`" -- batch does NOT apply locally on ACK | +| LiveObject/LiveMap/LiveCounter marked internal but still unit-tested | Direct testing of CRDT logic is essential; public API tests can't cover all edge cases | +| Test IDs use `objects/unit/` prefix | Matches directory structure, not nested under `realtime/` | +| Behavioral GC testing via ADVANCE_TIME | Verify GC through observable consequences (value becomes null, object recreatable) rather than internal pool state inspection | +| Table-driven tests for input validation | Use FOR loops over scenario arrays (like ably-js forScenarios) to test all invalid/valid type combinations | +| Bytes data type coverage | Standard test pool includes "avatar" bytes entry; compact/compactJson/value tests verify base64 encoding | diff --git a/uts/objects/helpers/standard_test_pool.md b/uts/objects/helpers/standard_test_pool.md new file mode 100644 index 00000000..e0106290 --- /dev/null +++ b/uts/objects/helpers/standard_test_pool.md @@ -0,0 +1,322 @@ +# Standard Test Pool and Helpers + +Shared fixtures, protocol message builders, and synced-channel setup pattern for all LiveObjects test files. + +## Standard Test Tree + +The standard test pool defines a fixed LiveObjects tree used across test files. All object IDs use short synthetic values for clarity (real servers validate the hash format, but unit tests construct objects directly). + +``` +root (LiveMap, objectId: "root", semantics: LWW) + +-- "name" -> string "Alice" + +-- "age" -> number 30 + +-- "active" -> boolean true + +-- "score" -> objectId "counter:score@1000" + +-- "profile" -> objectId "map:profile@1000" + +-- "data" -> json {"tags": ["a", "b"]} + +-- "avatar" -> bytes base64("AQID") (raw bytes: [1, 2, 3]) + +counter:score@1000 (LiveCounter, data: 100) + +map:profile@1000 (LiveMap, semantics: LWW) + +-- "email" -> string "alice@example.com" + +-- "nested_counter" -> objectId "counter:nested@1000" + +-- "prefs" -> objectId "map:prefs@1000" + +counter:nested@1000 (LiveCounter, data: 5) + +map:prefs@1000 (LiveMap, semantics: LWW) + +-- "theme" -> string "dark" +``` + +All map entries have timeserial `"t:0"` and `tombstone: false` unless otherwise noted. +All objects have `siteTimeserials: { "aaa": "t:0" }` and `createOperationIsMerged: true` unless otherwise noted. + +--- + +## STANDARD_POOL_OBJECTS + +An array of `ObjectMessage` instances wrapping `ObjectState` for building OBJECT_SYNC messages. Each object is represented as `build_object_state(...)` using the builders below. + +```pseudo +STANDARD_POOL_OBJECTS = [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "name": { data: { string: "Alice" }, timeserial: "t:0" }, + "age": { data: { number: 30 }, timeserial: "t:0" }, + "active": { data: { boolean: true }, timeserial: "t:0" }, + "score": { data: { objectId: "counter:score@1000" }, timeserial: "t:0" }, + "profile": { data: { objectId: "map:profile@1000" }, timeserial: "t:0" }, + "data": { data: { json: {"tags": ["a", "b"]} }, timeserial: "t:0" }, + "avatar": { data: { bytes: "AQID" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:score@1000", {"aaa": "t:0"}, { + counter: { count: 100 }, + createOp: { counterCreate: { count: 100 } } + }), + build_object_state("map:profile@1000", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "email": { data: { string: "alice@example.com" }, timeserial: "t:0" }, + "nested_counter": { data: { objectId: "counter:nested@1000" }, timeserial: "t:0" }, + "prefs": { data: { objectId: "map:prefs@1000" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:nested@1000", {"aaa": "t:0"}, { + counter: { count: 5 }, + createOp: { counterCreate: { count: 5 } } + }), + build_object_state("map:prefs@1000", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "theme": { data: { string: "dark" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +] +``` + +--- + +## Builder Functions + +### Protocol Message Builders + +```pseudo +build_object_sync_message(channel, channelSerial, objectMessages[]): + RETURN ProtocolMessage( + action: OBJECT_SYNC, + channel: channel, + channelSerial: channelSerial, + state: objectMessages + ) + +build_object_message(channel, objectMessages[]): + RETURN ProtocolMessage( + action: OBJECT, + channel: channel, + state: objectMessages + ) + +build_ack_message(msgSerial, serials[]): + RETURN ProtocolMessage( + action: ACK, + msgSerial: msgSerial, + res: [{ serials: serials }] + ) +``` + +### ObjectMessage Builders (Operations) + +```pseudo +build_counter_inc(objectId, number, serial, siteCode): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + operation: { + action: "COUNTER_INC", + objectId: objectId, + counterInc: { number: number } + } + ) + +build_map_set(objectId, key, value, serial, siteCode): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + operation: { + action: "MAP_SET", + objectId: objectId, + mapSet: { key: key, value: value } + } + ) + +build_map_remove(objectId, key, serial, siteCode, serialTimestamp?): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + serialTimestamp: serialTimestamp, + operation: { + action: "MAP_REMOVE", + objectId: objectId, + mapRemove: { key: key } + } + ) + +build_map_clear(objectId, serial, siteCode): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + operation: { + action: "MAP_CLEAR", + objectId: objectId + } + ) + +build_object_delete(objectId, serial, siteCode, serialTimestamp?): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + serialTimestamp: serialTimestamp, + operation: { + action: "OBJECT_DELETE", + objectId: objectId + } + ) + +build_counter_create(objectId, counterCreate, serial, siteCode): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + operation: { + action: "COUNTER_CREATE", + objectId: objectId, + counterCreate: counterCreate + } + ) + +build_map_create(objectId, mapCreate, serial, siteCode): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + operation: { + action: "MAP_CREATE", + objectId: objectId, + mapCreate: mapCreate + } + ) +``` + +### ObjectMessage Builder (State — for OBJECT_SYNC) + +```pseudo +build_object_state(objectId, siteTimeserials, opts): + state = { + objectId: objectId, + siteTimeserials: siteTimeserials + } + IF opts.map IS NOT null: + state.map = opts.map + IF opts.counter IS NOT null: + state.counter = opts.counter + IF opts.tombstone IS NOT null: + state.tombstone = opts.tombstone + IF opts.createOp IS NOT null: + state.createOp = opts.createOp + RETURN ObjectMessage(object: state) +``` + +--- + +## Standard Synced-Channel Setup + +Used by all mock WebSocket test files. Creates a connected client with a synced channel containing the standard test pool. + +```pseudo +setup_synced_channel(channel_name): + mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", + connectionKey: "conn-key-1", + siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message( + msg.channel, "sync1:", STANDARD_POOL_OBJECTS + )) + ELSE IF msg.action == OBJECT: + serials = [] + FOR i IN 0..msg.state.length - 1: + serials.append("ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } + ) + install_mock(mock_ws) + + client = Realtime(options: { + key: "fake:key", + autoConnect: true + }) + channel = client.channels.get(channel_name, { + modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] + }) + root = AWAIT channel.object.get() + + RETURN { client, channel, root, mock_ws } +``` + +### Variant: Setup Without Auto-ACK + +For tests that need to control ACK timing, use this variant that omits the OBJECT message handler: + +```pseudo +setup_synced_channel_no_ack(channel_name): + mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", + connectionKey: "conn-key-1", + siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message( + msg.channel, "sync1:", STANDARD_POOL_OBJECTS + )) + } + ) + install_mock(mock_ws) + + client = Realtime(options: { + key: "fake:key", + autoConnect: true + }) + channel = client.channels.get(channel_name, { + modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] + }) + root = AWAIT channel.object.get() + + RETURN { client, channel, root, mock_ws } +``` + +--- + +## REST Fixture Provisioning + +For integration tests that need pre-existing object state before the test client connects, use the REST API to establish fixtures. + +```pseudo +provision_objects_via_rest(api_key, channel_name, operations): + POST https://sandbox-rest.ably.io/channels/{encode_uri_component(channel_name)}/objects + WITH Authorization: Basic {base64(api_key)} + WITH Content-Type: application/json + WITH body: { "messages": operations } +``` diff --git a/uts/objects/integration/objects_batch_test.md b/uts/objects/integration/objects_batch_test.md new file mode 100644 index 00000000..a5805482 --- /dev/null +++ b/uts/objects/integration/objects_batch_test.md @@ -0,0 +1,201 @@ +# Objects Batch Integration Tests + +Spec points: `RTPO22`, `RTBC12`–`RTBC15` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +Batch operations end-to-end — multiple mutations in a single publish, atomic +propagation to subscribers. Verifies that batch() groups multiple operations +into a single ProtocolMessage and the server processes and delivers them +correctly to other clients. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Notes +- Each test uses a unique channel name + +--- + +## RTPO22 - Batch set of multiple keys arrives to second client + +**Test ID**: `objects/integration/RTPO22/batch-set-propagates-0` + +**Spec requirement:** batch() groups multiple mutations into a single publish. +All operations are delivered together to subscribers. + +### Setup +```pseudo +channel_name = "objects-batch-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +AWAIT root_a.batch((ctx) => { + ctx.set("x", 1) + ctx.set("y", 2) + ctx.set("z", 3) +}) + +poll_until(root_b.get("x").value() == 1, timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("x").value() == 1 +ASSERT root_b.get("y").value() == 2 +ASSERT root_b.get("z").value() == 3 +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTPO22 - Batch with mixed operations (set + remove + increment) + +**Test ID**: `objects/integration/RTPO22/batch-mixed-ops-0` + +**Spec requirement:** Batch can contain different operation types published atomically. + +### Setup +```pseudo +channel_name = "objects-batch-mixed-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +// Set up initial state +AWAIT root_a.set("to_remove", "temp") +AWAIT root_a.set("counter", LiveCounter.create(10)) +poll_until(root_b.get("to_remove").value() == "temp", timeout: 10s) +poll_until(root_b.get("counter").value() == 10, timeout: 10s) + +// Batch with mixed operations +AWAIT root_a.batch((ctx) => { + ctx.set("name", "Alice") + ctx.remove("to_remove") + child = ctx.get("counter") + child.increment(5) +}) + +poll_until(root_b.get("name").value() == "Alice", timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("name").value() == "Alice" +ASSERT root_b.get("to_remove").value() == null +ASSERT root_b.get("counter").value() == 15 +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTPO22 - Batch with LiveCounterValueType creates counter atomically + +**Test ID**: `objects/integration/RTPO22/batch-create-counter-0` + +**Spec requirement:** Batch containing LiveCounterValueType generates COUNTER_CREATE + +MAP_SET in a single publish. The server processes both atomically. + +### Setup +```pseudo +channel_name = "objects-batch-counter-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +AWAIT root_a.batch((ctx) => { + ctx.set("batch_counter", LiveCounter.create(99)) + ctx.set("label", "created in batch") +}) + +poll_until(root_b.get("batch_counter").value() == 99, timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("batch_counter").value() == 99 +ASSERT root_b.get("label").value() == "created in batch" +ASSERT root_b.get("batch_counter").instance() IS NOT null +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` diff --git a/uts/objects/integration/objects_gc_test.md b/uts/objects/integration/objects_gc_test.md new file mode 100644 index 00000000..2d9bc86a --- /dev/null +++ b/uts/objects/integration/objects_gc_test.md @@ -0,0 +1,138 @@ +# Objects GC Integration Tests + +Spec points: `RTO10`, `RTLM19` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +Behavioral verification of garbage collection for tombstoned objects and tombstoned +map entries. Uses `ADVANCE_TIME` (fake timers) to control timing and verifies GC +through observable API consequences rather than internal pool state inspection. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Notes +- These tests use fake timers to control GC timing +- Each test uses a unique channel name + +--- + +## RTO10 - Tombstoned object is GC'd and recreatable + +**Test ID**: `objects/integration/RTO10/tombstoned-object-gc-recreate-0` + +**Spec requirement:** After an object is tombstoned and the GC grace period elapses, +the object is removed from the pool. A new object can then be created at the same +map key. + +### Setup +```pseudo +enable_fake_timers() +channel_name = "objects-gc-object-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +// Create a counter +AWAIT root.set("counter", LiveCounter.create(42)) +ASSERT root.get("counter").value() == 42 +counter_id = root.get("counter").instance().id() + +// Remove it (tombstones the entry and the object) +AWAIT root.remove("counter") +ASSERT root.get("counter").value() == null + +// Advance past GC grace period +ADVANCE_TIME(86400000 + 300000) + +// Create a new counter at the same key +AWAIT root.set("counter", LiveCounter.create(99)) +``` + +### Assertions +```pseudo +ASSERT root.get("counter").value() == 99 +new_counter_id = root.get("counter").instance().id() +ASSERT new_counter_id != counter_id +``` + +### Teardown +```pseudo +client.close() +``` + +--- + +## RTLM19 - Tombstoned map entry is GC'd, re-settable with old serial + +**Test ID**: `objects/integration/RTLM19/tombstoned-entry-gc-reset-0` + +**Spec requirement:** After a map entry is tombstoned and GC'd, the entry is fully +removed. A subsequent MAP_SET with any serial succeeds because there is no existing +entry to compare against. + +### Setup +```pseudo +enable_fake_timers() +channel_name = "objects-gc-entry-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +// Set then remove a key +AWAIT root.set("ephemeral", "temporary") +ASSERT root.get("ephemeral").value() == "temporary" + +AWAIT root.remove("ephemeral") +ASSERT root.get("ephemeral").value() == null + +// Advance past GC grace period for entries +ADVANCE_TIME(86400000 + 300000) + +// Set the same key again +AWAIT root.set("ephemeral", "revived") +``` + +### Assertions +```pseudo +ASSERT root.get("ephemeral").value() == "revived" +``` + +### Teardown +```pseudo +client.close() +``` diff --git a/uts/objects/integration/objects_lifecycle_test.md b/uts/objects/integration/objects_lifecycle_test.md new file mode 100644 index 00000000..9c440f51 --- /dev/null +++ b/uts/objects/integration/objects_lifecycle_test.md @@ -0,0 +1,317 @@ +# Objects Lifecycle Integration Tests + +Spec points: `RTO23`, `RTPO15`, `RTPO17` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +End-to-end lifecycle: connect, sync, create objects via PathObject, mutate, and +verify propagation to a second client. Complements unit tests by verifying real +server sync, mutation delivery, and object creation. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Notes +- Each test uses a unique channel name to avoid interference + +--- + +## RTO23, RTPO15 - Set primitive via PathObject, second client reads it + +**Test ID**: `objects/integration/RTO23-RTPO15/set-primitive-propagates-0` + +**Spec requirement:** PathObject#set delegates to LiveMap#set. The mutation +propagates via the server and a second client sees the updated value. + +### Setup +```pseudo +channel_name = "objects-lifecycle-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +// Client A sets a value +AWAIT root_a.set("greeting", "hello") + +// Client B subscribes and waits for the update +events_b = [] +root_b.subscribe((event) => events_b.append(event)) +poll_until(root_b.get("greeting").value() == "hello", timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("greeting").value() == "hello" +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTPO15 - Set with LiveCounterValueType, second client reads counter + +**Test ID**: `objects/integration/RTPO15/set-counter-value-type-0` + +**Spec requirement:** PathObject#set with LiveCounterValueType creates a new counter +on the server. Second client syncs and reads the counter value. + +### Setup +```pseudo +channel_name = "objects-counter-create-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +AWAIT root_a.set("my_counter", LiveCounter.create(42)) +poll_until(root_b.get("my_counter").value() == 42, timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("my_counter").value() == 42 +ASSERT root_b.get("my_counter").instance() IS NOT null +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTPO17 - Increment counter, second client sees updated value + +**Test ID**: `objects/integration/RTPO17/increment-propagates-0` + +**Spec requirement:** PathObject#increment delegates to LiveCounter#increment. +The server applies the increment and propagates the updated value. + +### Setup +```pseudo +channel_name = "objects-increment-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +// Create a counter first +AWAIT root_a.set("hits", LiveCounter.create(0)) +poll_until(root_b.get("hits").value() == 0, timeout: 10s) + +// Increment it +AWAIT root_a.get("hits").increment(10) +poll_until(root_b.get("hits").value() == 10, timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_a.get("hits").value() == 10 +ASSERT root_b.get("hits").value() == 10 +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTPO15 - Set with LiveMapValueType, second client reads nested map + +**Test ID**: `objects/integration/RTPO15/set-map-value-type-0` + +**Spec requirement:** PathObject#set with LiveMapValueType creates a nested map. +Second client can navigate into the nested map. + +### Setup +```pseudo +channel_name = "objects-map-create-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +AWAIT root_a.set("settings", LiveMap.create({ + "theme": "dark", + "fontSize": 14 +})) +poll_until(root_b.get("settings").get("theme").value() == "dark", timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("settings").get("theme").value() == "dark" +ASSERT root_b.get("settings").get("fontSize").value() == 14 +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTO23 - get() waits for sync and returns PathObject + +**Test ID**: `objects/integration/RTO23/get-returns-path-object-0` + +**Spec requirement:** channel.object.get() returns a PathObject pointing to the root +after the sync sequence completes. + +### Setup +```pseudo +channel_name = "objects-get-root-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +root = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +ASSERT root.path() == "" +ASSERT root.size() == 0 +``` + +### Teardown +```pseudo +client.close() +``` + +--- + +## RTPO15 - Client syncs pre-existing data provisioned via REST + +**Test ID**: `objects/integration/RTPO15/rest-provisioned-data-sync-0` + +**Spec requirement:** Data created via the REST API is visible to a realtime client +that connects afterward. + +### Setup +```pseudo +channel_name = "objects-rest-provision-" + random_id() + +// Provision data via REST before any realtime client connects +provision_objects_via_rest(api_key, channel_name, [ + { + operation: { + action: "MAP_SET", + objectId: "root", + mapSet: { key: "provisioned", value: { string: "from_rest" } } + } + } +]) +``` + +### Test Steps +```pseudo +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root.get("provisioned").value() == "from_rest" +``` + +### Teardown +```pseudo +client.close() +``` diff --git a/uts/objects/integration/objects_sync_test.md b/uts/objects/integration/objects_sync_test.md new file mode 100644 index 00000000..7f0721ec --- /dev/null +++ b/uts/objects/integration/objects_sync_test.md @@ -0,0 +1,200 @@ +# Objects Sync Integration Tests + +Spec points: `RTO4`, `RTO5`, `RTO17` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +Verify the sync sequence against the real server: attach with HAS_OBJECTS, +receive OBJECT_SYNC, reach SYNCED state. Also tests re-attach behaviour where +the client detaches and re-attaches to verify the pool is re-synced. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Notes +- Each test uses a unique channel name + +--- + +## RTO4, RTO5 - Attach triggers sync, get() resolves after SYNCED + +**Test ID**: `objects/integration/RTO4-RTO5/attach-sync-get-0` + +**Spec requirement:** On ATTACHED with HAS_OBJECTS flag, client transitions to SYNCING, +processes OBJECT_SYNC messages, then transitions to SYNCED. get() waits for SYNCED. + +### Setup +```pseudo +channel_name = "objects-sync-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +root = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +ASSERT root.path() == "" +``` + +### Teardown +```pseudo +client.close() +``` + +--- + +## RTO5, RTO17 - Two clients sync same channel with pre-existing data + +**Test ID**: `objects/integration/RTO5-RTO17/two-clients-sync-0` + +**Spec requirement:** Both clients complete sync and see the same object pool state. + +### Setup +```pseudo +channel_name = "objects-two-sync-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +// Client A creates data +root_a = AWAIT channel_a.object.get() +AWAIT root_a.set("key1", "value1") + +// Client B attaches and syncs — should see the data +root_b = AWAIT channel_b.object.get() +poll_until(root_b.get("key1").value() == "value1", timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("key1").value() == "value1" +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTO17 - Re-attach re-syncs object pool + +**Test ID**: `objects/integration/RTO17/reattach-resyncs-0` + +**Spec requirement:** On re-attach, the sync state machine restarts and the pool +is re-populated from the server. + +### Setup +```pseudo +channel_name = "objects-reattach-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +// Set some data +AWAIT root.set("before_detach", "hello") +ASSERT root.get("before_detach").value() == "hello" + +// Detach and re-attach +AWAIT channel.detach() +AWAIT channel.attach() + +// Re-sync should restore data +root = AWAIT channel.object.get() +poll_until(root.get("before_detach").value() == "hello", timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root.get("before_detach").value() == "hello" +``` + +### Teardown +```pseudo +client.close() +``` + +--- + +## RTO4 - Attach without OBJECT_SUBSCRIBE still resolves get() with empty pool + +**Test ID**: `objects/integration/RTO4/attach-subscribe-only-0` + +**Spec requirement:** Channel attached with only OBJECT_SUBSCRIBE mode. Server +sends HAS_OBJECTS, sync completes, root is an empty LiveMap. + +### Setup +```pseudo +channel_name = "objects-subscribe-only-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE"] }) +``` + +### Test Steps +```pseudo +root = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +ASSERT root.size() == 0 +``` + +### Teardown +```pseudo +client.close() +``` diff --git a/uts/objects/integration/proxy/objects_faults.md b/uts/objects/integration/proxy/objects_faults.md new file mode 100644 index 00000000..24069b73 --- /dev/null +++ b/uts/objects/integration/proxy/objects_faults.md @@ -0,0 +1,459 @@ +# Objects Proxy Integration Tests + +Spec points: `RTO5a2`, `RTO7`, `RTO8`, `RTO17`, `RTO20e` + +## Test Type + +Proxy integration test against Ably Sandbox endpoint + +## Proxy Infrastructure + +See `realtime/integration/helpers/proxy.md` for the full proxy infrastructure specification. + +## Corresponding Unit Tests + +- `objects/unit/objects_pool.md` — RTO5a2 (new sync discards old), RTO7/RTO8 (buffering during SYNCING) +- `objects/unit/realtime_object.md` — RTO17 (sync state events), RTO20e (publishAndApply waits for SYNCED/fails on FAILED) + +## Sandbox Setup + +Tests run against the Ably Sandbox via a programmable proxy. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Common Cleanup + +```pseudo +AFTER EACH TEST: + IF client IS NOT null AND client.connection.state IN [connected, connecting, disconnected]: + client.connection.close() + AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10 seconds + IF session IS NOT null: + session.close() +``` + +### Protocol Message Action Numbers (Objects-relevant) + +| Name | Number | +|------|--------| +| ATTACHED | 11 | +| DETACHED | 13 | +| OBJECT | 19 | +| OBJECT_SYNC | 20 | + +--- + +## RTO5a2, RTO17 - Sync interrupted by disconnect, re-syncs on reconnect + +**Test ID**: `objects/proxy/RTO5a2-RTO17/sync-interrupted-reconnect-0` + +| Spec | Requirement | +|------|-------------| +| RTO5a2 | New sync sequence discards old SyncObjectsPool | +| RTO17 | Sync state transitions: SYNCING → SYNCED, re-triggered on re-attach | + +Tests that when the connection drops mid-OBJECT_SYNC, the client discards +partial sync state and re-syncs cleanly on reconnect. The proxy disconnects +after the first OBJECT_SYNC frame so the sync is never completed, then on +reconnect the client re-attaches and syncs fully. + +### Setup + +```pseudo +channel_name = "objects-sync-interrupt-" + random_id() + +// Disconnect after first OBJECT_SYNC frame +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + 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", + port: allocated_port, + 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", + port: allocated_port, + 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", + port: allocated_port, + rules: [] +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + WITH timeout: 15 seconds + +root = AWAIT channel.object.get() + WITH timeout: 15 seconds + +// Inject channel ERROR to transition to FAILED +session.trigger_action({ + type: "inject_to_client", + message: { + action: 9, + channel: channel_name, + error: { statusCode: 400, code: 90000, message: "injected error" } + } +}) + +AWAIT_STATE channel.state == ChannelState.failed + WITH timeout: 15 seconds + +// Attempt a mutation — should fail since channel is FAILED +AWAIT root.set("key", "value") FAILS WITH error +``` + +### Assertions + +```pseudo +ASSERT error.code == 92008 +``` + +--- + +## RTO5, RTO7 - Publish during sync, echo arrives after sync completes + +**Test ID**: `objects/proxy/RTO5-RTO7/publish-during-sync-echo-after-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c6 | Apply buffered OBJECT messages after sync completes | +| RTO7 | Buffer OBJECT messages during SYNCING | + +The proxy delays the OBJECT_SYNC completion so the client stays in SYNCING. +Client A publishes a mutation that arrives as an OBJECT message to client B +while B is still syncing. The mutation must be buffered and applied after +sync completes. + +### Setup + +```pseudo +channel_name = "objects-publish-during-sync-" + random_id() + +// Client A: direct, no proxy +client_a = Realtime(options: { key: api_key }) +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + WITH timeout: 15 seconds + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root_a = AWAIT channel_a.object.get() + +// Set up initial data +AWAIT root_a.set("existing", "before") + +// Client B: through proxy with delayed OBJECT_SYNC +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [{ + "match": { "type": "ws_frame_to_client", "action": 20 }, + "action": { "type": "delay", "delayMs": 3000 }, + "times": 1, + "comment": "Delay first OBJECT_SYNC to keep B in SYNCING state" + }] +) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps + +```pseudo +// Start client B — will be stuck in SYNCING due to delayed OBJECT_SYNC +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + WITH timeout: 15 seconds +channel_b.attach() + +// While B is syncing, A publishes a mutation +AWAIT root_a.set("existing", "after") + +// B's get() will resolve once delayed sync completes +root_b = AWAIT channel_b.object.get() + WITH timeout: 30 seconds + +// The mutation from A should be visible (either in sync data or buffered OBJECT) +poll_until(root_b.get("existing").value() == "after", timeout: 15s) +``` + +### Assertions + +```pseudo +ASSERT root_b.get("existing").value() == "after" +``` + +### Teardown + +```pseudo +client_a.close() +client_b.close() +session.close() +``` diff --git a/uts/objects/unit/batch.md b/uts/objects/unit/batch.md new file mode 100644 index 00000000..b53098c3 --- /dev/null +++ b/uts/objects/unit/batch.md @@ -0,0 +1,782 @@ +# Batch API Tests + +Spec points: `RTPO22`, `RTINS19`, `RTBC1`–`RTBC16` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTPO22 - PathObject#batch resolves path and executes fn + +**Test ID**: `objects/unit/RTPO22/batch-resolves-and-executes-0` + +| Spec | Requirement | +|------|-------------| +| RTPO22c | Resolves path to LiveObject | +| RTPO22d | Creates RootBatchContext wrapping Instance | +| RTPO22e | Executes fn with BatchContext | +| RTPO22f | Flushes after fn returns | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.set("name", "Bob") + ctx.set("age", 31) +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +ASSERT captured_messages[0].state.length == 2 +ASSERT captured_messages[0].state[0].operation.action == "MAP_SET" +ASSERT captured_messages[0].state[0].operation.mapSet.key == "name" +ASSERT captured_messages[0].state[1].operation.action == "MAP_SET" +ASSERT captured_messages[0].state[1].operation.mapSet.key == "age" +``` + +--- + +## RTPO22c - PathObject#batch on unresolvable path throws 92007 + +**Test ID**: `objects/unit/RTPO22c/batch-unresolvable-throws-0` + +**Spec requirement:** If path does not resolve to LiveObject, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("nonexistent").get("deep").batch((ctx) => {}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTINS19 - Instance#batch resolves and executes fn + +**Test ID**: `objects/unit/RTINS19/batch-instance-executes-0` + +| Spec | Requirement | +|------|-------------| +| RTINS19d | Creates RootBatchContext wrapping Instance | +| RTINS19e | Executes fn with BatchContext | +| RTINS19f | Flushes after fn returns | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +instance = root.instance() +AWAIT instance.batch((ctx) => { + ctx.set("name", "Charlie") + ctx.remove("age") +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +ASSERT captured_messages[0].state.length == 2 +ASSERT captured_messages[0].state[0].operation.action == "MAP_SET" +ASSERT captured_messages[0].state[1].operation.action == "MAP_REMOVE" +``` + +--- + +## RTINS19c - Instance#batch on non-LiveObject throws 92007 + +**Test ID**: `objects/unit/RTINS19c/batch-non-live-object-throws-0` + +**Spec requirement:** If wrapped value is not a LiveObject, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +name_inst = root.instance().get("name") +AWAIT name_inst.batch((ctx) => {}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTBC3 - BatchContext#id returns objectId + +**Test ID**: `objects/unit/RTBC3/id-returns-objectid-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +received_id = null +AWAIT root.batch((ctx) => { + received_id = ctx.id() +}) +``` + +### Assertions +```pseudo +ASSERT received_id == "root" +``` + +--- + +## RTBC5 - BatchContext#value delegates to Instance#value + +**Test ID**: `objects/unit/RTBC5/value-delegates-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +received_value = null +AWAIT root.get("score").batch((ctx) => { + received_value = ctx.value() +}) +``` + +### Assertions +```pseudo +ASSERT received_value == 100 +``` + +--- + +## RTBC4 - BatchContext#get wraps result via wrapInstance + +**Test ID**: `objects/unit/RTBC4/get-wraps-instance-0` + +| Spec | Requirement | +|------|-------------| +| RTBC4c | Delegates to Instance#get | +| RTBC4d | Wraps result via RootBatchContext#wrapInstance | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +child_id = null +AWAIT root.batch((ctx) => { + child = ctx.get("score") + child_id = child.id() +}) +``` + +### Assertions +```pseudo +ASSERT child_id == "counter:score@1000" +``` + +--- + +## RTBC4 - BatchContext#get returns null for nonexistent key + +**Test ID**: `objects/unit/RTBC4/get-null-nonexistent-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +result = "not_null" +AWAIT root.batch((ctx) => { + result = ctx.get("nonexistent") +}) +``` + +### Assertions +```pseudo +ASSERT result == null +``` + +--- + +## RTBC6 - BatchContext#entries yields [key, BatchContext] pairs + +**Test ID**: `objects/unit/RTBC6/entries-yields-pairs-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +keys = [] +AWAIT root.batch((ctx) => { + FOR [key, child] IN ctx.entries(): + keys.append(key) +}) +``` + +### Assertions +```pseudo +ASSERT keys.length == 6 +ASSERT "name" IN keys +ASSERT "score" IN keys +``` + +--- + +## RTBC9 - BatchContext#size delegates to Instance#size + +**Test ID**: `objects/unit/RTBC9/size-delegates-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +received_size = null +AWAIT root.batch((ctx) => { + received_size = ctx.size() +}) +``` + +### Assertions +```pseudo +ASSERT received_size == 6 +``` + +--- + +## RTBC10 - BatchContext#compact delegates to Instance#compact + +**Test ID**: `objects/unit/RTBC10/compact-delegates-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +result = null +AWAIT root.batch((ctx) => { + result = ctx.compact() +}) +``` + +### Assertions +```pseudo +ASSERT result["name"] == "Alice" +ASSERT result["score"] == 100 +``` + +--- + +## RTBC12 - BatchContext#set queues MAP_SET message + +**Test ID**: `objects/unit/RTBC12/set-queues-map-set-0` + +| Spec | Requirement | +|------|-------------| +| RTBC12d | Queues message constructor for MAP_SET | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.set("name", "Bob") +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_SET" +ASSERT obj_msg.operation.objectId == "root" +ASSERT obj_msg.operation.mapSet.key == "name" +ASSERT obj_msg.operation.mapSet.value.string == "Bob" +``` + +--- + +## RTBC12c - BatchContext#set on non-LiveMap throws 92007 + +**Test ID**: `objects/unit/RTBC12c/set-non-map-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").batch((ctx) => { + ctx.set("key", "value") +}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTBC13 - BatchContext#remove queues MAP_REMOVE message + +**Test ID**: `objects/unit/RTBC13/remove-queues-map-remove-0` + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as RTPO22, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.remove("name") +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_REMOVE" +ASSERT obj_msg.operation.objectId == "root" +ASSERT obj_msg.operation.mapRemove.key == "name" +``` + +--- + +## RTBC14 - BatchContext#increment queues COUNTER_INC message + +**Test ID**: `objects/unit/RTBC14/increment-queues-counter-inc-0` + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as RTPO22, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.get("score").batch((ctx) => { + ctx.increment(25) +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "COUNTER_INC" +ASSERT obj_msg.operation.objectId == "counter:score@1000" +ASSERT obj_msg.operation.counterInc.number == 25 +``` + +--- + +## RTBC14c - BatchContext#increment on non-LiveCounter throws 92007 + +**Test ID**: `objects/unit/RTBC14c/increment-non-counter-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.increment(5) +}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTBC15 - BatchContext#decrement delegates to increment with negated amount + +**Test ID**: `objects/unit/RTBC15/decrement-negates-0` + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as RTPO22, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.get("score").batch((ctx) => { + ctx.decrement(10) +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "COUNTER_INC" +ASSERT obj_msg.operation.counterInc.number == -10 +``` + +--- + +## RTBC16c - wrapInstance memoizes by objectId + +**Test ID**: `objects/unit/RTBC16c/wrap-instance-memoized-0` + +**Spec requirement:** If a wrapper for that objectId already exists, the existing wrapper is returned. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +same_ref = false +AWAIT root.batch((ctx) => { + child1 = ctx.get("score") + child2 = ctx.get("score") + same_ref = (child1 IS child2) +}) +``` + +### Assertions +```pseudo +ASSERT same_ref == true +``` + +--- + +## RTBC16d - flush publishes via RTO15 (publish, not publishAndApply) + +**Test ID**: `objects/unit/RTBC16d/flush-uses-publish-0` + +**Spec requirement:** Flushes queued messages as a single array via RealtimeObject#publish. + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.set("name", "Bob") + ctx.set("age", 31) + child = ctx.get("score") + child.increment(50) +}) +``` + +### Assertions +```pseudo +// All operations published as a single OBJECT message +ASSERT captured_messages.length == 1 +ASSERT captured_messages[0].state.length == 3 +ASSERT captured_messages[0].state[0].operation.action == "MAP_SET" +ASSERT captured_messages[0].state[1].operation.action == "MAP_SET" +ASSERT captured_messages[0].state[2].operation.action == "COUNTER_INC" +``` + +--- + +## RTBC16d - flush with no queued messages does not publish + +**Test ID**: `objects/unit/RTBC16d/flush-empty-no-publish-0` + +**Spec requirement:** If there are no queued messages, no publish is performed. + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as RTPO22, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + // Read-only: no writes queued + ctx.value() + ctx.size() +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 0 +``` + +--- + +## RTBC16e - closed batch throws 40000 on any method call + +**Test ID**: `objects/unit/RTBC16e/closed-batch-throws-0` + +**Spec requirement:** After the batch is closed, any method call must throw 40000. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +saved_ctx = null +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + saved_ctx = ctx +}) + +saved_ctx.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +``` + +--- + +## RTBC16e - closed batch read methods also throw 40000 + +**Test ID**: `objects/unit/RTBC16e/closed-batch-read-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +saved_ctx = null +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + saved_ctx = ctx +}) + +saved_ctx.id() FAILS WITH error_id +saved_ctx.value() FAILS WITH error_value +saved_ctx.size() FAILS WITH error_size +``` + +### Assertions +```pseudo +ASSERT error_id.code == 40000 +ASSERT error_value.code == 40000 +ASSERT error_size.code == 40000 +``` + +--- + +## RTPO22g - RootBatchContext closed after flush regardless of success + +**Test ID**: `objects/unit/RTPO22g/closed-after-flush-0` + +**Spec requirement:** The RootBatchContext is closed after flush completes, regardless of success or failure. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +saved_ctx = null +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + saved_ctx = ctx + ctx.set("name", "Bob") +}) + +saved_ctx.set("age", 99) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +``` + +--- + +## RTPO22b - PathObject#batch requires OBJECT_PUBLISH mode + +**Test ID**: `objects/unit/RTPO22b/batch-requires-publish-mode-0` + +**Spec requirement:** Requires OBJECT_PUBLISH channel mode per RTO2. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS, modes: ["OBJECT_SUBSCRIBE"] + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.set("name", "Bob") +}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +``` diff --git a/uts/objects/unit/instance.md b/uts/objects/unit/instance.md new file mode 100644 index 00000000..221d635e --- /dev/null +++ b/uts/objects/unit/instance.md @@ -0,0 +1,524 @@ +# Instance Tests + +Spec points: `RTINS1`–`RTINS19` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTINS3 - id property returns objectId + +**Test ID**: `objects/unit/RTINS3/id-returns-objectid-0` + +| Spec | Requirement | +|------|-------------| +| RTINS3a | LiveObject -> returns objectId | +| RTINS3b | Primitive -> returns null | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +counter_inst = root.get("score").instance() +ASSERT counter_inst.id() == "counter:score@1000" + +map_inst = root.get("profile").instance() +ASSERT map_inst.id() == "map:profile@1000" +``` + +--- + +## RTINS4 - value() returns counter number or primitive + +**Test ID**: `objects/unit/RTINS4/value-counter-0` + +| Spec | Requirement | +|------|-------------| +| RTINS4a | LiveCounter -> numeric value | +| RTINS4c | LiveMap -> null | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +counter_inst = root.get("score").instance() +ASSERT counter_inst.value() == 100 + +map_inst = root.instance() +ASSERT map_inst.value() == null +``` + +--- + +## RTINS5 - get() returns Instance wrapping entry value + +**Test ID**: `objects/unit/RTINS5/get-wraps-entry-0` + +| Spec | Requirement | +|------|-------------| +| RTINS5b | LiveMap -> look up key, wrap result in Instance | +| RTINS5c | Non-LiveMap -> null | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +``` + +### Assertions +```pseudo +name_inst = root_inst.get("name") +ASSERT name_inst IS Instance +ASSERT name_inst.value() == "Alice" + +score_inst = root_inst.get("score") +ASSERT score_inst.id() == "counter:score@1000" + +null_inst = root_inst.get("nonexistent") +ASSERT null_inst == null +``` + +--- + +## RTINS6 - entries() yields [key, Instance] pairs + +**Test ID**: `objects/unit/RTINS6/entries-yields-instances-0` + +| Spec | Requirement | +|------|-------------| +| RTINS6a | LiveMap -> [key, Instance] pairs | +| RTINS6b | Non-LiveMap -> empty iterator | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +``` + +### Test Steps +```pseudo +entries = {} +FOR [key, inst] IN root_inst.entries(): + entries[key] = inst +``` + +### Assertions +```pseudo +ASSERT entries.length == 7 +ASSERT entries["name"] IS Instance +ASSERT entries["name"].value() == "Alice" +``` + +--- + +## RTINS9 - size() returns non-tombstoned count + +**Test ID**: `objects/unit/RTINS9/size-0` + +| Spec | Requirement | +|------|-------------| +| RTINS9a | LiveMap -> non-tombstoned entry count | +| RTINS9b | Non-LiveMap -> null | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +root_inst = root.instance() +ASSERT root_inst.size() == 7 + +counter_inst = root.get("score").instance() +ASSERT counter_inst.size() == null +``` + +--- + +## RTINS10 - compact() recursively compacts + +**Test ID**: `objects/unit/RTINS10/compact-0` + +**Spec requirement:** Behaves identically to PathObject#compact on the wrapped value. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +``` + +### Test Steps +```pseudo +result = root_inst.compact() +``` + +### Assertions +```pseudo +ASSERT result["name"] == "Alice" +ASSERT result["score"] == 100 +ASSERT result["profile"]["email"] == "alice@example.com" +``` + +--- + +## RTINS12 - set() delegates to LiveMap#set + +**Test ID**: `objects/unit/RTINS12/set-delegates-0` + +| Spec | Requirement | +|------|-------------| +| RTINS12b | LiveMap -> delegate to LiveMap#set | +| RTINS12c | Non-LiveMap -> throw 92007 | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +``` + +### Test Steps +```pseudo +AWAIT root_inst.set("name", "Bob") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Bob" +``` + +--- + +## RTINS12c - set() on non-LiveMap throws 92007 + +**Test ID**: `objects/unit/RTINS12c/set-non-map-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.set("key", "value") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTINS13 - remove() delegates to LiveMap#remove + +**Test ID**: `objects/unit/RTINS13/remove-delegates-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +``` + +### Test Steps +```pseudo +AWAIT root_inst.remove("name") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == null +``` + +--- + +## RTINS14 - increment() delegates to LiveCounter#increment + +**Test ID**: `objects/unit/RTINS14/increment-delegates-0` + +| Spec | Requirement | +|------|-------------| +| RTINS14b | LiveCounter -> delegate to increment | +| RTINS14c | Non-LiveCounter -> throw 92007 | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.increment(25) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 125 +``` + +--- + +## RTINS14c - increment() on non-LiveCounter throws 92007 + +**Test ID**: `objects/unit/RTINS14c/increment-non-counter-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +map_inst = root.instance() +``` + +### Test Steps +```pseudo +AWAIT map_inst.increment(5) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTINS15 - decrement() delegates to LiveCounter#decrement + +**Test ID**: `objects/unit/RTINS15/decrement-delegates-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.decrement(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 90 +``` + +--- + +## RTINS16 - subscribe() receives InstanceSubscriptionEvent + +**Test ID**: `objects/unit/RTINS16/subscribe-receives-events-0` + +| Spec | Requirement | +|------|-------------| +| RTINS16c | Subscribes via LiveObject#subscribe | +| RTINS16d1 | Event.object is the Instance | +| RTINS16e | Returns Subscription | +| RTINS16f | Identity-based subscription | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +events = [] +sub = counter_inst.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT sub IS Subscription +ASSERT events.length == 1 +ASSERT events[0].object IS Instance +ASSERT events[0].object.id() == "counter:score@1000" +``` + +--- + +## RTINS16b - subscribe() on primitive throws 92007 + +**Test ID**: `objects/unit/RTINS16b/subscribe-primitive-throws-0` + +**Spec requirement:** If wrapped value is not LiveObject, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +name_inst = root.instance().get("name") +name_inst.subscribe((event) => {}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTINS16f - Instance subscription follows identity not path + +**Test ID**: `objects/unit/RTINS16f/subscription-follows-identity-0` + +**Spec requirement:** Instance follows the specific LiveObject, regardless of tree position. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +events = [] +counter_inst.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") +])) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "100", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length >= 1 +ASSERT counter_inst.id() == "counter:score@1000" +``` + +--- + +## RTINS17 - unsubscribe() deregisters listener + +**Test ID**: `objects/unit/RTINS17/unsubscribe-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +events = [] +sub = counter_inst.subscribe((event) => events.append(event)) +sub.unsubscribe() +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 0 +``` + +--- + +## RTINS14a - increment() defaults to 1 + +**Test ID**: `objects/unit/RTINS14a/increment-default-0` + +**Spec requirement:** amount defaults to 1. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.increment() +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 101 +``` + +--- + +## RTINS15a - decrement() defaults to 1 + +**Test ID**: `objects/unit/RTINS15a/decrement-default-0` + +**Spec requirement:** amount defaults to 1. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.decrement() +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 99 +``` + +--- + +## RTINS16 - Subscription event contains message metadata + +**Test ID**: `objects/unit/RTINS16/subscription-event-metadata-0` + +| Spec | Requirement | +|------|-------------| +| RTINS16d1 | Event.object is the Instance | +| RTINS16d2 | Event.message is the ObjectMessage that triggered the update | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +events = [] +root_inst.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events[0].object IS Instance +ASSERT events[0].object.id() == "root" +ASSERT events[0].message IS NOT null +ASSERT events[0].message.operation.action == "MAP_SET" +ASSERT events[0].message.operation.mapSet.key == "name" +``` diff --git a/uts/objects/unit/live_counter.md b/uts/objects/unit/live_counter.md new file mode 100644 index 00000000..300f1779 --- /dev/null +++ b/uts/objects/unit/live_counter.md @@ -0,0 +1,824 @@ +# LiveCounter Tests + +Spec points: `RTLC1`, `RTLC3`, `RTLC4`, `RTLC6`, `RTLC7`, `RTLC8`, `RTLC9`, `RTLC14`, `RTLC16`, `RTLO3`, `RTLO4a`, `RTLO4e`, `RTLO5`, `RTLO6` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +Tests the `LiveCounter` CRDT data structure. LiveCounter holds a 64-bit float and supports increment operations, create operations (initial value merge), data replacement during sync, tombstoning, and serial-based newness checks. + +Tests operate directly on LiveCounter by calling `applyOperation()` and `replaceData()` with constructed messages. No channel or connection infrastructure is needed. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `build_counter_inc`, `build_counter_create`, `build_object_delete`, `build_object_state`. + +--- + +## RTLC4 - Zero-value LiveCounter + +**Test ID**: `objects/unit/RTLC4/zero-value-0` + +**Spec requirement:** The zero-value LiveCounter has data set to 0, empty siteTimeserials, createOperationIsMerged false, isTombstone false. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Assertions +```pseudo +ASSERT counter.data == 0 +ASSERT counter.objectId == "counter:abc@1000" +ASSERT counter.isTombstone == false +ASSERT counter.tombstonedAt == null +ASSERT counter.createOperationIsMerged == false +ASSERT counter.siteTimeserials == {} +``` + +--- + +## RTLC9 - COUNTER_INC adds number to data + +**Test ID**: `objects/unit/RTLC9/counter-inc-basic-0` + +| Spec | Requirement | +|------|-------------| +| RTLC9f | Add `CounterInc.number` to data if it exists | +| RTLC9g | Return LiveCounterUpdate with amount set to the number | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 5 +ASSERT update.noop == false +ASSERT update.update.amount == 5 +``` + +--- + +## RTLC9 - COUNTER_INC with negative number + +**Test ID**: `objects/unit/RTLC9/counter-inc-negative-0` + +**Spec requirement:** COUNTER_INC with a negative number decrements the counter. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 10 +counter.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", -3, "01", "site1") +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 7 +ASSERT update.update.amount == -3 +``` + +--- + +## RTLC9 - COUNTER_INC with missing number is noop + +**Test ID**: `objects/unit/RTLC9/counter-inc-missing-number-0` + +**Spec requirement:** If CounterInc.number does not exist, return noop. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 10 +``` + +### Test Steps +```pseudo +msg = ObjectMessage( + serial: "01", + siteCode: "site1", + operation: { + action: "COUNTER_INC", + objectId: "counter:abc@1000", + counterInc: {} + } +) +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 10 +ASSERT update.noop == true +``` + +--- + +## RTLC9 - Multiple COUNTER_INC operations accumulate + +**Test ID**: `objects/unit/RTLC9/counter-inc-accumulate-0` + +**Spec requirement:** Multiple increments accumulate additively. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +counter.applyOperation(build_counter_inc("counter:abc@1000", 10, "01", "site1"), source: CHANNEL) +counter.applyOperation(build_counter_inc("counter:abc@1000", 20, "02", "site1"), source: CHANNEL) +counter.applyOperation(build_counter_inc("counter:abc@1000", -5, "01", "site2"), source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 25 +``` + +--- + +## RTLC8, RTLC16 - COUNTER_CREATE merges initial count + +**Test ID**: `objects/unit/RTLC8/counter-create-merge-0` + +| Spec | Requirement | +|------|-------------| +| RTLC8c | Merge initial value via RTLC16 | +| RTLC16a | Add counterCreate.count to data | +| RTLC16b | Set createOperationIsMerged to true | +| RTLC16c | Return LiveCounterUpdate with amount = count | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_create("counter:abc@1000", { count: 42 }, "01", "site1") +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 42 +ASSERT counter.createOperationIsMerged == true +ASSERT update.update.amount == 42 +``` + +--- + +## RTLC8 - COUNTER_CREATE noop when already merged + +**Test ID**: `objects/unit/RTLC8/counter-create-already-merged-0` + +**Spec requirement:** If createOperationIsMerged is true, log and return noop. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 42 +counter.createOperationIsMerged = true +counter.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_counter_create("counter:abc@1000", { count: 99 }, "01", "site1") +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 42 +ASSERT update.noop == true +``` + +--- + +## RTLC16 - COUNTER_CREATE with missing count is noop + +**Test ID**: `objects/unit/RTLC16/counter-create-no-count-0` + +**Spec requirement:** If counterCreate.count does not exist, return noop. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_create("counter:abc@1000", {}, "01", "site1") +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 0 +ASSERT counter.createOperationIsMerged == true +ASSERT update.noop == true +``` + +--- + +## RTLO4a - canApplyOperation allows when siteSerial is empty + +**Test ID**: `objects/unit/RTLO4a/apply-empty-site-serial-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4a5 | If siteSerial is null or empty, return true | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result IS NOT false +ASSERT counter.data == 5 +``` + +--- + +## RTLO4a - canApplyOperation rejects stale serial + +**Test ID**: `objects/unit/RTLO4a/reject-stale-serial-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4a6 | Return true only if serial is greater than siteSerial lexicographically | +| RTLC7b | If canApplyOperation returns false, discard and return false | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.siteTimeserials = { "site1": "05" } +counter.data = 10 +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 99, "03", "site1") +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +ASSERT counter.data == 10 +``` + +--- + +## RTLO4a - canApplyOperation rejects equal serial + +**Test ID**: `objects/unit/RTLO4a/reject-equal-serial-0` + +**Spec requirement:** Serial must be strictly greater; equal serial is rejected. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.siteTimeserials = { "site1": "05" } +counter.data = 10 +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 99, "05", "site1") +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +ASSERT counter.data == 10 +``` + +--- + +## RTLO4a - canApplyOperation warns on empty serial or siteCode + +**Test ID**: `objects/unit/RTLO4a/warn-invalid-serial-0` + +**Spec requirement:** Both serial and siteCode must be non-empty strings. Otherwise, log warning and do not apply. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg_no_serial = ObjectMessage( + serial: "", + siteCode: "site1", + operation: { action: "COUNTER_INC", objectId: "counter:abc@1000", counterInc: { number: 5 } } +) +result1 = counter.applyOperation(msg_no_serial, source: CHANNEL) + +msg_no_site = ObjectMessage( + serial: "01", + siteCode: "", + operation: { action: "COUNTER_INC", objectId: "counter:abc@1000", counterInc: { number: 5 } } +) +result2 = counter.applyOperation(msg_no_site, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 0 +ASSERT result1 == false +ASSERT result2 == false +``` + +--- + +## RTLC7c - CHANNEL source updates siteTimeserials + +**Test ID**: `objects/unit/RTLC7c/channel-source-updates-serials-0` + +**Spec requirement:** If source is CHANNEL, set siteTimeserials[siteCode] = serial. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.siteTimeserials["site1"] == "01" +``` + +--- + +## RTLC7c - LOCAL source does not update siteTimeserials + +**Test ID**: `objects/unit/RTLC7c/local-source-no-serial-update-0` + +**Spec requirement:** If source is LOCAL, siteTimeserials must not be updated. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +counter.applyOperation(msg, source: LOCAL) +``` + +### Assertions +```pseudo +ASSERT counter.siteTimeserials == {} +ASSERT counter.data == 5 +``` + +--- + +## RTLC7g - applyOperation returns true on success + +**Test ID**: `objects/unit/RTLC7g/apply-returns-true-0` + +**Spec requirement:** Returns a boolean indicating whether the operation was successfully applied. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == true +``` + +--- + +## RTLO4e, RTLO5 - OBJECT_DELETE tombstones counter + +**Test ID**: `objects/unit/RTLO5/object-delete-tombstones-0` + +| Spec | Requirement | +|------|-------------| +| RTLO5b | Tombstone the LiveObject | +| RTLO4e2 | Set isTombstone to true | +| RTLO4e4 | Set data to zero-value | +| RTLC7d4a | Emit LiveCounterUpdate with negated previous value | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 42 +counter.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_object_delete("counter:abc@1000", "01", "site1", 1700000000000) +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.isTombstone == true +ASSERT counter.data == 0 +ASSERT counter.tombstonedAt == 1700000000000 +ASSERT update.update.amount == -42 +``` + +--- + +## RTLC7e - Operations on tombstoned counter are rejected + +**Test ID**: `objects/unit/RTLC7e/tombstoned-reject-ops-0` + +**Spec requirement:** If isTombstone is true, the operation cannot be applied. Return false. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.isTombstone = true +counter.tombstonedAt = 1700000000000 +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +ASSERT counter.data == 0 +``` + +--- + +## RTLO6 - tombstonedAt from serialTimestamp + +**Test ID**: `objects/unit/RTLO6/tombstoned-at-from-serial-timestamp-0` + +| Spec | Requirement | +|------|-------------| +| RTLO6a | tombstonedAt equals serialTimestamp if it exists | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_object_delete("counter:abc@1000", "01", "site1", 1700000050000) +counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.tombstonedAt == 1700000050000 +``` + +--- + +## RTLO6 - tombstonedAt from local clock when no serialTimestamp + +**Test ID**: `objects/unit/RTLO6/tombstoned-at-local-clock-0` + +| Spec | Requirement | +|------|-------------| +| RTLO6b | tombstonedAt equals current local time if serialTimestamp not provided | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +before_time = current_time() +``` + +### Test Steps +```pseudo +msg = build_object_delete("counter:abc@1000", "01", "site1") +counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +after_time = current_time() +ASSERT counter.tombstonedAt >= before_time +ASSERT counter.tombstonedAt <= after_time +``` + +--- + +## RTLC7d3 - Unsupported action is discarded + +**Test ID**: `objects/unit/RTLC7d3/unsupported-action-0` + +**Spec requirement:** Log warning, discard without action, return false. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = ObjectMessage( + serial: "01", + siteCode: "site1", + operation: { action: "MAP_SET", objectId: "counter:abc@1000", mapSet: { key: "x", value: { string: "y" } } } +) +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +ASSERT counter.data == 0 +``` + +--- + +## RTLC6 - replaceData sets data from ObjectState + +**Test ID**: `objects/unit/RTLC6/replace-data-basic-0` + +| Spec | Requirement | +|------|-------------| +| RTLC6a | Replace siteTimeserials from ObjectState | +| RTLC6b | Set createOperationIsMerged to false | +| RTLC6c | Set data to counter.count | +| RTLC6h | Return diff as LiveCounterUpdate | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 10 +counter.createOperationIsMerged = true +counter.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site2": "05"}, { + counter: { count: 50 } +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT counter.data == 50 +ASSERT counter.siteTimeserials == { "site2": "05" } +ASSERT counter.createOperationIsMerged == false +ASSERT update.update.amount == 40 +``` + +--- + +## RTLC6 - replaceData with createOp merges initial value + +**Test ID**: `objects/unit/RTLC6/replace-data-with-create-op-0` + +| Spec | Requirement | +|------|-------------| +| RTLC6c | Set data to counter.count | +| RTLC6d | If createOp present, merge via RTLC16 | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, { + counter: { count: 100 }, + createOp: { counterCreate: { count: 50 } } +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT counter.data == 150 +ASSERT counter.createOperationIsMerged == true +ASSERT update.update.amount == 150 +``` + +--- + +## RTLC6e - replaceData on tombstoned counter is noop + +**Test ID**: `objects/unit/RTLC6e/replace-data-tombstoned-noop-0` + +**Spec requirement:** If isTombstone is true, finish processing. Return noop. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.isTombstone = true +counter.tombstonedAt = 1700000000000 +counter.data = 0 +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, { + counter: { count: 999 } +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT counter.data == 0 +ASSERT update.noop == true +``` + +--- + +## RTLC6f - replaceData with tombstone flag tombstones counter + +**Test ID**: `objects/unit/RTLC6f/replace-data-tombstone-flag-0` + +| Spec | Requirement | +|------|-------------| +| RTLC6f | If ObjectState.tombstone is true, tombstone the counter | +| RTLC6f1 | Return LiveCounterUpdate with amount = negated previous data | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 30 +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, { + counter: { count: 0 }, + tombstone: true +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT counter.isTombstone == true +ASSERT counter.data == 0 +ASSERT update.update.amount == -30 +``` + +--- + +## RTLC6 - replaceData with missing counter.count defaults to 0 + +**Test ID**: `objects/unit/RTLC6/replace-data-missing-count-0` + +**Spec requirement:** Set data to counter.count, or to 0 if it does not exist. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 42 +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, { + counter: {} +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT counter.data == 0 +ASSERT update.update.amount == -42 +``` + +--- + +## RTLC14 - Diff calculation + +**Test ID**: `objects/unit/RTLC14/diff-calculation-0` + +**Spec requirement:** Return LiveCounterUpdate with amount = newData - previousData. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 20 +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, { + counter: { count: 75 } +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT update.update.amount == 55 +``` + +--- + +## RTLC8, RTLC16 - COUNTER_CREATE then COUNTER_INC accumulates + +**Test ID**: `objects/unit/RTLC8/create-then-inc-0` + +**Spec requirement:** Create operation merges initial count, then increment adds to it. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +counter.applyOperation( + build_counter_create("counter:abc@1000", { count: 100 }, "01", "site1"), + source: CHANNEL +) +counter.applyOperation( + build_counter_inc("counter:abc@1000", 25, "02", "site1"), + source: CHANNEL +) +``` + +### Assertions +```pseudo +ASSERT counter.data == 125 +ASSERT counter.createOperationIsMerged == true +``` + +--- + +## RTLO3 - LiveObject properties initialized correctly + +**Test ID**: `objects/unit/RTLO3/live-object-init-properties-0` + +| Spec | Requirement | +|------|-------------| +| RTLO3a1 | objectId must be provided in constructor | +| RTLO3b1 | siteTimeserials set to empty map | +| RTLO3c1 | createOperationIsMerged set to false | +| RTLO3d1 | isTombstone set to false | +| RTLO3e1 | tombstonedAt set to null | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:test@2000") +``` + +### Assertions +```pseudo +ASSERT counter.objectId == "counter:test@2000" +ASSERT counter.siteTimeserials == {} +ASSERT counter.createOperationIsMerged == false +ASSERT counter.isTombstone == false +ASSERT counter.tombstonedAt == null +``` diff --git a/uts/objects/unit/live_counter_api.md b/uts/objects/unit/live_counter_api.md new file mode 100644 index 00000000..2b5e733e --- /dev/null +++ b/uts/objects/unit/live_counter_api.md @@ -0,0 +1,343 @@ +# LiveCounter API Tests + +Spec points: `RTLC5`, `RTLC11`–`RTLC13` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTLC5 - value() returns current counter data + +**Test ID**: `objects/unit/RTLC5/value-returns-data-0` + +| Spec | Requirement | +|------|-------------| +| RTLC5c | Returns current data value | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +counter = root.get("score") +ASSERT counter.value() == 100 +``` + +--- + +## RTLC5a - value() requires OBJECT_SUBSCRIBE mode + +**Test ID**: `objects/unit/RTLC5a/value-requires-subscribe-0` + +**Spec requirement:** Requires OBJECT_SUBSCRIBE channel mode per RTO2. + +This is implicitly tested by `setup_synced_channel` which always includes OBJECT_SUBSCRIBE. A negative test would use a channel without OBJECT_SUBSCRIBE and verify the error. + +--- + +## RTLC12 - increment sends v6 COUNTER_INC message + +**Test ID**: `objects/unit/RTLC12/increment-sends-counter-inc-0` + +| Spec | Requirement | +|------|-------------| +| RTLC12e2 | action set to COUNTER_INC | +| RTLC12e3 | objectId set to counter's objectId | +| RTLC12e5 | counterInc.number set to amount | +| RTLC12g | Publishes via publishAndApply | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.increment(25) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "COUNTER_INC" +ASSERT obj_msg.operation.objectId == "counter:score@1000" +ASSERT obj_msg.operation.counterInc.number == 25 +``` + +--- + +## RTLC12 - increment applies locally after ACK + +**Test ID**: `objects/unit/RTLC12/increment-applies-locally-0` + +**Spec requirement:** Via publishAndApply, value reflects change after await. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment(50) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 150 +``` + +--- + +## RTLC12b - increment requires OBJECT_PUBLISH mode + +**Test ID**: `objects/unit/RTLC12b/increment-requires-publish-0` + +**Spec requirement:** Requires OBJECT_PUBLISH channel mode. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS, modes: ["OBJECT_SUBSCRIBE"] + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +``` + +--- + +## RTLC12d - increment with echoMessages false throws + +**Test ID**: `objects/unit/RTLC12d/echo-messages-false-0` + +**Spec requirement:** If echoMessages is false, throw 40000. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key", echoMessages: false }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +``` + +--- + +## RTLC12e1 - increment with non-number throws + +**Test ID**: `objects/unit/RTLC12e1/increment-non-number-0` + +**Spec requirement:** If amount is null, not Number, not finite, or omitted, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment("not_a_number") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLC13 - decrement delegates to increment with negated amount + +**Test ID**: `objects/unit/RTLC13/decrement-negates-0` + +| Spec | Requirement | +|------|-------------| +| RTLC13b | Alias for increment with negative amount | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.decrement(15) +``` + +### Assertions +```pseudo +ASSERT captured_messages[0].state[0].operation.counterInc.number == -15 +ASSERT root.get("score").value() == 85 +``` + +--- + +## RTLC11 - LiveCounterUpdate emitted on increment + +**Test ID**: `objects/unit/RTLC11/counter-update-on-inc-0` + +| Spec | Requirement | +|------|-------------| +| RTLC11b1 | update.amount is the increment value | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +updates = [] +instance = root.get("score").instance() +instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote-site") +])) + +poll_until(updates.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT updates[0].message.operation.counterInc.number == 7 +``` + +--- + +## RTLC12e1 - Table-driven invalid increment amounts + +**Test ID**: `objects/unit/RTLC12e1/increment-invalid-amounts-table-0` + +**Spec requirement:** If amount is null, not Number, not finite, or NaN, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +invalid_amounts = [ + { value: null, label: "null" }, + { value: NaN, label: "NaN" }, + { value: Infinity, label: "Infinity" }, + { value: -Infinity, label: "-Infinity" }, + { value: "10", label: "string" }, + { value: true, label: "boolean" }, + { value: [1, 2], label: "array" }, + { value: { n: 1 }, label: "object" } +] +``` + +### Test Steps +```pseudo +FOR scenario IN invalid_amounts: + AWAIT root.increment(scenario.value) FAILS WITH error + ASSERT error.code == 40003 +``` diff --git a/uts/objects/unit/live_map.md b/uts/objects/unit/live_map.md new file mode 100644 index 00000000..a930c17a --- /dev/null +++ b/uts/objects/unit/live_map.md @@ -0,0 +1,980 @@ +# LiveMap Tests + +Spec points: `RTLM1`–`RTLM9`, `RTLM14`–`RTLM16`, `RTLM18`–`RTLM19`, `RTLM22`–`RTLM25`, `RTLO3`, `RTLO4a`, `RTLO4e`, `RTLO5`, `RTLO6` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +Tests the `LiveMap` LWW-map CRDT data structure. LiveMap holds a dictionary of `ObjectsMapEntry` values with entry-level last-write-wins semantics, supports set/remove/clear operations, create operations (initial entries merge), data replacement during sync, tombstoning, GC of tombstoned entries, and diff calculation. + +Tests operate directly on LiveMap by calling `applyOperation()` and `replaceData()` with constructed messages. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for builder functions. + +--- + +## RTLM4 - Zero-value LiveMap + +**Test ID**: `objects/unit/RTLM4/zero-value-0` + +| Spec | Requirement | +|------|-------------| +| RTLM4 | Zero-value LiveMap has empty data map and null clearTimeserial | +| RTLM25 | clearTimeserial initially null | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Assertions +```pseudo +ASSERT map.data == {} +ASSERT map.clearTimeserial == null +ASSERT map.isTombstone == false +ASSERT map.createOperationIsMerged == false +ASSERT map.siteTimeserials == {} +``` + +--- + +## RTLM7 - MAP_SET creates new entry + +**Test ID**: `objects/unit/RTLM7/map-set-new-entry-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7b4 | Create new ObjectsMapEntry with data and timeserial | +| RTLM7f | Return LiveMapUpdate with key set to "updated" | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Alice" }, "01", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT map.data["name"].timeserial == "01" +ASSERT map.data["name"].tombstone == false +ASSERT update.update == { "name": "updated" } +``` + +--- + +## RTLM7 - MAP_SET updates existing entry + +**Test ID**: `objects/unit/RTLM7/map-set-update-entry-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7a2e | Set data to MapSet.value | +| RTLM7a2b | Set timeserial to the provided serial | +| RTLM7a2c | Set tombstone to false | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Bob" }, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Bob" } +ASSERT map.data["name"].timeserial == "02" +ASSERT update.update == { "name": "updated" } +``` + +--- + +## RTLM9 - LWW rejects stale serial on existing entry + +**Test ID**: `objects/unit/RTLM9/lww-reject-stale-0` + +| Spec | Requirement | +|------|-------------| +| RTLM9a | Operation serial must be strictly greater than entry serial | +| RTLM9e | Compare lexicographically | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "05", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Bob" }, "03", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT update.noop == true +``` + +--- + +## RTLM9 - LWW rejects equal serial + +**Test ID**: `objects/unit/RTLM9/lww-reject-equal-0` + +**Spec requirement:** Equal serials are rejected — must be strictly greater. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "05", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Bob" }, "05", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT update.noop == true +``` + +--- + +## RTLM9b - Both serials empty rejects operation + +**Test ID**: `objects/unit/RTLM9b/both-empty-reject-0` + +**Spec requirement:** If both the entry serial and operation serial are null/empty, considered equal, so operation is not applied. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Bob" }, "", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT update.noop == true +``` + +--- + +## RTLM9d - Missing entry serial allows operation + +**Test ID**: `objects/unit/RTLM9d/missing-entry-serial-allows-0` + +**Spec requirement:** If only the operation serial exists and is non-empty, it is greater than the missing entry serial. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: null, tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Bob" }, "01", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Bob" } +ASSERT update.update == { "name": "updated" } +``` + +--- + +## RTLM7h - MAP_SET rejected when serial <= clearTimeserial + +**Test ID**: `objects/unit/RTLM7h/map-set-clear-timeserial-floor-0` + +**Spec requirement:** If clearTimeserial is non-null and >= serial, discard operation. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.clearTimeserial = "05" +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Alice" }, "03", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT "name" NOT IN map.data +ASSERT update.noop == true +``` + +--- + +## RTLM7g - MAP_SET with objectId creates zero-value object + +**Test ID**: `objects/unit/RTLM7g/map-set-objectid-creates-zero-value-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7g | If MapSet.value.objectId is non-empty, create zero-value LiveObject | +| RTLM7g1 | Create via RTO6 | + +This test requires an ObjectsPool to be passed alongside the LiveMap. The LiveMap creates a zero-value object in the pool when it encounters an objectId reference. + +### Setup +```pseudo +pool = ObjectsPool() +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "score", { objectId: "counter:new@2000" }, "01", "site1") +map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT "counter:new@2000" IN pool +ASSERT pool["counter:new@2000"] IS LiveCounter +ASSERT pool["counter:new@2000"].data == 0 +``` + +--- + +## RTLM8 - MAP_REMOVE tombstones existing entry + +**Test ID**: `objects/unit/RTLM8/map-remove-existing-0` + +| Spec | Requirement | +|------|-------------| +| RTLM8a2a | Set data to null | +| RTLM8a2b | Set timeserial to serial | +| RTLM8a2c | Set tombstone to true | +| RTLM8a2d | Set tombstonedAt via RTLO6 | +| RTLM8e | Return LiveMapUpdate with key set to "removed" | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_remove("root", "name", "02", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == null +ASSERT map.data["name"].tombstone == true +ASSERT map.data["name"].timeserial == "02" +ASSERT map.data["name"].tombstonedAt == 1700000000000 +ASSERT update.update == { "name": "removed" } +``` + +--- + +## RTLM8 - MAP_REMOVE creates tombstoned entry if not exists + +**Test ID**: `objects/unit/RTLM8/map-remove-nonexistent-0` + +| Spec | Requirement | +|------|-------------| +| RTLM8b1 | Create new entry with data null and timeserial | +| RTLM8b2 | Set tombstone to true | +| RTLM8b3 | Set tombstonedAt via RTLO6 | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Test Steps +```pseudo +msg = build_map_remove("root", "ghost", "01", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["ghost"].tombstone == true +ASSERT map.data["ghost"].tombstonedAt == 1700000000000 +ASSERT update.update == { "ghost": "removed" } +``` + +--- + +## RTLM8g - MAP_REMOVE rejected when serial <= clearTimeserial + +**Test ID**: `objects/unit/RTLM8g/map-remove-clear-timeserial-floor-0` + +**Spec requirement:** If clearTimeserial is non-null and >= serial, discard operation. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.clearTimeserial = "05" +map.data = { + "name": { data: { string: "Alice" }, timeserial: "04", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_remove("root", "name", "03", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT map.data["name"].tombstone == false +ASSERT update.noop == true +``` + +--- + +## RTLM24 - MAP_CLEAR sets clearTimeserial and removes older entries + +**Test ID**: `objects/unit/RTLM24/map-clear-basic-0` + +| Spec | Requirement | +|------|-------------| +| RTLM24d | Set clearTimeserial to serial | +| RTLM24e1a | Remove entries with timeserial null or < serial | +| RTLM24f | Return LiveMapUpdate with removed keys | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "old": { data: { string: "old" }, timeserial: "02", tombstone: false }, + "new": { data: { string: "new" }, timeserial: "06", tombstone: false }, + "same": { data: { string: "same" }, timeserial: "04", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_clear("root", "04", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.clearTimeserial == "04" +ASSERT "old" NOT IN map.data +ASSERT "same" NOT IN map.data +ASSERT "new" IN map.data +ASSERT update.update == { "old": "removed", "same": "removed" } +``` + +--- + +## RTLM24c - MAP_CLEAR rejected when clearTimeserial is already greater + +**Test ID**: `objects/unit/RTLM24c/map-clear-stale-0` + +**Spec requirement:** If existing clearTimeserial is greater than provided serial, discard. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.clearTimeserial = "10" +``` + +### Test Steps +```pseudo +msg = build_map_clear("root", "05", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.clearTimeserial == "10" +ASSERT update.noop == true +``` + +--- + +## RTLM16, RTLM23 - MAP_CREATE merges entries + +**Test ID**: `objects/unit/RTLM16/map-create-merge-0` + +| Spec | Requirement | +|------|-------------| +| RTLM16d | Merge via RTLM23 | +| RTLM23a1 | Non-tombstoned entries merged via MAP_SET logic | +| RTLM23a2 | Tombstoned entries merged via MAP_REMOVE logic | +| RTLM23b | Set createOperationIsMerged to true | + +### Setup +```pseudo +map = LiveMap(objectId: "map:test@1000", semantics: "LWW") +``` + +### Test Steps +```pseudo +msg = build_map_create("map:test@1000", { + semantics: "LWW", + entries: { + "name": { data: { string: "Alice" }, timeserial: "01" }, + "removed_key": { tombstone: true, timeserial: "01", serialTimestamp: 1700000000000 } + } +}, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT map.data["removed_key"].tombstone == true +ASSERT map.createOperationIsMerged == true +ASSERT update.update == { "name": "updated", "removed_key": "removed" } +``` + +--- + +## RTLM16b - MAP_CREATE noop when already merged + +**Test ID**: `objects/unit/RTLM16b/map-create-already-merged-0` + +**Spec requirement:** If createOperationIsMerged is true, return noop. + +### Setup +```pseudo +map = LiveMap(objectId: "map:test@1000", semantics: "LWW") +map.createOperationIsMerged = true +map.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_map_create("map:test@1000", { + semantics: "LWW", + entries: { "name": { data: { string: "Bob" }, timeserial: "01" } } +}, "01", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT "name" NOT IN map.data +ASSERT update.noop == true +``` + +--- + +## RTLM15c - CHANNEL source updates siteTimeserials + +**Test ID**: `objects/unit/RTLM15c/channel-source-updates-serials-0` + +**Spec requirement:** If source is CHANNEL, set siteTimeserials[siteCode] = serial. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "x", { number: 1 }, "01", "site1") +map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.siteTimeserials["site1"] == "01" +``` + +--- + +## RTLM15e - Operations on tombstoned map are rejected + +**Test ID**: `objects/unit/RTLM15e/tombstoned-reject-ops-0` + +**Spec requirement:** If isTombstone is true, finish without action, return false. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.isTombstone = true +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "x", { number: 1 }, "01", "site1") +result = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +ASSERT map.data == {} +``` + +--- + +## RTLO5 - OBJECT_DELETE tombstones map + +**Test ID**: `objects/unit/RTLO5/object-delete-tombstones-map-0` + +| Spec | Requirement | +|------|-------------| +| RTLM15d5a | Emit LiveMapUpdate with removed keys | +| RTLM15d5b | Return true | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false }, + "age": { data: { number: 30 }, timeserial: "01", tombstone: false } +} +map.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_object_delete("root", "01", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.isTombstone == true +ASSERT map.data == {} +ASSERT update.update == { "name": "removed", "age": "removed" } +``` + +--- + +## RTLM14, RTLM14c - Tombstoned entry check includes objectId reference + +**Test ID**: `objects/unit/RTLM14/tombstone-check-objectid-ref-0` + +| Spec | Requirement | +|------|-------------| +| RTLM14a | Entry is tombstoned if entry.tombstone is true | +| RTLM14c | Entry is tombstoned if referenced LiveObject.isTombstone is true | + +### Setup +```pseudo +pool = ObjectsPool() +tombstoned_counter = LiveCounter(objectId: "counter:dead@1000") +tombstoned_counter.isTombstone = true +pool["counter:dead@1000"] = tombstoned_counter + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "alive": { data: { string: "ok" }, timeserial: "01", tombstone: false }, + "dead_entry": { data: null, timeserial: "01", tombstone: true }, + "dead_ref": { data: { objectId: "counter:dead@1000" }, timeserial: "01", tombstone: false } +} +``` + +### Assertions +```pseudo +ASSERT isTombstoned(map.data["alive"]) == false +ASSERT isTombstoned(map.data["dead_entry"]) == true +ASSERT isTombstoned(map.data["dead_ref"]) == true +``` + +--- + +## RTLM6 - replaceData sets data from ObjectState + +**Test ID**: `objects/unit/RTLM6/replace-data-basic-0` + +| Spec | Requirement | +|------|-------------| +| RTLM6a | Replace siteTimeserials | +| RTLM6b | Set createOperationIsMerged to false | +| RTLM6i | Set clearTimeserial from ObjectState.map.clearTimeserial | +| RTLM6c | Set data to ObjectState.map.entries | +| RTLM6h | Return diff LiveMapUpdate | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "old": { data: { string: "old" }, timeserial: "01", tombstone: false } +} +map.createOperationIsMerged = true +``` + +### Test Steps +```pseudo +state_msg = build_object_state("root", {"site2": "05"}, { + map: { + semantics: "LWW", + clearTimeserial: "03", + entries: { + "new": { data: { string: "new" }, timeserial: "04", tombstone: false } + } + } +}) +update = map.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT map.siteTimeserials == { "site2": "05" } +ASSERT map.createOperationIsMerged == false +ASSERT map.clearTimeserial == "03" +ASSERT "old" NOT IN map.data +ASSERT map.data["new"].data == { string: "new" } +ASSERT update.update == { "old": "removed", "new": "updated" } +``` + +--- + +## RTLM6c1 - replaceData sets tombstonedAt on tombstoned entries + +**Test ID**: `objects/unit/RTLM6c1/replace-data-tombstoned-entries-0` + +**Spec requirement:** For each tombstoned entry, set tombstonedAt via RTLO6. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Test Steps +```pseudo +state_msg = build_object_state("root", {"site1": "01"}, { + map: { + semantics: "LWW", + entries: { + "dead": { tombstone: true, timeserial: "01", serialTimestamp: 1700000050000 } + } + } +}) +map.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT map.data["dead"].tombstonedAt == 1700000050000 +``` + +--- + +## RTLM6d - replaceData with createOp merges initial entries + +**Test ID**: `objects/unit/RTLM6d/replace-data-with-create-op-0` + +**Spec requirement:** If createOp present, merge via RTLM23. + +### Setup +```pseudo +map = LiveMap(objectId: "map:test@1000", semantics: "LWW") +``` + +### Test Steps +```pseudo +state_msg = build_object_state("map:test@1000", {"site1": "01"}, { + map: { + semantics: "LWW", + entries: { + "from_sync": { data: { string: "synced" }, timeserial: "01" } + } + }, + createOp: { + mapCreate: { + semantics: "LWW", + entries: { + "from_create": { data: { string: "created" }, timeserial: "00" } + } + } + } +}) +map.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT map.data["from_sync"].data == { string: "synced" } +ASSERT map.data["from_create"].data == { string: "created" } +ASSERT map.createOperationIsMerged == true +``` + +--- + +## RTLM19 - GC removes tombstoned entries past grace period + +**Test ID**: `objects/unit/RTLM19/gc-tombstoned-entries-0` + +**Spec requirement:** Entries where tombstonedAt + gracePeriod <= currentTime are removed. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +grace_period = 86400000 +now = 1700100000000 + +map.data = { + "recent_dead": { data: null, timeserial: "01", tombstone: true, tombstonedAt: now - 1000 }, + "old_dead": { data: null, timeserial: "01", tombstone: true, tombstonedAt: now - grace_period - 1 }, + "alive": { data: { string: "ok" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +map.gcTombstonedEntries(grace_period, now) +``` + +### Assertions +```pseudo +ASSERT "recent_dead" IN map.data +ASSERT "old_dead" NOT IN map.data +ASSERT "alive" IN map.data +``` + +--- + +## RTLM22 - Diff between two data states + +**Test ID**: `objects/unit/RTLM22/diff-calculation-0` + +| Spec | Requirement | +|------|-------------| +| RTLM22b1 | Key in previous but not new -> removed | +| RTLM22b2 | Key in new but not previous -> updated | +| RTLM22b3 | Key in both with different data -> updated | +| RTLM22b | Only non-tombstoned entries are considered | + +### Setup +```pseudo +previousData = { + "removed": { data: { string: "gone" }, timeserial: "01", tombstone: false }, + "changed": { data: { string: "old" }, timeserial: "01", tombstone: false }, + "unchanged": { data: { string: "same" }, timeserial: "01", tombstone: false }, + "was_dead": { data: null, timeserial: "01", tombstone: true } +} + +newData = { + "added": { data: { string: "new" }, timeserial: "02", tombstone: false }, + "changed": { data: { string: "new_val" }, timeserial: "02", tombstone: false }, + "unchanged": { data: { string: "same" }, timeserial: "01", tombstone: false }, + "now_dead": { data: null, timeserial: "02", tombstone: true } +} +``` + +### Test Steps +```pseudo +update = LiveMap.diff(previousData, newData) +``` + +### Assertions +```pseudo +ASSERT update.update["removed"] == "removed" +ASSERT update.update["added"] == "updated" +ASSERT update.update["changed"] == "updated" +ASSERT "unchanged" NOT IN update.update +ASSERT "was_dead" NOT IN update.update +ASSERT "now_dead" NOT IN update.update +``` + +--- + +## RTLM15d4 - Unsupported action is discarded + +**Test ID**: `objects/unit/RTLM15d4/unsupported-action-0` + +**Spec requirement:** Log warning, discard, return false. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Test Steps +```pseudo +msg = ObjectMessage( + serial: "01", siteCode: "site1", + operation: { action: "COUNTER_INC", objectId: "root", counterInc: { number: 5 } } +) +result = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +``` + +--- + +## RTLM6i - replaceData without clearTimeserial resets to null + +**Test ID**: `objects/unit/RTLM6i/replace-data-resets-clear-timeserial-0` + +**Spec requirement:** If ObjectState.map.clearTimeserial is absent, clearTimeserial is reset to null. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.clearTimeserial = "05" +map.data = { + "x": { data: { number: 1 }, timeserial: "03", tombstone: false } +} +``` + +### Test Steps +```pseudo +state_msg = build_object_state("root", {"site1": "01"}, { + map: { + semantics: "LWW", + entries: { + "y": { data: { number: 2 }, timeserial: "01" } + } + } +}) +map.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT map.clearTimeserial == null +ASSERT "y" IN map.data +``` + +--- + +## RTLM14c, RTLM5 - MAP_SET referencing tombstoned objectId yields null value + +**Test ID**: `objects/unit/RTLM14c/tombstoned-ref-yields-null-0` + +**Spec requirement:** If entry references an objectId whose LiveObject is tombstoned, the entry is treated as tombstoned (RTLM14c). Value resolution returns null. + +### Setup +```pseudo +pool = ObjectsPool() +tombstoned_counter = LiveCounter(objectId: "counter:dead@1000") +tombstoned_counter.isTombstone = true +pool["counter:dead@1000"] = tombstoned_counter + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "ref": { data: { objectId: "counter:dead@1000" }, timeserial: "01", tombstone: false } +} +``` + +### Assertions +```pseudo +// The entry itself is not tombstoned, but the referenced object is +ASSERT map.data["ref"].tombstone == false +// size() should NOT count this entry because RTLM14c makes it tombstoned +ASSERT map.size() == 0 +// get() should return null for the value +ASSERT map.get("ref") == null +``` + +--- + +## RTLM7 - MAP_SET revives tombstoned entry + +**Test ID**: `objects/unit/RTLM7/map-set-revives-tombstoned-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7a2c | Set tombstone to false | +| RTLM7a2d | Set tombstonedAt to null | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: null, timeserial: "01", tombstone: true, tombstonedAt: 1700000000000 } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Alice" }, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT map.data["name"].tombstone == false +ASSERT map.data["name"].tombstonedAt == null +ASSERT update.update == { "name": "updated" } +``` + +--- + +## RTLM24 - MAP_CLEAR preserves entries with newer serial + +**Test ID**: `objects/unit/RTLM24/map-clear-preserves-newer-0` + +**Spec requirement:** Only entries with timeserial null or <= serial are removed. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "before": { data: { string: "a" }, timeserial: "03", tombstone: false }, + "after": { data: { string: "b" }, timeserial: "07", tombstone: false }, + "no_ts": { data: { string: "c" }, timeserial: null, tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_clear("root", "05", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT "before" NOT IN map.data +ASSERT "no_ts" NOT IN map.data +ASSERT map.data["after"].data == { string: "b" } +ASSERT "before" IN update.update +ASSERT "no_ts" IN update.update +ASSERT "after" NOT IN update.update +``` diff --git a/uts/objects/unit/live_map_api.md b/uts/objects/unit/live_map_api.md new file mode 100644 index 00000000..7a728224 --- /dev/null +++ b/uts/objects/unit/live_map_api.md @@ -0,0 +1,483 @@ +# LiveMap API Tests + +Spec points: `RTLM5`, `RTLM10`–`RTLM13`, `RTLM20`–`RTLM21`, `RTLM24` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTLM5 - get() returns resolved value from LiveMap + +**Test ID**: `objects/unit/RTLM5/get-string-value-0` + +**Spec requirement:** Returns value at key, resolved per RTLM5d2. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Alice" +ASSERT root.get("age").value() == 30 +ASSERT root.get("active").value() == true +``` + +--- + +## RTLM5 - get() returns null for non-existent key + +**Test ID**: `objects/unit/RTLM5/get-nonexistent-key-0` + +**Spec requirement:** If no entry exists at key, return null. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("nonexistent").value() == null +``` + +--- + +## RTLM5 - get() resolves objectId to LiveObject + +**Test ID**: `objects/unit/RTLM5/get-objectid-reference-0` + +**Spec requirement:** If data.objectId exists, resolve from pool. Return LiveCounter/LiveMap. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 100 +ASSERT root.get("profile").get("email").value() == "alice@example.com" +``` + +--- + +## RTLM10 - size() returns non-tombstoned entry count + +**Test ID**: `objects/unit/RTLM10/size-non-tombstoned-0` + +**Spec requirement:** Returns number of non-tombstoned entries. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.size() == 7 +``` + +--- + +## RTLM11 - entries() yields key-value pairs + +**Test ID**: `objects/unit/RTLM11/entries-yields-pairs-0` + +**Spec requirement:** Returns non-tombstoned key-value pairs. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +entries = [] +FOR [key, pathObj] IN root.entries(): + entries.append(key) +``` + +### Assertions +```pseudo +ASSERT "name" IN entries +ASSERT "age" IN entries +ASSERT "active" IN entries +ASSERT "score" IN entries +ASSERT "profile" IN entries +ASSERT "data" IN entries +ASSERT "avatar" IN entries +ASSERT entries.length == 7 +``` + +--- + +## RTLM12 - keys() yields only keys + +**Test ID**: `objects/unit/RTLM12/keys-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +keys = list(root.keys()) +``` + +### Assertions +```pseudo +ASSERT keys.length == 7 +ASSERT "name" IN keys +``` + +--- + +## RTLM20 - set() sends MAP_SET message with v6 format + +**Test ID**: `objects/unit/RTLM20/set-sends-map-set-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20e2 | action set to MAP_SET | +| RTLM20e3 | objectId set to LiveMap's objectId | +| RTLM20e6 | mapSet.key set | +| RTLM20e7c | mapSet.value.string for string value | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_SET" +ASSERT obj_msg.operation.objectId == "root" +ASSERT obj_msg.operation.mapSet.key == "name" +ASSERT obj_msg.operation.mapSet.value.string == "Bob" +``` + +--- + +## RTLM20 - set() with different value types + +**Test ID**: `objects/unit/RTLM20/set-value-types-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20e7b | JsonArray/JsonObject -> mapSet.value.json | +| RTLM20e7d | Number -> mapSet.value.number | +| RTLM20e7e | Boolean -> mapSet.value.boolean | + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as above, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.set("num_key", 42) +AWAIT root.set("bool_key", false) +AWAIT root.set("json_key", {"nested": true}) +``` + +### Assertions +```pseudo +ASSERT captured_messages[0].state[0].operation.mapSet.value.number == 42 +ASSERT captured_messages[1].state[0].operation.mapSet.value.boolean == false +ASSERT captured_messages[2].state[0].operation.mapSet.value.json == {"nested": true} +``` + +--- + +## RTLM20e7g - set() with LiveCounterValueType consumes and sends create + set + +**Test ID**: `objects/unit/RTLM20e7g/set-counter-value-type-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20e7g1 | Consume value type to generate COUNTER_CREATE | +| RTLM20e7g2 | Set mapSet.value.objectId to the created objectId | +| RTLM20h1 | Array: CREATE messages then MAP_SET | + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as above) +``` + +### Test Steps +```pseudo +AWAIT root.set("new_counter", LiveCounter.create(50)) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +state = captured_messages[0].state +ASSERT state.length == 2 +ASSERT state[0].operation.action == "COUNTER_CREATE" +ASSERT state[0].operation.objectId STARTS WITH "counter:" +ASSERT state[1].operation.action == "MAP_SET" +ASSERT state[1].operation.mapSet.value.objectId == state[0].operation.objectId +``` + +--- + +## RTLM21 - remove() sends MAP_REMOVE message + +**Test ID**: `objects/unit/RTLM21/remove-sends-map-remove-0` + +| Spec | Requirement | +|------|-------------| +| RTLM21e2 | action set to MAP_REMOVE | +| RTLM21e5 | mapRemove.key set | + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as above) +``` + +### Test Steps +```pseudo +AWAIT root.remove("name") +``` + +### Assertions +```pseudo +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_REMOVE" +ASSERT obj_msg.operation.objectId == "root" +ASSERT obj_msg.operation.mapRemove.key == "name" +``` + +--- + +## RTLM20d - set() with echoMessages false throws + +**Test ID**: `objects/unit/RTLM20d/echo-messages-false-0` + +**Spec requirement:** If echoMessages is false, throw 40000. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key", echoMessages: false }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +``` + +--- + +## RTLM21d - remove() with echoMessages false throws + +**Test ID**: `objects/unit/RTLM21d/echo-messages-false-0` + +**Spec requirement:** Same as RTLM20d for remove. + +### Setup +```pseudo +// Same echoMessages: false setup as above +``` + +### Test Steps +```pseudo +AWAIT root.remove("name") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +``` + +--- + +## RTLM20 - set() applies locally after ACK + +**Test ID**: `objects/unit/RTLM20/set-applies-locally-0` + +**Spec requirement:** Via publishAndApply, local state reflects change after await. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Bob" +``` + +--- + +## RTLM24 - clear() sends MAP_CLEAR message + +**Test ID**: `objects/unit/RTLM24/clear-sends-map-clear-0` + +**Spec requirement:** Constructs MAP_CLEAR ObjectMessage. + +### Setup +```pseudo +captured_messages = [] +// (same mock setup capturing OBJECT messages) +``` + +### Test Steps +```pseudo +instance = root.instance() +AWAIT instance.clear() +``` + +### Assertions +```pseudo +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_CLEAR" +ASSERT obj_msg.operation.objectId == "root" +``` + +--- + +## RTLM20 - Table-driven invalid set value types + +**Test ID**: `objects/unit/RTLM20/set-invalid-values-table-0` + +**Spec requirement:** set() rejects values of unsupported types with error 40013. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +invalid_values = [ + { value: some_function, label: "function" }, + { value: undefined, label: "undefined" }, + { value: some_symbol, label: "symbol" } +] +``` + +### Test Steps +```pseudo +FOR scenario IN invalid_values: + AWAIT root.set("key", scenario.value) FAILS WITH error + ASSERT error.code == 40013 +``` + +--- + +## RTLM20 - set() with bytes value type + +**Test ID**: `objects/unit/RTLM20/set-bytes-value-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20e7f | Binary -> mapSet.value.bytes (base64 encoded) | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("binary_data", bytes([1, 2, 3])) +``` + +### Assertions +```pseudo +ASSERT captured_messages[0].state[0].operation.mapSet.value.bytes == "AQID" +``` diff --git a/uts/objects/unit/live_object_subscribe.md b/uts/objects/unit/live_object_subscribe.md new file mode 100644 index 00000000..5f8398e8 --- /dev/null +++ b/uts/objects/unit/live_object_subscribe.md @@ -0,0 +1,244 @@ +# LiveObject Subscribe Tests + +Spec points: `RTLO4b`, `RTLO4c` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTLO4b - subscribe registers listener for data updates + +**Test ID**: `objects/unit/RTLO4b/subscribe-receives-updates-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4b3 | User provides listener for data updates | +| RTLO4b4c2 | Listener called with LiveObjectUpdate | +| RTLO4b7 | Returns Subscription object | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.get("score").instance() +sub = instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT sub IS Subscription +ASSERT updates.length == 1 +``` + +--- + +## RTLO4b4c1 - noop update does not trigger listener + +**Test ID**: `objects/unit/RTLO4b4c1/noop-no-trigger-0` + +**Spec requirement:** If LiveObjectUpdate is a noop, do nothing. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.get("score").instance() +instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "01", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + ObjectMessage( + serial: "01", siteCode: "remote", + operation: { action: "COUNTER_INC", objectId: "counter:score@1000", counterInc: {} } + ) +])) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 +``` + +--- + +## RTLO4c - unsubscribe deregisters listener + +**Test ID**: `objects/unit/RTLO4c/unsubscribe-deregisters-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4c3 | Once deregistered, subsequent updates do not call listener | +| RTLO4c4 | No side effects on channel or RealtimeObject | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.get("score").instance() +sub = instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "01", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) + +sub.unsubscribe() + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "02", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 +``` + +--- + +## RTLO4b1 - subscribe requires OBJECT_SUBSCRIBE mode + +**Test ID**: `objects/unit/RTLO4b1/subscribe-requires-mode-0` + +**Spec requirement:** Requires OBJECT_SUBSCRIBE channel mode per RTO2. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS, modes: ["OBJECT_PUBLISH"] + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +instance = root.get("score").instance() +``` + +### Test Steps +```pseudo +instance.subscribe((event) => {}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +``` + +--- + +## RTLO4b6 - subscribe has no side effects + +**Test ID**: `objects/unit/RTLO4b6/subscribe-no-side-effects-0` + +**Spec requirement:** Must not have side effects on RealtimeObject, channel, or their status. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +state_before = channel.state +instance = root.get("score").instance() +``` + +### Test Steps +```pseudo +instance.subscribe((event) => {}) +``` + +### Assertions +```pseudo +ASSERT channel.state == state_before +``` + +--- + +## RTLO4b - subscribe on LiveMap receives LiveMapUpdate + +**Test ID**: `objects/unit/RTLO4b/subscribe-map-update-0` + +**Spec requirement:** LiveMapUpdate.update contains key -> "updated"/"removed". + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.instance() +instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 +``` + +--- + +## RTLO4c1 - unsubscribe requires no channel mode + +**Test ID**: `objects/unit/RTLO4c1/unsubscribe-no-mode-required-0` + +**Spec requirement:** Does not require any specific channel modes. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +instance = root.get("score").instance() +sub = instance.subscribe((event) => {}) +``` + +### Test Steps +```pseudo +sub.unsubscribe() +``` + +### Assertions +```pseudo +// No error thrown +``` diff --git a/uts/objects/unit/object_id.md b/uts/objects/unit/object_id.md new file mode 100644 index 00000000..8f51f7bc --- /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 00000000..214fe7db --- /dev/null +++ b/uts/objects/unit/objects_pool.md @@ -0,0 +1,910 @@ +# ObjectsPool Tests + +Spec points: `RTO3`–`RTO9` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +Tests the `ObjectsPool` internal data structure and sync state machine. ObjectsPool is a `Dict` that manages all objects on a channel. It processes ATTACHED messages (to determine sync mode), OBJECT_SYNC messages (to build state from server), and OBJECT messages (to apply operations). It maintains a SyncObjectsPool for accumulating sync data, buffers operations during SYNCING, and manages the INITIALIZED -> SYNCING -> SYNCED state transitions. + +Tests operate directly on ObjectsPool by calling `processAttached()`, `processObjectSync()`, and `processObjectMessage()`. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for builder functions and STANDARD_POOL_OBJECTS. + +--- + +## RTO3 - ObjectsPool initialization with root LiveMap + +**Test ID**: `objects/unit/RTO3/pool-init-root-0` + +| Spec | Requirement | +|------|-------------| +| RTO3a | ObjectsPool is Dict | +| RTO3b | Must always contain a LiveMap with id "root" | +| RTO3b1 | On initialization, create zero-value LiveMap with objectId "root" | + +### Setup +```pseudo +pool = ObjectsPool() +``` + +### Assertions +```pseudo +ASSERT "root" IN pool +ASSERT pool["root"] IS LiveMap +ASSERT pool["root"].data == {} +ASSERT pool["root"].objectId == "root" +``` + +--- + +## RTO4a - ATTACHED with HAS_OBJECTS flag starts SYNCING + +**Test ID**: `objects/unit/RTO4/attached-has-objects-syncing-0` + +| Spec | Requirement | +|------|-------------| +| RTO4c | Sync state transitions to SYNCING | +| RTO4d | bufferedObjectOperations cleared | +| RTO4a | HAS_OBJECTS=1 means server will send OBJECT_SYNC | + +### Setup +```pseudo +pool = ObjectsPool() +``` + +### Test Steps +```pseudo +pool.processAttached(ProtocolMessage( + action: ATTACHED, + channel: "test", + channelSerial: "sync1:cursor", + flags: HAS_OBJECTS +)) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCING +``` + +--- + +## RTO4b - ATTACHED without HAS_OBJECTS clears pool and goes to SYNCED + +**Test ID**: `objects/unit/RTO4b/attached-no-objects-synced-0` + +| Spec | Requirement | +|------|-------------| +| RTO4b1 | Remove all objects except root | +| RTO4b2 | Clear root LiveMap data to zero-value | +| RTO4b2a | Emit LiveMapUpdate for root with removed entries | +| RTO4b4 | Perform sync completion actions | + +### Setup +```pseudo +pool = ObjectsPool() +pool["counter:abc@1000"] = LiveCounter(objectId: "counter:abc@1000") +pool["root"].data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +updates = [] +pool["root"].subscribe((update) => updates.append(update)) + +pool.processAttached(ProtocolMessage( + action: ATTACHED, + channel: "test", + flags: 0 +)) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "counter:abc@1000" NOT IN pool +ASSERT "root" IN pool +ASSERT pool["root"].data == {} +ASSERT updates.length >= 1 +ASSERT updates[0].update == { "name": "removed" } +``` + +--- + +## RTO5 - OBJECT_SYNC complete sequence + +**Test ID**: `objects/unit/RTO5/sync-complete-sequence-0` + +| Spec | Requirement | +|------|-------------| +| RTO5a1 | channelSerial is "sequenceId:cursor" | +| RTO5a4 | Sync complete when cursor is empty | +| RTO5f1 | Store new entries in SyncObjectsPool | +| RTO5c8 | Transition to SYNCED | + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { "name": { data: { string: "Alice" }, timeserial: "t:0" } } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { + counter: { count: 42 }, + createOp: { counterCreate: { count: 42 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "root" IN pool +ASSERT "counter:abc@1000" IN pool +ASSERT pool["root"].data["name"].data == { string: "Alice" } +ASSERT pool["counter:abc@1000"].data == 42 +``` + +--- + +## RTO5a2 - New sync sequence discards previous + +**Test ID**: `objects/unit/RTO5a2/new-sequence-discards-old-0` + +| Spec | Requirement | +|------|-------------| +| RTO5a2a | SyncObjectsPool must be cleared | +| RTO5a2 | New sequence id starts fresh sync | + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "seq1:cursor", flags: HAS_OBJECTS +)) +pool.processObjectSync(build_object_sync_message("test", "seq1:more", [ + build_object_state("counter:old@1000", {"aaa": "t:0"}, { counter: { count: 10 } }) +])) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "seq2:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:new@1000", {"aaa": "t:0"}, { counter: { count: 99 } }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "counter:old@1000" NOT IN pool +ASSERT "counter:new@1000" IN pool +``` + +--- + +## RTO5f2a - Partial object state merge for maps + +**Test ID**: `objects/unit/RTO5f2a/partial-map-merge-0` + +| Spec | Requirement | +|------|-------------| +| RTO5f2 | Existing entry: partial state, merge into existing | +| RTO5f2a2 | Merge map entries from incoming into existing | + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:more", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { "name": { data: { string: "Alice" }, timeserial: "t:0" } } + } + }) +])) + +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { "age": { data: { number: 30 }, timeserial: "t:0" } } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool["root"].data["name"].data == { string: "Alice" } +ASSERT pool["root"].data["age"].data == { number: 30 } +``` + +--- + +## RTO5c2 - Sync completion removes objects not in sync + +**Test ID**: `objects/unit/RTO5c2/remove-absent-objects-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c2 | Remove objects not received during sync | +| RTO5c2a | root must not be removed | + +### Setup +```pseudo +pool = ObjectsPool() +pool["counter:old@1000"] = LiveCounter(objectId: "counter:old@1000") +pool["counter:old@1000"].data = 99 +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT "counter:old@1000" NOT IN pool +ASSERT "root" IN pool +``` + +--- + +## RTO5c9 - Sync completion clears appliedOnAckSerials + +**Test ID**: `objects/unit/RTO5c9/clear-applied-on-ack-serials-0` + +**Spec requirement:** appliedOnAckSerials set must be cleared after sync. + +### Setup +```pseudo +pool = ObjectsPool() +pool.appliedOnAckSerials = {"serial-1", "serial-2"} +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.appliedOnAckSerials == {} +``` + +--- + +## RTO7, RTO8a - OBJECT messages buffered during SYNCING + +**Test ID**: `objects/unit/RTO8a/buffer-during-syncing-0` + +| Spec | Requirement | +|------|-------------| +| RTO8a | If sync state is not SYNCED, buffer ObjectMessages | +| RTO7a | bufferedObjectOperations is an array | + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:abc@1000", 5, "01", "site1") +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCING +ASSERT pool.bufferedObjectOperations.length == 1 +ASSERT "counter:abc@1000" NOT IN pool +``` + +--- + +## RTO5c6, RTO8b - Buffered operations applied on sync completion + +**Test ID**: `objects/unit/RTO5c6/apply-buffered-on-sync-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c6 | Apply buffered operations with source CHANNEL | +| RTO8b | When SYNCED, apply directly | + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) + +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:abc@1000", 10, "02", "site1") +])) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { + counter: { count: 100 }, + createOp: { counterCreate: { count: 100 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool["counter:abc@1000"].data == 110 +ASSERT pool.bufferedObjectOperations.length == 0 +``` + +--- + +## RTO9a1 - Null operation is discarded with warning + +**Test ID**: `objects/unit/RTO9a1/null-operation-warning-0` + +**Spec requirement:** If ObjectMessage.operation is null or omitted, log warning and discard. + +### Setup +```pseudo +pool = ObjectsPool() +pool.syncState = SYNCED +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + ObjectMessage(serial: "01", siteCode: "site1", operation: null) +])) +``` + +### Assertions +```pseudo +ASSERT pool.keys().length == 1 +``` + +--- + +## RTO9a3 - appliedOnAckSerials deduplication + +**Test ID**: `objects/unit/RTO9a3/dedup-applied-on-ack-0` + +**Spec requirement:** If appliedOnAckSerials contains the serial, log debug, remove from set, and discard. + +### Setup +```pseudo +pool = ObjectsPool() +pool.syncState = SYNCED +pool["counter:abc@1000"] = LiveCounter(objectId: "counter:abc@1000") +pool["counter:abc@1000"].data = 10 +pool.appliedOnAckSerials = {"echo-serial-1"} +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + ObjectMessage( + serial: "echo-serial-1", + siteCode: "site1", + operation: { action: "COUNTER_INC", objectId: "counter:abc@1000", counterInc: { number: 5 } } + ) +])) +``` + +### Assertions +```pseudo +ASSERT pool["counter:abc@1000"].data == 10 +ASSERT "echo-serial-1" NOT IN pool.appliedOnAckSerials +``` + +--- + +## RTO9a2a4 - LOCAL source adds serial to appliedOnAckSerials + +**Test ID**: `objects/unit/RTO9a2a4/local-source-adds-serial-0` + +**Spec requirement:** If source is LOCAL and operation was applied successfully, add serial to appliedOnAckSerials. + +### Setup +```pseudo +pool = ObjectsPool() +pool.syncState = SYNCED +pool["counter:abc@1000"] = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +pool.applyObjectMessages([ + build_counter_inc("counter:abc@1000", 5, "local-serial-1", "test-site") +], source: LOCAL) +``` + +### Assertions +```pseudo +ASSERT "local-serial-1" IN pool.appliedOnAckSerials +ASSERT pool["counter:abc@1000"].data == 5 +``` + +--- + +## RTO9a2b - Unsupported action is discarded with warning + +**Test ID**: `objects/unit/RTO9a2b/unsupported-action-warning-0` + +**Spec requirement:** Log warning, discard. + +### Setup +```pseudo +pool = ObjectsPool() +pool.syncState = SYNCED +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + ObjectMessage( + serial: "01", siteCode: "site1", + operation: { action: "UNKNOWN_ACTION", objectId: "counter:abc@1000" } + ) +])) +``` + +### Assertions +```pseudo +ASSERT pool.keys().length == 1 +``` + +--- + +## RTO6 - Zero-value object creation from objectId prefix + +**Test ID**: `objects/unit/RTO6/zero-value-from-prefix-0` + +| Spec | Requirement | +|------|-------------| +| RTO6b1 | Parse type from objectId prefix before ":" | +| RTO6b2 | "map" prefix creates zero-value LiveMap | +| RTO6b3 | "counter" prefix creates zero-value LiveCounter | +| RTO6a | Skip if object already exists | + +### Setup +```pseudo +pool = ObjectsPool() +pool.syncState = SYNCED +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:new@2000", 5, "01", "site1") +])) +pool.processObjectMessage(build_object_message("test", [ + build_map_set("map:new@2000", "key", { string: "val" }, "01", "site1") +])) +``` + +### Assertions +```pseudo +ASSERT "counter:new@2000" IN pool +ASSERT pool["counter:new@2000"] IS LiveCounter +ASSERT pool["counter:new@2000"].data == 5 + +ASSERT "map:new@2000" IN pool +ASSERT pool["map:new@2000"] IS LiveMap +ASSERT pool["map:new@2000"].data["key"].data == { string: "val" } +``` + +--- + +## RTO5d - OBJECT_SYNC with null object field is skipped + +**Test ID**: `objects/unit/RTO5d/null-object-skipped-0` + +**Spec requirement:** If ObjectMessage.object is null or omitted, skip processing. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + ObjectMessage(object: null), + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +``` + +--- + +## RTO5f3 - OBJECT_SYNC with unsupported object type is skipped + +**Test ID**: `objects/unit/RTO5f3/unsupported-type-skipped-0` + +**Spec requirement:** If neither map nor counter is present, log warning and skip. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + ObjectMessage(object: { objectId: "unknown:xyz@1000", siteTimeserials: {} }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "unknown:xyz@1000" NOT IN pool +``` + +--- + +## RTO5e - OBJECT_SYNC transitions to SYNCING + +**Test ID**: `objects/unit/RTO5e/object-sync-transitions-syncing-0` + +**Spec requirement:** When OBJECT_SYNC received, sync state must transition to SYNCING if not already. + +### Setup +```pseudo +pool = ObjectsPool() +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:more", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCING +``` + +--- + +## RTO5c7 - Sync completion emits updates for existing objects + +**Test ID**: `objects/unit/RTO5c7/sync-emits-updates-0` + +**Spec requirement:** For each previously existing object updated by sync, emit the stored LiveObjectUpdate. + +### Setup +```pseudo +pool = ObjectsPool() +pool["root"].data = { + "name": { data: { string: "Old" }, timeserial: "01", tombstone: false } +} + +updates = [] +pool["root"].subscribe((update) => updates.append(update)) + +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { "name": { data: { string: "New" }, timeserial: "t:0" } } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT updates.length >= 1 +ASSERT "name" IN updates[0].update +ASSERT updates[0].update["name"] == "updated" +``` + +--- + +## RTO5f2b - Partial counter state logs error + +**Test ID**: `objects/unit/RTO5f2b/partial-counter-error-0` + +**Spec requirement:** If counter is present on partial merge, log error and skip. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:more", [ + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { counter: { count: 10 } }) +])) +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { counter: { count: 5 } }) +])) +``` + +### Assertions +```pseudo +ASSERT pool["counter:abc@1000"].data == 10 +``` + +--- + +## RTO4d - ATTACHED clears buffered operations + +**Test ID**: `objects/unit/RTO4d/attached-clears-buffer-0` + +**Spec requirement:** On ATTACHED, bufferedObjectOperations is cleared. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) + +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:abc@1000", 5, "01", "site1") +])) +ASSERT pool.bufferedObjectOperations.length == 1 +``` + +### Test Steps +```pseudo +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +``` + +### Assertions +```pseudo +ASSERT pool.bufferedObjectOperations.length == 0 +``` + +--- + +## RTO4, RTO5 - ATTACHED during SYNCING resets sync + +**Test ID**: `objects/unit/RTO4-RTO5/attached-during-syncing-resets-0` + +**Spec requirement:** A new ATTACHED message during SYNCING resets the sync state machine. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +pool.processObjectSync(build_object_sync_message("test", "sync1:more", [ + build_object_state("counter:old@1000", {"aaa": "t:0"}, { counter: { count: 10 } }) +])) +ASSERT pool.syncState == SYNCING +``` + +### Test Steps +```pseudo +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) + +pool.processObjectSync(build_object_sync_message("test", "sync2:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:new@1000", {"aaa": "t:0"}, { counter: { count: 99 } }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "counter:old@1000" NOT IN pool +ASSERT "counter:new@1000" IN pool +``` + +--- + +## RTO5, RTO7 - New OBJECT_SYNC sequence does NOT clear buffer + +**Test ID**: `objects/unit/RTO5-RTO7/new-sync-keeps-buffer-0` + +**Spec requirement:** When a new OBJECT_SYNC sequence starts (RTO5a2), only the SyncObjectsPool is discarded. Buffered OBJECT messages are retained for application after sync completion. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) + +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:abc@1000", 5, "01", "site1") +])) +ASSERT pool.bufferedObjectOperations.length == 1 +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "seq2:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { + counter: { count: 100 }, + createOp: { counterCreate: { count: 100 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT pool["counter:abc@1000"].data == 105 +``` + +--- + +## RTO7, RTO8 - OBJECT messages buffered even without preceding ATTACHED + +**Test ID**: `objects/unit/RTO7-RTO8/buffer-without-attached-0` + +**Spec requirement:** RTO8a: if sync state is not SYNCED, buffer ObjectMessages. This applies regardless of whether ATTACHED was received — INITIALIZED state also buffers. + +### Setup +```pseudo +pool = ObjectsPool() +ASSERT pool.syncState == INITIALIZED +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:abc@1000", 5, "01", "site1") +])) +``` + +### Assertions +```pseudo +ASSERT pool.bufferedObjectOperations.length == 1 +``` + +--- + +## RTO5c, RTLM23 - Sync with clearTimeserial hides initial createOp entries + +**Test ID**: `objects/unit/RTO5c-RTLM23/sync-clear-timeserial-hides-create-entries-0` + +**Spec requirement:** When a map's ObjectState includes a clearTimeserial, createOp entries with serials <= clearTimeserial are rejected during merge. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: {}, + clearTimeserial: "05" + }, + createOp: { + mapCreate: { + semantics: "LWW", + entries: { + "old_key": { data: { string: "old" }, timeserial: "03" }, + "new_key": { data: { string: "new" }, timeserial: "07" } + } + } + } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "old_key" NOT IN pool["root"].data +ASSERT pool["root"].data["new_key"].data == { string: "new" } +``` diff --git a/uts/objects/unit/path_object.md b/uts/objects/unit/path_object.md new file mode 100644 index 00000000..5a83c8e9 --- /dev/null +++ b/uts/objects/unit/path_object.md @@ -0,0 +1,603 @@ +# PathObject Read Operations Tests + +Spec points: `RTPO1`–`RTPO14` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTPO4 - path() returns dot-delimited string + +**Test ID**: `objects/unit/RTPO4/path-string-representation-0` + +| Spec | Requirement | +|------|-------------| +| RTPO4a | Dot-delimited string of path segments | +| RTPO4c | Empty path returns empty string | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.path() == "" +ASSERT root.get("profile").path() == "profile" +ASSERT root.get("profile").get("email").path() == "profile.email" +``` + +--- + +## RTPO4b - path() escapes dots in segments + +**Test ID**: `objects/unit/RTPO4b/path-escapes-dots-0` + +**Spec requirement:** Dot characters within segments are escaped with backslash. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +po = root.get("a.b").get("c") +``` + +### Assertions +```pseudo +ASSERT po.path() == "a\\.b.c" +``` + +--- + +## RTPO5 - get() returns new PathObject with appended key + +**Test ID**: `objects/unit/RTPO5/get-appends-key-0` + +| Spec | Requirement | +|------|-------------| +| RTPO5c | New PathObject with key appended | +| RTPO5d | Purely navigational, no resolution | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +child = root.get("profile") +grandchild = child.get("email") +``` + +### Assertions +```pseudo +ASSERT child.path() == "profile" +ASSERT grandchild.path() == "profile.email" +ASSERT child IS NOT root +``` + +--- + +## RTPO5b - get() throws on non-string key + +**Test ID**: `objects/unit/RTPO5b/get-non-string-throws-0` + +**Spec requirement:** If key is not String, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root.get(123) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTPO6 - at() parses dot-delimited path + +**Test ID**: `objects/unit/RTPO6/at-parses-path-0` + +| Spec | Requirement | +|------|-------------| +| RTPO6b | Parses dots as separators, backslash-escaped dots as literal | +| RTPO6d | Equivalent to chained get() calls | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +po = root.at("profile.email") +``` + +### Assertions +```pseudo +ASSERT po.path() == "profile.email" +ASSERT po.value() == "alice@example.com" +``` + +--- + +## RTPO6 - at() respects escaped dots + +**Test ID**: `objects/unit/RTPO6/at-escaped-dots-0` + +**Spec requirement:** `\.` is a literal dot within a segment. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +po = root.at("a\\.b.c") +``` + +### Assertions +```pseudo +ASSERT po.path() == "a\\.b.c" +``` + +--- + +## RTPO7 - value() returns counter numeric value + +**Test ID**: `objects/unit/RTPO7/value-counter-0` + +**Spec requirement:** If resolved value is LiveCounter, returns numeric value. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 100 +``` + +--- + +## RTPO7 - value() returns primitive value + +**Test ID**: `objects/unit/RTPO7/value-primitive-0` + +**Spec requirement:** If resolved value is a primitive, returns the value directly. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Alice" +ASSERT root.get("age").value() == 30 +ASSERT root.get("active").value() == true +``` + +--- + +## RTPO7d - value() returns null for LiveMap + +**Test ID**: `objects/unit/RTPO7d/value-livemap-null-0` + +**Spec requirement:** If resolved value is a LiveMap, returns null. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("profile").value() == null +``` + +--- + +## RTPO7e - value() returns null on resolution failure + +**Test ID**: `objects/unit/RTPO7e/value-unresolvable-null-0` + +**Spec requirement:** If path resolution fails, returns null. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("nonexistent").get("deep").value() == null +``` + +--- + +## RTPO8 - instance() returns Instance for LiveObject + +**Test ID**: `objects/unit/RTPO8/instance-live-object-0` + +| Spec | Requirement | +|------|-------------| +| RTPO8b | LiveMap or LiveCounter -> Instance wrapping that object | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +counter_inst = root.get("score").instance() +ASSERT counter_inst IS Instance +ASSERT counter_inst.id() == "counter:score@1000" + +map_inst = root.get("profile").instance() +ASSERT map_inst IS Instance +ASSERT map_inst.id() == "map:profile@1000" +``` + +--- + +## RTPO8c - instance() returns null for primitive + +**Test ID**: `objects/unit/RTPO8c/instance-primitive-null-0` + +**Spec requirement:** If resolved value is a primitive, returns null. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("name").instance() == null +``` + +--- + +## RTPO9 - entries() yields [key, PathObject] pairs + +**Test ID**: `objects/unit/RTPO9/entries-yields-pairs-0` + +| Spec | Requirement | +|------|-------------| +| RTPO9b | Iterator of [key, PathObject] for LiveMap entries | +| RTPO9c | Only non-tombstoned entries | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +entries = {} +FOR [key, pathObj] IN root.entries(): + entries[key] = pathObj.path() +``` + +### Assertions +```pseudo +ASSERT entries["name"] == "name" +ASSERT entries["profile"] == "profile" +ASSERT entries.length == 7 +``` + +--- + +## RTPO9d - entries() returns empty iterator for non-LiveMap + +**Test ID**: `objects/unit/RTPO9d/entries-non-map-empty-0` + +**Spec requirement:** If resolved value is not LiveMap or resolution fails, return empty iterator. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +entries = list(root.get("score").entries()) +``` + +### Assertions +```pseudo +ASSERT entries.length == 0 +``` + +--- + +## RTPO12 - size() returns non-tombstoned count + +**Test ID**: `objects/unit/RTPO12/size-count-0` + +**Spec requirement:** For LiveMap, returns non-tombstoned entry count. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.size() == 7 +ASSERT root.get("profile").size() == 3 +``` + +--- + +## RTPO12c - size() returns null for non-LiveMap + +**Test ID**: `objects/unit/RTPO12c/size-non-map-null-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("score").size() == null +ASSERT root.get("name").size() == null +``` + +--- + +## RTPO13 - compact() recursively compacts LiveMap tree + +**Test ID**: `objects/unit/RTPO13/compact-recursive-0` + +| Spec | Requirement | +|------|-------------| +| RTPO13b1 | Each entry included, tombstoned excluded | +| RTPO13b2 | Nested LiveMap recursively compacted | +| RTPO13b3 | Nested LiveCounter resolved to number | +| RTPO13b4 | Primitives as-is | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +result = root.compact() +``` + +### Assertions +```pseudo +ASSERT result["name"] == "Alice" +ASSERT result["age"] == 30 +ASSERT result["active"] == true +ASSERT result["score"] == 100 +ASSERT result["data"] == {"tags": ["a", "b"]} +ASSERT result["avatar"] IS bytes [1, 2, 3] +ASSERT result["profile"]["email"] == "alice@example.com" +ASSERT result["profile"]["nested_counter"] == 5 +ASSERT result["profile"]["prefs"]["theme"] == "dark" +``` + +--- + +## RTPO13b5 - compact() handles cycles via shared reference + +**Test ID**: `objects/unit/RTPO13b5/compact-cycle-detection-0` + +**Spec requirement:** Cyclic references reuse the already-compacted in-memory object. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:prefs@1000", "back_ref", { objectId: "map:profile@1000" }, "99", "remote") +])) +``` + +### Test Steps +```pseudo +result = root.get("profile").compact() +``` + +### Assertions +```pseudo +ASSERT result["prefs"]["back_ref"] IS result +``` + +--- + +## RTPO13c - compact() returns number for LiveCounter + +**Test ID**: `objects/unit/RTPO13c/compact-counter-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("score").compact() == 100 +``` + +--- + +## RTPO14 - compactJson() encodes binary as base64 and cycles as objectId + +**Test ID**: `objects/unit/RTPO14/compact-json-0` + +| Spec | Requirement | +|------|-------------| +| RTPO14a1 | Binary as base64 strings | +| RTPO14a2 | Cycles as {objectId: ...} | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:prefs@1000", "back_ref", { objectId: "map:profile@1000" }, "99", "remote") +])) +``` + +### Test Steps +```pseudo +result = root.get("profile").compactJson() +``` + +### Assertions +```pseudo +ASSERT result["prefs"]["back_ref"] == { "objectId": "map:profile@1000" } +``` + +--- + +## RTPO3 - Path resolution walks through LiveMaps + +**Test ID**: `objects/unit/RTPO3/path-resolution-walk-0` + +| Spec | Requirement | +|------|-------------| +| RTPO3a | Walk segments through LiveMaps | +| RTPO3b | Empty path resolves to root | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.value() == null +ASSERT root.get("profile").get("prefs").get("theme").value() == "dark" +``` + +--- + +## RTPO3a1 - Resolution fails if intermediate is not LiveMap + +**Test ID**: `objects/unit/RTPO3a1/intermediate-not-map-0` + +**Spec requirement:** Current object must be a LiveMap. If not, resolution fails. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("score").get("something").value() == null +``` + +--- + +## RTPO3c1 - Read operation returns null on resolution failure + +**Test ID**: `objects/unit/RTPO3c1/read-null-on-failure-0` + +**Spec requirement:** For read operations, return null. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("nonexistent").value() == null +ASSERT root.get("nonexistent").instance() == null +ASSERT root.get("nonexistent").size() == null +ASSERT root.get("nonexistent").compact() == null +``` + +--- + +## RTPO6b - at() throws for non-string input + +**Test ID**: `objects/unit/RTPO6b/at-non-string-throws-0` + +**Spec requirement:** If path is not String, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root.at(123) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTPO7 - value() returns bytes for binary entry + +**Test ID**: `objects/unit/RTPO7/value-bytes-0` + +**Spec requirement:** If resolved value is bytes, returns the raw binary data. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("avatar").value() IS bytes [1, 2, 3] +``` + +--- + +## RTPO14 - compactJson() encodes bytes as base64 string + +**Test ID**: `objects/unit/RTPO14/compact-json-bytes-0` + +**Spec requirement:** Binary values encoded as base64 strings in JSON representation. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +result = root.compactJson() +``` + +### Assertions +```pseudo +ASSERT result["avatar"] == "AQID" +``` diff --git a/uts/objects/unit/path_object_mutations.md b/uts/objects/unit/path_object_mutations.md new file mode 100644 index 00000000..ef33a1a1 --- /dev/null +++ b/uts/objects/unit/path_object_mutations.md @@ -0,0 +1,321 @@ +# PathObject Write Operations Tests + +Spec points: `RTPO15`–`RTPO18`, `RTPO3c2` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTPO15 - set() delegates to LiveMap#set + +**Test ID**: `objects/unit/RTPO15/set-delegates-to-map-0` + +| Spec | Requirement | +|------|-------------| +| RTPO15b | Resolves path, on failure throws RTPO3c2 | +| RTPO15c | LiveMap -> delegates to LiveMap#set | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Bob" +``` + +--- + +## RTPO15 - set() on nested path + +**Test ID**: `objects/unit/RTPO15/set-nested-path-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("profile").set("email", "bob@example.com") +``` + +### Assertions +```pseudo +ASSERT root.get("profile").get("email").value() == "bob@example.com" +``` + +--- + +## RTPO15d - set() on non-LiveMap throws 92007 + +**Test ID**: `objects/unit/RTPO15d/set-non-map-throws-0` + +**Spec requirement:** If resolved value is not a LiveMap, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").set("key", "value") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTPO16 - remove() delegates to LiveMap#remove + +**Test ID**: `objects/unit/RTPO16/remove-delegates-to-map-0` + +| Spec | Requirement | +|------|-------------| +| RTPO16b | Resolves path, on failure throws RTPO3c2 | +| RTPO16c | LiveMap -> delegates to LiveMap#remove | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.remove("name") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == null +``` + +--- + +## RTPO16d - remove() on non-LiveMap throws 92007 + +**Test ID**: `objects/unit/RTPO16d/remove-non-map-throws-0` + +**Spec requirement:** If resolved value is not a LiveMap, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").remove("key") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTPO17 - increment() delegates to LiveCounter#increment + +**Test ID**: `objects/unit/RTPO17/increment-delegates-to-counter-0` + +| Spec | Requirement | +|------|-------------| +| RTPO17b | Resolves path, on failure throws RTPO3c2 | +| RTPO17c | LiveCounter -> delegates to LiveCounter#increment | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").increment(25) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 125 +``` + +--- + +## RTPO17 - increment() defaults to 1 + +**Test ID**: `objects/unit/RTPO17/increment-default-amount-0` + +**Spec requirement:** amount defaults to 1. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").increment() +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 101 +``` + +--- + +## RTPO17d - increment() on non-LiveCounter throws 92007 + +**Test ID**: `objects/unit/RTPO17d/increment-non-counter-throws-0` + +**Spec requirement:** If resolved value is not a LiveCounter, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment(5) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTPO18 - decrement() delegates to LiveCounter#decrement + +**Test ID**: `objects/unit/RTPO18/decrement-delegates-to-counter-0` + +| Spec | Requirement | +|------|-------------| +| RTPO18b | Resolves path, on failure throws RTPO3c2 | +| RTPO18c | LiveCounter -> delegates to LiveCounter#decrement | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").decrement(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 90 +``` + +--- + +## RTPO18 - decrement() defaults to 1 + +**Test ID**: `objects/unit/RTPO18/decrement-default-amount-0` + +**Spec requirement:** amount defaults to 1. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").decrement() +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 99 +``` + +--- + +## RTPO18d - decrement() on non-LiveCounter throws 92007 + +**Test ID**: `objects/unit/RTPO18d/decrement-non-counter-throws-0` + +**Spec requirement:** If resolved value is not a LiveCounter, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.decrement(5) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTPO3c2 - set() on unresolvable path throws 92005 + +**Test ID**: `objects/unit/RTPO3c2/set-unresolvable-throws-0` + +**Spec requirement:** For write operations, if path resolution fails, throw 92005. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("nonexistent").get("deep").set("key", "value") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92005 +``` + +--- + +## RTPO3c2 - increment() on unresolvable path throws 92005 + +**Test ID**: `objects/unit/RTPO3c2/increment-unresolvable-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("nonexistent").increment(5) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92005 +``` diff --git a/uts/objects/unit/path_object_subscribe.md b/uts/objects/unit/path_object_subscribe.md new file mode 100644 index 00000000..503ac43f --- /dev/null +++ b/uts/objects/unit/path_object_subscribe.md @@ -0,0 +1,618 @@ +# PathObject Subscribe Tests + +Spec points: `RTPO19`–`RTPO21`, `RTO24` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTPO19 - subscribe() returns Subscription and receives events + +**Test ID**: `objects/unit/RTPO19/subscribe-receives-events-0` + +| Spec | Requirement | +|------|-------------| +| RTPO19c | Returns Subscription object | +| RTPO19d1 | Event.object is a PathObject pointing to change path | +| RTPO19d2 | Event.message is the ObjectMessage | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +sub = root.get("score").subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT sub IS Subscription +ASSERT events.length == 1 +ASSERT events[0].object IS PathObject +ASSERT events[0].object.path() == "score" +ASSERT events[0].message IS NOT null +``` + +--- + +## RTPO19b1b - subscribe() with depth 1 only receives self events + +**Test ID**: `objects/unit/RTPO19b1b/subscribe-depth-1-self-only-0` + +**Spec requirement:** depth=1 means only changes at the exact subscribed path trigger the listener. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event), { depth: 1 }) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "100", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 1 +``` + +--- + +## RTPO19b1c - subscribe() with depth 2 receives self and children + +**Test ID**: `objects/unit/RTPO19b1c/subscribe-depth-2-children-0` + +**Spec requirement:** depth=n means changes up to n-1 levels of children trigger the listener. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event), { depth: 2 }) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "101", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 2 +``` + +--- + +## RTPO19b1a - subscribe() with no depth receives all descendants + +**Test ID**: `objects/unit/RTPO19b1a/subscribe-unlimited-depth-0` + +**Spec requirement:** If depth is undefined, subscription receives events at any depth. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:prefs@1000", "theme", { string: "light" }, "101", "remote") +])) +poll_until(events.length >= 3, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length >= 3 +``` + +--- + +## RTPO19b1d - subscribe() with non-positive depth throws 40003 + +**Test ID**: `objects/unit/RTPO19b1d/subscribe-non-positive-depth-throws-0` + +**Spec requirement:** If depth is provided and is not a positive integer, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root.subscribe((event) => {}, { depth: 0 }) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTPO19b1d - subscribe() with negative depth throws 40003 + +**Test ID**: `objects/unit/RTPO19b1d/subscribe-negative-depth-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root.subscribe((event) => {}, { depth: -1 }) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTPO19e - subscribe() follows path not identity + +**Test ID**: `objects/unit/RTPO19e/subscribe-follows-path-0` + +**Spec requirement:** If the object at the path changes identity, the subscription continues to deliver events for the new object. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.get("score").subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +// Replace the counter at "score" with a new counter +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") +])) + +// Increment the NEW counter at "score" +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:new@2000", 10, "100", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +// Should receive event for the new counter, since subscription follows path +found_new = false +FOR event IN events: + IF event.object.path() == "score": + found_new = true +ASSERT found_new == true +``` + +--- + +## RTPO19f - child events bubble up to parent subscription + +**Test ID**: `objects/unit/RTPO19f/child-events-bubble-0` + +**Spec requirement:** Events at child paths bubble up subject to depth filtering. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.get("profile").subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:nested@1000", 3, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length >= 2 +``` + +--- + +## RTO24b3 - depth filtering formula + +**Test ID**: `objects/unit/RTO24b3/depth-filtering-formula-0` + +**Spec requirement:** Event dispatched if `eventPath.length - subscriptionPath.length + 1 <= depth`. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +// Subscribe at "profile" with depth 2: +// self (profile) → segmentDiff=0, 0+1=1 ≤ 2 ✓ +// child (profile.email) → segmentDiff=1, 1+1=2 ≤ 2 ✓ +// grandchild (profile.prefs.theme) → segmentDiff=2, 2+1=3 > 2 ✗ +root.get("profile").subscribe((event) => events.append(event), { depth: 2 }) +``` + +### Test Steps +```pseudo +// Self event (profile map update) +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) + +// Child event (nested counter) +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:nested@1000", 3, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) + +// Grandchild event (prefs.theme) — should NOT be received +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:prefs@1000", "theme", { string: "light" }, "101", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 2 +``` + +--- + +## RTO24b5 - listener exception does not affect other listeners + +**Test ID**: `objects/unit/RTO24b5/listener-exception-caught-0` + +**Spec requirement:** If a listener throws, the error is caught and logged without affecting other subscriptions. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => { THROW Error("boom") }) +root.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length == 1 +``` + +--- + +## RTPO20 - unsubscribe() deregisters listener + +**Test ID**: `objects/unit/RTPO20/unsubscribe-deregisters-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +sub = root.get("score").subscribe((event) => events.append(event)) +sub.unsubscribe() +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 0 +``` + +--- + +## RTPO19g - subscribe() has no side effects + +**Test ID**: `objects/unit/RTPO19g/subscribe-no-side-effects-0` + +**Spec requirement:** Must not have side effects on RealtimeObject, channel, or their status. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +state_before = channel.state +``` + +### Test Steps +```pseudo +root.get("score").subscribe((event) => {}) +``` + +### Assertions +```pseudo +ASSERT channel.state == state_before +``` + +--- + +## RTPO19 - MAP_CLEAR triggers subscription events on child paths + +**Test ID**: `objects/unit/RTPO19/map-clear-triggers-child-events-0` + +**Spec requirement:** When MAP_CLEAR is applied, subscriptions on affected child paths receive events. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_clear("root", "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length >= 1 +``` + +--- + +## RTPO19 - subscribe() on primitive path receives change events + +**Test ID**: `objects/unit/RTPO19/subscribe-primitive-path-0` + +**Spec requirement:** A subscription on a path pointing to a primitive (e.g., root.get("name")) fires when the map entry at that key changes. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.get("name").subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length == 1 +ASSERT events[0].object.path() == "name" +``` + +--- + +## RTPO19d - subscribe() event provides correct PathObject + +**Test ID**: `objects/unit/RTPO19d/event-path-object-correct-0` + +**Spec requirement:** RTPO19d1: event.object is a PathObject pointing to the change location. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events[0].object IS PathObject +ASSERT events[0].object.path() == "score" +ASSERT events[0].object.value() == 107 +``` + +--- + +## RTPO21 - subscribeIterator() yields events + +**Test ID**: `objects/unit/RTPO21/subscribe-iterator-yields-0` + +| Spec | Requirement | +|------|-------------| +| RTPO21b | Returns async iterable of PathObjectSubscriptionEvent | +| RTPO21d | Each iteration yields next event | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +iter = root.get("score").subscribeIterator() +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) + +event = AWAIT iter.next() +``` + +### Assertions +```pseudo +ASSERT event.object IS PathObject +ASSERT event.object.path() == "score" +``` + +--- + +## RTPO21 - subscribeIterator() with depth option + +**Test ID**: `objects/unit/RTPO21/subscribe-iterator-depth-0` + +**Spec requirement:** subscribeIterator accepts same options as subscribe, including depth. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +iter = root.subscribeIterator({ depth: 1 }) +``` + +### Test Steps +```pseudo +// Self event (depth 1 allows) +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +event = AWAIT iter.next() + +// Child event (depth 1 rejects — counter at depth 2) +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "100", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT event.object.path() == "" +``` + +--- + +## RTPO21 - subscribeIterator() break cleanup + +**Test ID**: `objects/unit/RTPO21/subscribe-iterator-break-cleanup-0` + +**Spec requirement:** Breaking out of the iterator loop cleans up the underlying subscription. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +received = [] +``` + +### Test Steps +```pseudo +iter = root.get("score").subscribeIterator() + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 1, "99", "remote") +])) + +event = AWAIT iter.next() +received.append(event) + +// Break the iterator (cleanup) +iter.return() + +// Further events should not be received +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 1, "100", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT received.length == 1 +``` + +--- + +## RTPO21 - subscribeIterator() multiple concurrent iterators + +**Test ID**: `objects/unit/RTPO21/subscribe-iterator-concurrent-0` + +**Spec requirement:** Multiple iterators can coexist independently. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +iter1 = root.get("score").subscribeIterator() +iter2 = root.get("score").subscribeIterator() +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "99", "remote") +])) + +event1 = AWAIT iter1.next() +event2 = AWAIT iter2.next() +``` + +### Assertions +```pseudo +ASSERT event1.object.path() == "score" +ASSERT event2.object.path() == "score" +``` diff --git a/uts/objects/unit/realtime_object.md b/uts/objects/unit/realtime_object.md new file mode 100644 index 00000000..fd833be6 --- /dev/null +++ b/uts/objects/unit/realtime_object.md @@ -0,0 +1,927 @@ +# RealtimeObject Tests + +Spec points: `RTO2`, `RTO10`, `RTO15`, `RTO17`–`RTO20`, `RTO22`–`RTO24` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel`, `setup_synced_channel_no_ack`, and builder functions. + +--- + +## RTO23 - get() returns PathObject wrapping root + +**Test ID**: `objects/unit/RTO23/get-returns-path-object-0` + +| Spec | Requirement | +|------|-------------| +| RTO23d | Returns PathObject wrapping root LiveMap with empty path | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +ASSERT root.path() == "" +``` + +--- + +## RTO23a - get() requires OBJECT_SUBSCRIBE mode + +**Test ID**: `objects/unit/RTO23a/get-requires-subscribe-mode-0` + +**Spec requirement:** Requires OBJECT_SUBSCRIBE channel mode per RTO2. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site" + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +AWAIT channel.object.get() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +``` + +--- + +## RTO23b - get() throws on DETACHED or FAILED channel + +**Test ID**: `objects/unit/RTO23b/get-throws-detached-0` + +**Spec requirement:** If channel is DETACHED or FAILED, throw 90001. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site" + }) + ) +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE"] }) +``` + +### Test Steps +```pseudo +AWAIT channel.object.get() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 90001 +``` + +--- + +## RTO23c - get() waits for SYNCED state + +**Test ID**: `objects/unit/RTO23c/get-waits-for-synced-0` + +**Spec requirement:** If sync state is not SYNCED, waits for SYNCED transition. + +### Setup +```pseudo +attach_sent = false +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_sent = true + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:cursor", + flags: HAS_OBJECTS + )) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +get_future = channel.object.get() + +poll_until(attach_sent, timeout: 5s) + +mock_ws.send_to_client(build_object_sync_message( + "test", "sync1:", STANDARD_POOL_OBJECTS +)) + +root = AWAIT get_future +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +``` + +--- + +## RTO15 - publish sends OBJECT ProtocolMessage + +**Test ID**: `objects/unit/RTO15/publish-sends-object-pm-0` + +| Spec | Requirement | +|------|-------------| +| RTO15e1 | action set to OBJECT | +| RTO15e2 | channel set to channel name | +| RTO15e3 | state set to encoded ObjectMessages | +| RTO15h | Returns PublishResult from ACK | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, ["serial-0"])) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +result = AWAIT channel.object.publish([ + build_counter_inc("counter:score@1000", 5, null, null) +]) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +ASSERT captured_messages[0].action == OBJECT +ASSERT captured_messages[0].channel == "test" +ASSERT captured_messages[0].state.length == 1 +ASSERT result.serials == ["serial-0"] +``` + +--- + +## RTO20 - publishAndApply applies locally on ACK + +**Test ID**: `objects/unit/RTO20/publish-and-apply-local-0` + +| Spec | Requirement | +|------|-------------| +| RTO20b | Calls publish and awaits PublishResult | +| RTO20d2a | Synthetic message serial from PublishResult | +| RTO20d2b | Synthetic message siteCode from ConnectionDetails | +| RTO20f | Apply synthetic messages with source LOCAL | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 110 +``` + +--- + +## RTO20c - publishAndApply logs error when siteCode missing + +**Test ID**: `objects/unit/RTO20c/missing-site-code-0` + +| Spec | Requirement | +|------|-------------| +| RTO20c1 | Requires siteCode from ConnectionDetails | + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + mock_ws.send_to_client(build_ack_message(msg.msgSerial, ["serial-0"])) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 100 +``` + +--- + +## RTO20d1 - null serial in PublishResult is skipped + +**Test ID**: `objects/unit/RTO20d1/null-serial-skipped-0` + +**Spec requirement:** If serial from PublishResult is null, skip that ObjectMessage. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + mock_ws.send_to_client(build_ack_message(msg.msgSerial, [null])) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 100 +``` + +--- + +## RTO20e - publishAndApply waits for SYNCED during SYNCING + +**Test ID**: `objects/unit/RTO20e/waits-for-synced-0` + +**Spec requirement:** If sync state is not SYNCED, wait for SYNCED transition. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", + flags: HAS_OBJECTS +)) + +inc_future = root.increment(10) + +mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) + +AWAIT inc_future +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 110 +``` + +--- + +## RTO20e1 - publishAndApply fails when channel enters FAILED during sync wait + +**Test ID**: `objects/unit/RTO20e1/fails-on-channel-failed-0` + +**Spec requirement:** If channel enters DETACHED/SUSPENDED/FAILED while waiting, fail with 92008. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", + flags: HAS_OBJECTS +)) + +inc_future = root.increment(10) + +mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, channel: "test", + error: { code: 90000, statusCode: 400, message: "Channel detached" } +)) + +AWAIT inc_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92008 +``` + +--- + +## RTO17, RTO18 - Sync state events + +**Test ID**: `objects/unit/RTO17/sync-state-events-0` + +| Spec | Requirement | +|------|-------------| +| RTO17b | Emit event matching new sync state | +| RTO18b1 | SYNCING event | +| RTO18b2 | SYNCED event | +| RTO18e | Listeners called with no arguments | + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:cursor", + flags: HAS_OBJECTS + )) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +events = [] +channel.object.on(SYNCING, () => events.append("SYNCING")) +channel.object.on(SYNCED, () => events.append("SYNCED")) +``` + +### Test Steps +```pseudo +get_future = channel.object.get() + +poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_sync_message("test", "sync1:", STANDARD_POOL_OBJECTS)) + +AWAIT get_future +``` + +### Assertions +```pseudo +ASSERT events CONTAINS_IN_ORDER ["SYNCING", "SYNCED"] +``` + +--- + +## RTO18d - Duplicate listener registered twice fires twice + +**Test ID**: `objects/unit/RTO18d/duplicate-listener-0` + +**Spec requirement:** If same listener registered twice, it is invoked twice per event. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +call_count = 0 +listener = () => { call_count++ } +channel.object.on(SYNCED, listener) +channel.object.on(SYNCED, listener) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) + +poll_until(call_count >= 2, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT call_count == 2 +``` + +--- + +## RTO19 - off() deregisters listener + +**Test ID**: `objects/unit/RTO19/off-deregisters-0` + +**Spec requirement:** Deregisters event listener previously registered via on(). + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +call_count = 0 +listener = () => { call_count++ } +sub = channel.object.on(SYNCED, listener) +sub.off() +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) +``` + +### Assertions +```pseudo +ASSERT call_count == 0 +``` + +--- + +## RTO2 - Channel mode enforcement + +**Test ID**: `objects/unit/RTO2/mode-enforcement-0` + +| Spec | Requirement | +|------|-------------| +| RTO2a | ATTACHED state checks granted modes | +| RTO2b | Non-ATTACHED checks requested modes | +| RTO2a2 | Missing mode throws 40024 | + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS, + modes: ["OBJECT_SUBSCRIBE"] + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +``` + +--- + +## RTO10 - GC removes tombstoned objects past grace period + +**Test ID**: `objects/unit/RTO10/gc-tombstoned-objects-0` + +| Spec | Requirement | +|------|-------------| +| RTO10a | Check at regular intervals | +| RTO10c1b | Remove if difference >= grace period | +| RTO10b1 | Grace period from ConnectionDetails | + +### Setup +```pseudo +enable_fake_timers() +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +mock_ws.send_to_client(build_object_message("test", [ + build_object_delete("counter:score@1000", "99", "site1", 1000) +])) +``` + +### Test Steps +```pseudo +ADVANCE_TIME(86400000 + 300000) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == null +``` + +--- + +## RTO20 - Echo deduplication via appliedOnAckSerials + +**Test ID**: `objects/unit/RTO20/echo-dedup-0` + +**Spec requirement:** When echo arrives with same serial as applied-on-ACK, it is deduplicated. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +score_after_apply = root.get("score").value() + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "ack-0:0", "test-site") +])) +score_after_echo = root.get("score").value() +``` + +### Assertions +```pseudo +ASSERT score_after_apply == 110 +ASSERT score_after_echo == 110 +``` + +--- + +## RTO20f - Apply-on-ACK does not update siteTimeserials + +**Test ID**: `objects/unit/RTO20f/ack-no-site-timeserials-update-0` + +| Spec | Requirement | +|------|-------------| +| RTO20f | Apply with source LOCAL | +| RTLC7c2 | LOCAL source does not update siteTimeserials | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +site_serials_before = root.get("score").instance()._liveObject.siteTimeserials +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +site_serials_after = root.get("score").instance()._liveObject.siteTimeserials +``` + +### Assertions +```pseudo +ASSERT site_serials_after == site_serials_before +``` + +--- + +## RTO20 - ACK after echo does not double-apply + +**Test ID**: `objects/unit/RTO20/ack-after-echo-no-double-apply-0` + +**Spec requirement:** If the echo arrives before the ACK is processed, the ACK-based apply finds the serial already applied and deduplicates via RTO9a3. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel_no_ack("test") +``` + +### Test Steps +```pseudo +inc_future = root.increment(10) + +// Send the echo BEFORE the ACK +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "ack-0:0", "test-site") +])) + +// Now send the ACK +mock_ws.send_to_client(build_ack_message(0, ["ack-0:0"])) + +AWAIT inc_future +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 110 +``` + +--- + +## RTO5c9, RTO20 - appliedOnAckSerials cleared on re-sync + +**Test ID**: `objects/unit/RTO5c9-RTO20/ack-serials-cleared-on-resync-0` + +**Spec requirement:** appliedOnAckSerials is cleared when sync completes. After re-sync, an echo with a previously-applied serial is applied normally (not deduplicated). + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +ASSERT root.get("score").value() == 110 + +// Trigger re-sync +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) + +// After re-sync, the score is back to 100 (from pool state) +ASSERT root.get("score").value() == 100 +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 100 +``` + +--- + +## RTO20 - Subscription fires on apply-on-ACK + +**Test ID**: `objects/unit/RTO20/subscription-fires-on-ack-apply-0` + +**Spec requirement:** When publishAndApply applies locally via ACK, subscription listeners are notified. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.get("score").subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +``` + +### Assertions +```pseudo +ASSERT events.length >= 1 +ASSERT root.get("score").value() == 110 +``` + +--- + +## RTO23 - get() implicitly attaches channel + +**Test ID**: `objects/unit/RTO23/get-implicit-attach-0` + +**Spec requirement:** get() triggers attach if channel is not yet attached. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +ASSERT channel.state == INITIALIZED +root = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +ASSERT channel.state == ATTACHED +``` + +--- + +## RTO23d - get() resolves immediately when already SYNCED + +**Test ID**: `objects/unit/RTO23d/get-resolves-immediately-synced-0` + +**Spec requirement:** If sync state is already SYNCED, get() resolves immediately. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root2 = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root2 IS PathObject +ASSERT root2.path() == "" +``` + +--- + +## RTO10b1 - GC grace period from ConnectionDetails + +**Test ID**: `objects/unit/RTO10b1/gc-grace-period-source-0` + +**Spec requirement:** GC grace period comes from ConnectionDetails.objectsGCGracePeriod. + +### Setup +```pseudo +enable_fake_timers() +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 5000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() + +mock_ws.send_to_client(build_object_message("test", [ + build_object_delete("counter:score@1000", "99", "site1", 1000) +])) +``` + +### Test Steps +```pseudo +// Short grace period (5000ms) — advance past it +ADVANCE_TIME(5000 + 1000) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == null +``` + +--- + +## RTO17, RTO18 - Sync event sequences for all state transitions + +**Test ID**: `objects/unit/RTO17-RTO18/sync-event-sequences-0` + +**Spec requirement:** Verify all sync state transition sequences. + +### Setup +```pseudo +scenarios = [ + { + name: "initial attach", + trigger: () => { + channel.attach() + }, + expected_events: ["SYNCING", "SYNCED"] + }, + { + name: "re-attach after detach", + trigger: () => { + mock_ws.send_to_client(ProtocolMessage(action: DETACHED, channel: "test")) + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) + }, + expected_events: ["SYNCING", "SYNCED"] + }, + { + name: "re-sync on new ATTACHED", + trigger: () => { + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync3:cursor", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync3:", STANDARD_POOL_OBJECTS)) + }, + expected_events: ["SYNCING", "SYNCED"] + }, + { + name: "ATTACHED without HAS_OBJECTS", + trigger: () => { + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync4:", flags: 0 + )) + }, + expected_events: ["SYNCED"] + } +] + +FOR scenario IN scenarios: + { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + events = [] + channel.object.on(SYNCING, () => events.append("SYNCING")) + channel.object.on(SYNCED, () => events.append("SYNCED")) + + scenario.trigger() + poll_until(events.length >= scenario.expected_events.length, timeout: 5s) + + ASSERT events == scenario.expected_events +``` diff --git a/uts/objects/unit/value_types.md b/uts/objects/unit/value_types.md new file mode 100644 index 00000000..dc99aec2 --- /dev/null +++ b/uts/objects/unit/value_types.md @@ -0,0 +1,451 @@ +# Value Types Tests + +Spec points: `RTLCV1`–`RTLCV4`, `RTLMV1`–`RTLMV4` + +## Test Type +Unit test — pure construction and consumption, no mocks required. + +## Purpose + +Tests `LiveCounterValueType` and `LiveMapValueType` — immutable blueprints created via `LiveCounter.create()` and `LiveMap.create()` static factories. When consumed by a mutation method, they generate `ObjectMessages` with v6 wire format fields (`counterCreateWithObjectId`, `mapCreateWithObjectId`). + +--- + +## RTLCV3 - LiveCounter.create with initial count + +**Test ID**: `objects/unit/RTLCV3/create-with-count-0` + +| Spec | Requirement | +|------|-------------| +| RTLCV3a1 | Accepts optional initialCount | +| RTLCV3b | Returns LiveCounterValueType with internal count | +| RTLCV3d | Returned value is immutable | + +### Test Steps +```pseudo +vt = LiveCounter.create(42) +``` + +### Assertions +```pseudo +ASSERT vt IS LiveCounterValueType +ASSERT vt.count == 42 +``` + +--- + +## RTLCV3 - LiveCounter.create defaults to 0 + +**Test ID**: `objects/unit/RTLCV3/create-default-zero-0` + +**Spec requirement:** If initialCount omitted, defaults to 0. + +### Test Steps +```pseudo +vt = LiveCounter.create() +``` + +### Assertions +```pseudo +ASSERT vt.count == 0 +``` + +--- + +## RTLCV3c - No validation at creation time + +**Test ID**: `objects/unit/RTLCV3c/no-validation-at-create-0` + +**Spec requirement:** No input validation is performed at creation time; deferred to consumption. + +### Test Steps +```pseudo +vt = LiveCounter.create("not_a_number") +``` + +### Assertions +```pseudo +ASSERT vt IS LiveCounterValueType +``` + +--- + +## RTLCV4 - Consumption generates COUNTER_CREATE ObjectMessage + +**Test ID**: `objects/unit/RTLCV4/consume-generates-message-0` + +| Spec | Requirement | +|------|-------------| +| RTLCV4b1 | CounterCreate.count set to internal count | +| RTLCV4c | Initial value JSON string from CounterCreate | +| RTLCV4d | Unique nonce with 16+ characters | +| RTLCV4f | objectId generated via RTO14 with type "counter" | +| RTLCV4g1 | action set to COUNTER_CREATE | +| RTLCV4g2 | objectId set | +| RTLCV4g3 | counterCreateWithObjectId.nonce set | +| RTLCV4g4 | counterCreateWithObjectId.initialValue set | + +### Test Steps +```pseudo +vt = LiveCounter.create(42) +messages = consume(vt) +``` + +### Assertions +```pseudo +ASSERT messages.length == 1 +msg = messages[0] +ASSERT msg.operation.action == "COUNTER_CREATE" +ASSERT msg.operation.objectId STARTS WITH "counter:" +ASSERT msg.operation.objectId CONTAINS "@" +ASSERT msg.operation.counterCreateWithObjectId IS NOT null +ASSERT msg.operation.counterCreateWithObjectId.nonce IS NOT null +ASSERT msg.operation.counterCreateWithObjectId.nonce.length >= 16 +ASSERT msg.operation.counterCreateWithObjectId.initialValue IS NOT null +``` + +--- + +## RTLCV4g5 - Consumption retains local CounterCreate + +**Test ID**: `objects/unit/RTLCV4g5/retains-local-counter-create-0` + +**Spec requirement:** Client must retain CounterCreate alongside CounterCreateWithObjectId for local use. + +### Test Steps +```pseudo +vt = LiveCounter.create(42) +messages = consume(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +ASSERT msg.operation.counterCreate IS NOT null +ASSERT msg.operation.counterCreate.count == 42 +``` + +--- + +## RTLCV4a - Consumption validates count type + +**Test ID**: `objects/unit/RTLCV4a/consume-validates-count-0` + +**Spec requirement:** If count is not undefined and (not a Number or not finite), throw 40003. + +### Test Steps +```pseudo +vt = LiveCounter.create("not_a_number") +consume(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLCV4 - Consumption with count 0 + +**Test ID**: `objects/unit/RTLCV4/consume-zero-count-0` + +**Spec requirement:** count=0 is valid and should be included in CounterCreate. + +### Test Steps +```pseudo +vt = LiveCounter.create(0) +messages = consume(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +ASSERT msg.operation.counterCreate.count == 0 +``` + +--- + +## RTLMV3 - LiveMap.create with entries + +**Test ID**: `objects/unit/RTLMV3/create-with-entries-0` + +| Spec | Requirement | +|------|-------------| +| RTLMV3a1 | Accepts optional entries dict | +| RTLMV3b | Returns LiveMapValueType with internal entries | +| RTLMV3d | Returned value is immutable | + +### Test Steps +```pseudo +vt = LiveMap.create({ + "name": "Alice", + "age": 30 +}) +``` + +### Assertions +```pseudo +ASSERT vt IS LiveMapValueType +ASSERT vt.entries["name"] == "Alice" +ASSERT vt.entries["age"] == 30 +``` + +--- + +## RTLMV3 - LiveMap.create with no entries + +**Test ID**: `objects/unit/RTLMV3/create-no-entries-0` + +**Spec requirement:** If entries omitted, internal entries is undefined. + +### Test Steps +```pseudo +vt = LiveMap.create() +``` + +### Assertions +```pseudo +ASSERT vt IS LiveMapValueType +``` + +--- + +## RTLMV4 - Consumption generates MAP_CREATE ObjectMessage + +**Test ID**: `objects/unit/RTLMV4/consume-generates-message-0` + +| Spec | Requirement | +|------|-------------| +| RTLMV4e1 | MapCreate.semantics set to LWW | +| RTLMV4f | Initial value JSON string | +| RTLMV4g | Unique nonce 16+ chars | +| RTLMV4i | objectId via RTO14 with type "map" | +| RTLMV4j1 | action set to MAP_CREATE | +| RTLMV4j3 | mapCreateWithObjectId.nonce set | +| RTLMV4j4 | mapCreateWithObjectId.initialValue set | + +### Test Steps +```pseudo +vt = LiveMap.create({ "name": "Alice" }) +messages = consume(vt) +``` + +### Assertions +```pseudo +ASSERT messages.length == 1 +msg = messages[0] +ASSERT msg.operation.action == "MAP_CREATE" +ASSERT msg.operation.objectId STARTS WITH "map:" +ASSERT msg.operation.mapCreateWithObjectId IS NOT null +ASSERT msg.operation.mapCreateWithObjectId.nonce.length >= 16 +ASSERT msg.operation.mapCreateWithObjectId.initialValue IS NOT null +``` + +--- + +## RTLMV4j5 - Consumption retains local MapCreate + +**Test ID**: `objects/unit/RTLMV4j5/retains-local-map-create-0` + +**Spec requirement:** Client must retain MapCreate alongside MapCreateWithObjectId for local use. + +### Test Steps +```pseudo +vt = LiveMap.create({ "name": "Alice" }) +messages = consume(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +ASSERT msg.operation.mapCreate IS NOT null +ASSERT msg.operation.mapCreate.semantics == "LWW" +ASSERT msg.operation.mapCreate.entries["name"].data.string == "Alice" +``` + +--- + +## RTLMV4d - Entry value type mapping + +**Test ID**: `objects/unit/RTLMV4d/entry-value-types-0` + +| Spec | Requirement | +|------|-------------| +| RTLMV4d3 | JsonArray/JsonObject -> data.json | +| RTLMV4d4 | String -> data.string | +| RTLMV4d5 | Number -> data.number | +| RTLMV4d6 | Boolean -> data.boolean | +| RTLMV4d7 | Binary -> data.bytes | + +### Test Steps +```pseudo +vt = LiveMap.create({ + "str": "hello", + "num": 42, + "bool": true, + "json_arr": [1, 2, 3], + "json_obj": { "key": "value" } +}) +messages = consume(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +entries = msg.operation.mapCreate.entries +ASSERT entries["str"].data.string == "hello" +ASSERT entries["num"].data.number == 42 +ASSERT entries["bool"].data.boolean == true +ASSERT entries["json_arr"].data.json == [1, 2, 3] +ASSERT entries["json_obj"].data.json == { "key": "value" } +``` + +--- + +## RTLMV4d1, RTLMV4d2 - Nested value types produce depth-first ObjectMessages + +**Test ID**: `objects/unit/RTLMV4d1/nested-value-types-0` + +| Spec | Requirement | +|------|-------------| +| RTLMV4d1 | LiveCounterValueType consumed, ObjectMessage collected, objectId set | +| RTLMV4d2 | LiveMapValueType recursively consumed, all ObjectMessages collected | +| RTLMV4k | Return depth-first order: inner creates before outer | + +### Test Steps +```pseudo +inner_counter = LiveCounter.create(10) +inner_map = LiveMap.create({ + "nested_count": inner_counter +}) +outer = LiveMap.create({ + "child": inner_map +}) +messages = consume(outer) +``` + +### Assertions +```pseudo +ASSERT messages.length == 3 +ASSERT messages[0].operation.action == "COUNTER_CREATE" +ASSERT messages[0].operation.objectId STARTS WITH "counter:" +ASSERT messages[1].operation.action == "MAP_CREATE" +ASSERT messages[1].operation.objectId STARTS WITH "map:" +ASSERT messages[2].operation.action == "MAP_CREATE" +ASSERT messages[2].operation.objectId STARTS WITH "map:" + +inner_counter_id = messages[0].operation.objectId +inner_map_id = messages[1].operation.objectId +outer_map_id = messages[2].operation.objectId + +ASSERT messages[1].operation.mapCreate.entries["nested_count"].data.objectId == inner_counter_id +ASSERT messages[2].operation.mapCreate.entries["child"].data.objectId == inner_map_id +``` + +--- + +## RTLMV4a - Consumption validates entries type + +**Test ID**: `objects/unit/RTLMV4a/consume-validates-entries-0` + +**Spec requirement:** If entries is not undefined and (is null or not Dict), throw 40003. + +### Test Steps +```pseudo +vt = LiveMap.create(null) +consume(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLMV4b - Consumption validates key types + +**Test ID**: `objects/unit/RTLMV4b/consume-validates-keys-0` + +**Spec requirement:** If any key is not String, throw 40003. + +### Test Steps +```pseudo +vt = LiveMap.create({ 123: "value" }) +consume(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLMV4c - Consumption validates value types + +**Test ID**: `objects/unit/RTLMV4c/consume-validates-values-0` + +**Spec requirement:** If any value is not an expected type, throw 40013. + +### Test Steps +```pseudo +vt = LiveMap.create({ "fn": some_function }) +consume(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40013 +``` + +--- + +## RTLMV4e2 - Empty entries produces MapCreate with empty entries + +**Test ID**: `objects/unit/RTLMV4e2/empty-entries-0` + +**Spec requirement:** If internal entries is undefined, MapCreate.entries is empty map. + +### Test Steps +```pseudo +vt = LiveMap.create() +messages = consume(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +ASSERT msg.operation.mapCreate.entries == {} +``` + +--- + +## RTLMV4d - Table-driven MAP_SET value type mapping + +**Test ID**: `objects/unit/RTLMV4d/map-set-all-types-table-0` + +**Spec requirement:** Every supported value type maps to the correct data field. + +### Test Steps +```pseudo +type_scenarios = [ + { input: "hello", expected_field: "string", expected_value: "hello" }, + { input: 42, expected_field: "number", expected_value: 42 }, + { input: 3.14, expected_field: "number", expected_value: 3.14 }, + { input: 0, expected_field: "number", expected_value: 0 }, + { input: -1, expected_field: "number", expected_value: -1 }, + { input: true, expected_field: "boolean", expected_value: true }, + { input: false, expected_field: "boolean", expected_value: false }, + { input: [1, "a", null], expected_field: "json", expected_value: [1, "a", null] }, + { input: { "k": "v" }, expected_field: "json", expected_value: { "k": "v" } }, + { input: bytes([1, 2, 3]), expected_field: "bytes", expected_value: "AQID" } +] + +FOR scenario IN type_scenarios: + vt = LiveMap.create({ "test_key": scenario.input }) + messages = consume(vt) + entry = messages[0].operation.mapCreate.entries["test_key"] + ASSERT entry.data[scenario.expected_field] == scenario.expected_value +``` From 08789a667934b360fc947bf3dec37ae37dd326ce Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Thu, 14 May 2026 08:16:21 +0100 Subject: [PATCH 2/2] Delegate proxy port assignment to uts-proxy in all test specs Remove client-side allocated_port/port_base patterns from all proxy test specs and helper docs. Port is now auto-assigned by the proxy when omitted from create_proxy_session(). Matches uts-proxy v0.2.0. Co-Authored-By: Claude Opus 4.6 --- uts/docs/integration-testing.md | 1 - uts/docs/writing-test-specs.md | 1 - .../integration/proxy/objects_faults.md | 10 ++--- uts/realtime/integration/helpers/proxy.md | 5 +-- uts/realtime/integration/proxy/auth_reauth.md | 13 +----- .../integration/proxy/channel_faults.md | 14 +++--- .../proxy/connection_open_failures.md | 10 ++--- .../integration/proxy/connection_resume.md | 44 +++++-------------- uts/realtime/integration/proxy/heartbeat.md | 2 +- uts/realtime/integration/proxy/rest_faults.md | 6 +-- uts/rest/integration/proxy/rest_fallback.md | 14 +++--- 11 files changed, 42 insertions(+), 78 deletions(-) diff --git a/uts/docs/integration-testing.md b/uts/docs/integration-testing.md index fa7fd0a6..cca26a71 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... ] ) diff --git a/uts/docs/writing-test-specs.md b/uts/docs/writing-test-specs.md index 1102181a..4abce657 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/integration/proxy/objects_faults.md b/uts/objects/integration/proxy/objects_faults.md index 24069b73..8988a019 100644 --- a/uts/objects/integration/proxy/objects_faults.md +++ b/uts/objects/integration/proxy/objects_faults.md @@ -80,7 +80,7 @@ channel_name = "objects-sync-interrupt-" + random_id() // Disconnect after first OBJECT_SYNC frame session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": 20 }, "action": { "type": "disconnect" }, @@ -163,7 +163,7 @@ AWAIT root_a.set("key1", "initial") // Client B: through proxy, will be disconnected session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -242,7 +242,7 @@ channel_name = "objects-detach-resync-" + random_id() session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -318,7 +318,7 @@ channel_name = "objects-publish-failed-" + random_id() session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -403,7 +403,7 @@ AWAIT root_a.set("existing", "before") // Client B: through proxy with delayed OBJECT_SYNC session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": 20 }, "action": { "type": "delay", "delayMs": 3000 }, diff --git a/uts/realtime/integration/helpers/proxy.md b/uts/realtime/integration/helpers/proxy.md index 18274311..303d8ea7 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 diff --git a/uts/realtime/integration/proxy/auth_reauth.md b/uts/realtime/integration/proxy/auth_reauth.md index 6d704c96..edb4d989 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 1035d677..35b2da0f 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 44b65ae7..0c79673d 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 3c908366..1621137a 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 8f6e1e2b..e213436b 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 7fdeb459..e93ce3cf 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 bd3b1323..51ffc526 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": {