From e38b889958363a6140bdd395e8b27ed28ab49df1 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 26 Jun 2026 02:26:09 +0530 Subject: [PATCH 1/4] Generated unit tests using `uts-to-kotlin` skill --- .../test/kotlin/io/ably/lib/uts/deviations.md | 201 +++ .../lib/uts/unit/liveobjects/InstanceTest.kt | 362 ++++++ .../unit/liveobjects/LiveCounterApiTest.kt | 197 +++ .../uts/unit/liveobjects/LiveMapApiTest.kt | 289 +++++ .../liveobjects/LiveObjectSubscribeTest.kt | 258 ++++ .../liveobjects/PathObjectMutationsTest.kt | 200 +++ .../liveobjects/PathObjectSubscribeTest.kt | 543 ++++++++ .../uts/unit/liveobjects/PathObjectTest.kt | 454 +++++++ .../liveobjects/PublicObjectMessageTest.kt | 367 ++++++ .../unit/liveobjects/RealtimeObjectTest.kt | 1091 +++++++++++++++++ .../uts/unit/liveobjects/ValueTypesTest.kt | 339 +++++ 11 files changed, 4301 insertions(+) create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/InstanceTest.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveCounterApiTest.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveMapApiTest.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveObjectSubscribeTest.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectMutationsTest.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectSubscribeTest.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectTest.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PublicObjectMessageTest.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/RealtimeObjectTest.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/ValueTypesTest.kt diff --git a/uts/src/test/kotlin/io/ably/lib/uts/deviations.md b/uts/src/test/kotlin/io/ably/lib/uts/deviations.md index d0cc49408..8e5a3dad3 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/deviations.md +++ b/uts/src/test/kotlin/io/ably/lib/uts/deviations.md @@ -49,3 +49,204 @@ Deviations from the Ably spec identified during UTS test translation. Each entry **Workaround in tests:** The spec-correct assertion (`assertEquals(42L, msgSerial)`) is gated behind `RUN_DEVIATIONS`. A regression guard assertion (`assertEquals(0L, msgSerial)`) runs by default to catch any unintentional change to the SDK's actual behaviour. **Tests affected:** - `RTN16f - recover option initializes msgSerial from recoveryKey` (RTN16f/recover-initializes-msgserial-0) — `assertEquals(42L, ...)` gated; `assertEquals(0L, ...)` added as regression guard. + +--- + +## RTINS12d / RTINS14d / RTINS16c — wrong-type Instance operation throws IllegalStateException, not ErrorInfo 92007 + +**Spec points:** RTINS12d, RTINS14d, RTINS16c +**What the spec requires:** Calling a type-specific operation on an `Instance` wrapping the wrong type fails with `ErrorInfo` code `92007` — `set`/`remove` on a non-LiveMap, `increment`/`decrement` on a non-LiveCounter, `subscribe` on a primitive. +**What the SDK does:** ably-java implements the typed-SDK variant (RTTS): those operations do not exist on the base `Instance` or on the mismatched typed view, so the wrong operation cannot be called at all. The type check happens at the `as*` cast, which fails fast with a plain `IllegalStateException` ("Not a LiveMap/LiveCounter instance") carrying no Ably error code (`DefaultInstance`, RTTS9d). There is no `92007` `AblyException` / `ErrorInfo`. +**Workaround in tests:** Assert `assertFailsWith { … }` on the relevant `asLiveMap()` / `asLiveCounter()` cast instead of an `ErrorInfo` code `92007`. +**Tests affected (InstanceTest.kt):** +- `RTINS12d - set on non-LiveMap throws` (RTINS12d/set-non-map-throws-0) +- `RTINS14d - increment on non-LiveCounter throws` (RTINS14d/increment-non-counter-throws-0) +- `RTINS16c - subscribe on primitive throws` (RTINS16c/subscribe-primitive-throws-0) + +--- + +## RTINS4d / RTINS9c — `value()` on a LiveMap / `size()` on a LiveCounter are not expressible + +**Spec points:** RTINS4d, RTINS9c +**What the spec requires:** The polymorphic `Instance#value()` returns `null` for a LiveMap, and `Instance#size()` returns `null` for a non-LiveMap. +**What the SDK does:** Under the typed-SDK variant (RTTS10) these accessors are partitioned: `value()` exists only on `LiveCounterInstance` / primitive instances, and `size()` only on `LiveMapInstance`. A `LiveMapInstance` has no `value()` and a `LiveCounterInstance` has no `size()`, so the "wrong-type returns null" assertions cannot be written (and the cast to the other view would throw — see above). +**Workaround in tests:** The expressible half of each test is translated (counter `value()`, map `size()`); the not-expressible sub-assertion is dropped with an inline note. +**Tests affected (InstanceTest.kt):** +- `RTINS4 - value returns counter number or primitive` (RTINS4/value-counter-0) — map `value() == null` omitted. +- `RTINS9 - size returns non-tombstoned count` (RTINS9/size-0) — counter `size() == null` omitted. + +--- + +## RTINS10 — `compact()` not implemented; `compactJson()` used instead + +**Spec point:** RTINS10 +**What the spec requires:** `Instance#compact()` returns a recursively-compacted native snapshot (plain map/number/string), e.g. `result["score"] == 100`. +**What the SDK does:** ably-java does not implement `compact()` (RTTS7d — typed SDKs need not). Only `compactJson()` is provided, returning a Gson `JsonObject`/`JsonElement` tree. +**Workaround in tests:** The test calls `compactJson()` and navigates the resulting `JsonObject` (`snapshot.get("score").asInt`, etc.). +**Tests affected (InstanceTest.kt):** +- `RTINS10 - compact recursively compacts` (RTINS10/compact-0) + +--- + +## RTLO4b4c1 — `LiveObjectUpdate.noop` flag not exposed on the public subscription event + +**Spec point:** RTLO4b4c1 +**What the spec requires:** When an applied operation produces a no-op `LiveObjectUpdate` (e.g. a `COUNTER_INC` of 0 that still passes the RTLO4a6 newness check), registered listeners must not be invoked — the spec frames this in terms of the internal `LiveObjectUpdate.noop` flag. +**What the SDK does:** ably-java subscribes through the public `instance.subscribe(...)` and delivers an `InstanceSubscriptionEvent`, which exposes only `getObject()` / `getMessage()` (mapping §8). There is no public `noop` accessor on the event, and the internal `LiveObjectUpdate` diff is not surfaced. The noop is therefore observable only as *suppressed delivery*: the listener is simply not fired for the no-op operation. +**Workaround in tests:** Send the real update first (listener fires once), then the no-op `COUNTER_INC(0)`, and assert the listener count stays at 1 — i.e. the noop is asserted via the absence of a second event rather than via a `noop` boolean. +**Tests affected (LiveObjectSubscribeTest.kt):** +- `RTLO4b4c1 - noop update does not trigger listener` (RTLO4b4c1/noop-no-trigger-0) + +## RTPO5b / RTPO6b — `get(non-string)` / `at(non-string)` failing with 40003 is not expressible + +**Spec points:** RTPO5b, RTPO6b +**What the spec requires:** Calling `PathObject.get(key)` / `LiveMap.at(path)` with a non-String argument fails at runtime with `ErrorInfo` code `40003`. +**What the SDK does:** ably-java is statically typed — `LiveMapPathObject.get(@NotNull String)` and `LiveMapPathObject.at(@NotNull String)` only accept a `String`. A non-string argument is a compile error, never a runtime failure, so there is no code path that returns a `40003` `AblyException` for this input. +**Workaround in tests:** The case is not expressible; the test body documents the omission with an inline note and contains no executable assertion. +**Tests affected (PathObjectTest.kt):** +- `RTPO5b - get throws on non-string key` (RTPO5b/get-non-string-throws-0) +- `RTPO6b - at throws for non-string input` (RTPO6b/at-non-string-throws-0) + +--- + +## RTPO13 / RTPO13b5 / RTPO13c / RTPO3c1 — `compact()` not implemented; `compactJson()` used instead + +**Spec points:** RTPO13, RTPO13b5, RTPO13c, RTPO3c1 +**What the spec requires:** `PathObject#compact()` returns a recursively-compacted native snapshot — plain map/number/string/boolean/bytes values, nested LiveMaps recursed, nested LiveCounters resolved to numbers, raw binary preserved as bytes, and cyclic references reused as the same in-memory object (`result["prefs"]["back_ref"] IS result`). `compact()` returns `null` on resolution failure. +**What the SDK does:** ably-java does not implement `compact()` (RTTS3f — typed SDKs need not). Only `compactJson(): JsonElement?` is provided, returning a Gson tree: binary values are base64-encoded strings (not raw bytes) and cyclic references are emitted as `{ "objectId": ... }` markers (not shared object identity). It returns `null` on resolution failure. +**Workaround in tests:** Each test calls `compactJson()` and navigates the resulting `JsonElement`/`JsonObject`. The binary entry is asserted as its base64 string (`"AQID"`); the cycle is asserted as the `{ "objectId": "map:profile@1000" }` marker instead of object identity; the LiveCounter compacts to its numeric JSON value; the resolution-failure case asserts `compactJson() == null`. +**Tests affected (PathObjectTest.kt):** +- `RTPO13 - compact recursively compacts LiveMap tree` (RTPO13/compact-recursive-0) — base64 for binary. +- `RTPO13b5 - compact handles cycles via shared reference` (RTPO13b5/compact-cycle-detection-0) — objectId marker instead of identity. +- `RTPO13c - compact returns number for LiveCounter` (RTPO13c/compact-counter-0). +- `RTPO3c1 - read operation returns null on resolution failure` (RTPO3c1/read-null-on-failure-0) — `compact()` sub-assertion uses `compactJson()`. + +--- + +## RTLM20 / RTLM21 — set/remove wire-message-shape assertions are internal; assert observable local effect + +**Spec points:** RTLM20e2, RTLM20e3, RTLM20e6, RTLM20e7b, RTLM20e7c, RTLM20e7d, RTLM20e7e, RTLM20e7f, RTLM20h2, RTLM21e2, RTLM21e5 +**What the spec requires:** `set` / `remove` send an OBJECT ProtocolMessage whose captured wire form is asserted directly — `captured_messages[0].state[0].operation.action == "MAP_SET" / "MAP_REMOVE"`, `operation.objectId == "root"`, `mapSet.key`, `mapSet.value.string / .number / .boolean / .json / .bytes` (base64), `mapRemove.key`. +**What the SDK does:** ably-java's public `LiveMapPathObject.set` / `remove` return a `CompletableFuture`; the bytes that go on the wire are internal `WireObjectMessage` objects in `ProtocolMessage.state` (`Object[]`), inaccessible through the public API (mapping §13). The public-observable consequence is that, once the operation is ACKed and echoed, it applies to the local graph. +**Workaround in tests:** Perform the public write, then assert the equivalent observable effect via a local round-trip read after the auto-ACK echo applies (`root.get(key).as().value()` for set, `getType() == null` for remove), polling for application. The exact wire-message shape is not asserted. +**Tests affected (LiveMapApiTest.kt):** +- `RTLM20 - set sends MAP_SET message` (RTLM20/set-sends-map-set-0) +- `RTLM20 - set with different value types` (RTLM20/set-value-types-0) +- `RTLM20 - set with bytes value type` (RTLM20/set-bytes-value-0) +- `RTLM21 - remove sends MAP_REMOVE message` (RTLM21/remove-sends-map-remove-0) + +--- + +## RTLM20e7g / RTLM20h1 — value-type CREATE-message generation/ordering is internal; assert resolved object + +**Spec points:** RTLM20e7g1, RTLM20e7g2, RTLM20h1, RTLMV4d1, RTLMV4d2 (also RTLCV4 / RTLMV4 evaluation) +**What the spec requires:** Setting a `LiveCounter` / `LiveMap` value type produces an OBJECT whose `state` array contains the generated `*_CREATE` ObjectMessages followed by a `MAP_SET`, in depth-first order, with the `MAP_SET`'s `mapSet.value.objectId` referencing the final CREATE's `objectId` (and `objectId` prefixes `counter:` / `map:`). +**What the SDK does:** The evaluation of a value type into an ordered list of `*_CREATE` wire messages, nonce/objectId derivation, and the cross-referencing objectIds are all internal wire-level concerns (mapping §13) — not reachable through the public typed API. The public-observable consequence is that the new nested object is created and resolvable at the key. +**Workaround in tests:** Perform the public write, then assert the equivalent observable effect: the new value resolves to a `LIVE_COUNTER` / `LIVE_MAP` with its initial value/entries (and, for the nested case, the nested counter and primitive resolve). The CREATE-message count, ordering, and objectId cross-references are not asserted. +**Tests affected (LiveMapApiTest.kt):** +- `RTLM20e7g - set with LiveCounterValueType generates COUNTER_CREATE plus MAP_SET` (RTLM20e7g/set-counter-value-type-0) +- `RTLM20e7g - set with LiveMapValueType generates nested CREATE plus MAP_SET` (RTLM20e7g/set-map-value-type-0) +- `RTLM20h1 - set with nested LiveMapValueType containing LiveCounterValueType` (RTLM20h1/set-nested-value-types-0) + +--- + +## RTLM20 / RTLMV4c — invalid set value types (function / undefined / symbol) not expressible + +**Spec points:** RTLM20e1, RTLMV4c +**What the spec requires:** A table-driven test feeds unsupported runtime values (a function, `undefined`, a symbol) into `set` and expects each to fail with `ErrorInfo` code `40013`. +**What the SDK does:** ably-java's `LiveMapPathObject.set(String, LiveMapValue)` accepts only a `LiveMapValue`, and `LiveMapValue.of(...)` is overloaded solely for the supported types (Boolean, Binary/byte[], Number, String, JsonArray, JsonObject, LiveCounter, LiveMap). There is no overload that accepts a function / undefined / symbol, so these inputs are rejected at compile time (mapping §6) — the runtime `40013` assertion cannot be expressed. +**Workaround in tests:** The test body is a documented no-op explaining the compile-time rejection; no runtime assertion is made. +**Tests affected (LiveMapApiTest.kt):** +- `RTLM20 - invalid set value types` (RTLM20/set-invalid-values-table-0) + +--- + +## RTLC12e2 / RTLC12e3 / RTLC12e5 / RTLC13b — outbound COUNTER_INC wire message is internal + +**Spec points:** RTLC12e2, RTLC12e3, RTLC12e5, RTLC13b +**What the spec requires:** After `increment(n)` / `decrement(n)`, inspect the published OBJECT message's wire form — `captured.state[0].operation.action == "COUNTER_INC"`, `.operation.objectId == "counter:score@1000"`, `.operation.counterInc.number == n` (and `== -15` for decrement, proving decrement negates the amount). +**What the SDK does:** The outbound wire types (`WireObjectMessage` / `WireObjectOperation` / `WireCounterInc`) are `internal` to `:liveobjects` and not part of the public API; there is no public accessor for the message a `LiveCounterPathObject.increment` / `.decrement` publishes. +**Workaround in tests:** The captured outbound `ProtocolMessage` is found in `mockWs.events` (`MessageFromClient` with `action == object`), and its `state[0]` wire object's `operation` / `action` / `objectId` / `counterInc.number` are read by reflection (the same reflection technique `helpers.kt` and `PublicObjectMessageTest.kt` use for internal `:liveobjects` types). Where the spec also provides an observable value outcome (decrement → `value() == 85`), that is asserted directly via the public API. +**Tests affected (LiveCounterApiTest.kt):** +- `RTLC12 - increment sends v6 COUNTER_INC message` (RTLC12/increment-sends-counter-inc-0) +- `RTLC13 - decrement delegates to increment with negated amount` (RTLC13/decrement-negates-0) + +--- + +## RTLC11b1 — LiveCounterUpdate diff (`update.amount`) not exposed on public event + +**Spec point:** RTLC11b1 +**What the spec requires:** Subscribing to a counter `instance` and incrementing it emits an event whose `message.operation.counterInc.number` (the increment amount) equals the applied value (`updates[0].message.operation.counterInc.number == 7`). +**What the SDK does:** ably-java's public `InstanceSubscriptionEvent` carries no internal `LiveCounterUpdate` diff (no `update.amount` accessor — that is the internal RTLO4b update). It does expose the originating public `ObjectMessage` via `getMessage()`, whose `operation.counterInc.number` carries the amount. +**Workaround in tests:** Assert `event.getMessage().operation.counterInc.number == 7.0` (and `operation.action == COUNTER_INC`) instead of an `update.amount` diff field. +**Tests affected (LiveCounterApiTest.kt):** +- `RTLC11 - LiveCounterUpdate emitted on increment` (RTLC11/counter-update-on-inc-0) + +--- + +## RTLC12e1 — non-Number increment amounts are compile errors, not 40003 runtime failures + +**Spec point:** RTLC12e1 +**What the spec requires:** `increment(amount)` throws `ErrorInfo` code `40003` when `amount` is `null`, not a Number, not finite, or NaN — exercised both singly (`increment("not_a_number")`) and as a table (`null`, `NaN`, `±Infinity`, `"10"`, `true`, `[1,2]`, `{n:1}`). +**What the SDK does:** ably-java's `LiveCounterPathObject.increment(@NotNull Number)` accepts only a non-null `Number`. The non-Number rows (`null`, String, Boolean, array, object) are rejected by the type system at compile time, so they cannot be written as runtime assertions. The numeric-but-invalid rows (`NaN`, `+Infinity`, `-Infinity`) are valid `Double` values and remain expressible runtime `40003` assertions. +**Workaround in tests:** The non-Number cases are dropped with an inline note; the non-finite `Double` cases (`NaN`, `±Infinity`) are exercised and asserted to fail with `40003`. The dedicated single-case `increment-non-number-0` test is reduced to a documented placeholder for the same reason. +**Tests affected (LiveCounterApiTest.kt):** +- `RTLC12e1 - increment with non-number throws` (RTLC12e1/increment-non-number-0) — not expressible; placeholder. +- `RTLC12e1 - Table-driven invalid increment amounts` (RTLC12e1/increment-invalid-amounts-table-0) — non-Number rows dropped; non-finite rows exercised. + +--- + +## RTO15 — `RealtimeObject.publish` and its OBJECT/ACK wire-message assertions are internal, not public + +**Spec point:** RTO15 (RTO15e1, RTO15e2, RTO15e3, RTO15h) +**What the spec requires:** `channel.object.publish([objectMessages])` sends an OBJECT `ProtocolMessage` whose captured wire form is asserted directly — `captured_messages[0].action == OBJECT`, `.channel == "test"`, `.state.length == 1` — and returns a `PublishResult` from the ACK whose `serials == ["serial-0"]`. +**What the SDK does:** ably-java's `RealtimeObject` exposes no public `publish` method — `publish` / `publishAndApply` (RTO15 / RTO20) are marked `internal` in the IDL (mapping §13). The only public mutators are the typed `set` / `remove` / `increment` / `decrement` on the path/instance views, which return `CompletableFuture` (no `PublishResult`). The OBJECT `ProtocolMessage.state` entries are internal `WireObjectMessage` objects (`Object[]`), and the ACK `PublishResult` is consumed internally to drive the local apply — neither is reachable through the public API. +**Workaround in tests:** None expressible against the public surface. The publish-and-apply *effect* (RTO20) is covered observably elsewhere in this file (e.g. `RTO20 - publishAndApply applies locally on ACK` asserts `value() == 110` after a public `increment`). The RTO15 test body is a documented no-op. +**Tests affected (RealtimeObjectTest.kt):** +- `RTO15 - publish sends OBJECT ProtocolMessage` (RTO15/publish-sends-object-pm-0) — not expressible; documented no-op. + +--- + +## RTLCV3 / RTLMV3 — value-type internal count / entries have no public accessor + +**Spec points:** RTLCV3b, RTLCV3a1, RTLMV3b, RTLMV3a1 +**What the spec requires:** A constructed value type exposes its internal blueprint state for inspection — `LiveCounter.create(42).count == 42`, `LiveCounter.create().count == 0`, `LiveMap.create({...}).entries["name"] == "Alice"`. +**What the SDK does:** ably-java's `LiveCounter` / `LiveMap` value types are opaque immutable holders: the initial count / entries are "held internally by the implementation; [they have] no public accessor" (their Javadoc). Only the static `create(...)` factory and the abstract type identity are observable; there is no `count` / `entries` getter. +**Workaround in tests:** Assert construction succeeds and the result `is LiveCounter` / `is LiveMap` (the value-type identity). The internal `count` / `entries` sub-assertions are dropped with an inline note. +**Tests affected (ValueTypesTest.kt):** +- `RTLCV3 - LiveCounter create with initial count` (RTLCV3/create-with-count-0) — `vt.count == 42` omitted. +- `RTLCV3 - LiveCounter create defaults to 0` (RTLCV3/create-default-zero-0) — `vt.count == 0` omitted. +- `RTLMV3 - LiveMap create with entries` (RTLMV3/create-with-entries-0) — `vt.entries[...]` omitted. + +--- + +## RTLCV4 / RTLMV4 — value-type `evaluate()` ObjectMessage generation is internal/wire-level + +**Spec points:** RTLCV4 (RTLCV4b1, RTLCV4c, RTLCV4d, RTLCV4f, RTLCV4g1–g5), RTLMV4 (RTLMV4e1, RTLMV4f, RTLMV4g, RTLMV4i, RTLMV4j1–j5, RTLMV4d1, RTLMV4d2, RTLMV4k, RTLMV4e2) +**What the spec requires:** Calling `evaluate(vt)` on a value type returns the list of generated `ObjectMessage`s and asserts on their internal/wire form — `operation.action == "COUNTER_CREATE"/"MAP_CREATE"`, `operation.objectId` `counter:`/`map:` prefix and `@`-suffixed RTO14 derivation, `counterCreateWithObjectId`/`mapCreateWithObjectId` with a 16+-char `nonce` and a JSON `initialValue`, the retained local `counterCreate`/`mapCreate` (`count == 42`, `semantics == "LWW"`, `entries[k].data.`), depth-first ordering of nested creates with cross-referencing `entries[k].data.objectId`, and `mapCreate.entries == {}` for empty entries. +**What the SDK does:** There is no public `evaluate` on the value types. Evaluation into an ordered list of `*_CREATE` wire messages, nonce / `initialValue` / `objectId` derivation, and the `counterCreateWithObjectId` / `mapCreateWithObjectId` wire forms are all internal/wire-level concerns (mapping §13), not reachable through the public typed API. (`PublicAPI::ObjectOperation` itself carries only the resolved `mapCreate`/`counterCreate`, never a `*WithObjectId` getter, per PAOOP1.) +**Workaround in tests:** Only the public construction is exercised (`create(...)` returns a `LiveCounter`/`LiveMap`). The message-generation, nonce/objectId, `initialValue`, retained-create, ordering and empty-entries assertions are dropped with inline notes. +**Tests affected (ValueTypesTest.kt):** +- `RTLCV4 - Evaluation generates COUNTER_CREATE ObjectMessage` (RTLCV4/evaluate-generates-message-0) +- `RTLCV4g5 - Evaluation retains local CounterCreate` (RTLCV4g5/retains-local-counter-create-0) +- `RTLCV4 - Evaluation with count 0` (RTLCV4/evaluate-zero-count-0) +- `RTLMV4 - Evaluation generates MAP_CREATE ObjectMessage` (RTLMV4/evaluate-generates-message-0) +- `RTLMV4j5 - Evaluation retains local MapCreate` (RTLMV4j5/retains-local-map-create-0) +- `RTLMV4d1, RTLMV4d2 - Nested value types produce depth-first ObjectMessages` (RTLMV4d1/nested-value-types-0) +- `RTLMV4e2 - Empty entries produces MapCreate with empty entries` (RTLMV4e2/empty-entries-0) +- `RTLMV4d - Entry value type mapping` (RTLMV4d/entry-value-types-0) — generated `data.` adapted to public `LiveMapValue` union inspection. +- `RTLMV4d - Table-driven MAP_SET value type mapping` (RTLMV4d/map-set-all-types-table-0) — generated `data[field]` (incl. base64 "AQID") adapted to public `LiveMapValue` union inspection. + +--- + +## RTLCV4a / RTLMV4a / RTLMV4b / RTLMV4c — wrong-typed value-type `create` args are compile errors, not runtime 40003/40013 + +**Spec points:** RTLCV4a, RTLMV4a, RTLMV4b, RTLMV4c +**What the spec requires:** Validation deferred to evaluation: `LiveCounter.create("not_a_number")` → 40003; `LiveMap.create(null)` → 40003; a non-String key (`{ 123: "value" }`) → 40003; an unsupported value (a function) → 40013. +**What the SDK does:** ably-java's signatures reject all of these at compile time (mapping §6): `LiveCounter.create(@NotNull Number)` rejects a String and rejects null; `LiveMap.create(@NotNull Map)` rejects null, enforces String keys, and the `LiveMapValue` union constructs only from the supported types (Boolean, byte[], Number, String, JsonArray, JsonObject, LiveCounter, LiveMap) — an unsupported value cannot be wrapped. So none of these inputs can be written, and the runtime 40003/40013 failures are not expressible. +**Workaround in tests:** Each test body is a documented no-op explaining the compile-time rejection; no runtime assertion is made. +**Tests affected (ValueTypesTest.kt):** +- `RTLCV4a - Evaluation validates count type` (RTLCV4a/evaluate-validates-count-0) +- `RTLMV4a - Evaluation validates entries type` (RTLMV4a/evaluate-validates-entries-0) +- `RTLMV4b - Evaluation validates key types` (RTLMV4b/evaluate-validates-keys-0) +- `RTLMV4c - Evaluation validates value types` (RTLMV4c/evaluate-validates-values-0) diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/InstanceTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/InstanceTest.kt new file mode 100644 index 000000000..c0305305a --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/InstanceTest.kt @@ -0,0 +1,362 @@ +package io.ably.lib.uts.unit.liveobjects + +import io.ably.lib.liveobjects.Subscription +import io.ably.lib.liveobjects.instance.Instance +import io.ably.lib.liveobjects.instance.InstanceListener +import io.ably.lib.liveobjects.instance.InstanceSubscriptionEvent +import io.ably.lib.liveobjects.message.ObjectOperationAction +import io.ably.lib.liveobjects.value.LiveMapValue +import io.ably.lib.uts.infra.pollUntil +import kotlinx.coroutines.future.await +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Derived from UTS `objects/unit/instance.md` (RTINS1–RTINS16) — the typed `Instance` view of a resolved + * LiveObject / primitive. + * + * ably-java implements the typed-SDK variant (RTTS), so the spec's single polymorphic `Instance` is + * partitioned: `id`, `value`, `get`, `set`, `subscribe`, … live on `LiveMapInstance` / + * `LiveCounterInstance` / the primitive instances, reached through `as*` casts. Unlike `PathObject`, an + * `Instance` cast **fails fast with `IllegalStateException`** on a type mismatch (RTTS9d). Three + * consequences for translation, recorded in `deviations.md`: + * - "wrong-type write/subscribe → ErrorInfo 92007" (RTINS12d/14d/16c) surfaces instead as the `as*` cast + * throwing `IllegalStateException` — there is no typed view on which to even call the wrong method. + * - `value()` on a map / `size()` on a counter (RTINS4d/RTINS9c) are not expressible — those accessors are + * partitioned off the wrong-typed view. + * - `compact()` is not implemented (RTTS7d); `compactJson()` is the supported snapshot (RTINS10). + * + * All tests use `setupSyncedChannel` (helpers.kt), which needs the SDK's OBJECT_SYNC processing + + * `RealtimeObject.get()` — still TODO — so these compile now and run once that lands (translate-only). + */ +class InstanceTest { + + /** + * @UTS objects/unit/RTINS3/id-returns-objectid-0 + */ + @Test + fun `RTINS3 - id property returns objectId`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val counterInst = root.get("score").instance() + assertEquals("counter:score@1000", counterInst!!.asLiveCounter().id) + + val mapInst = root.get("profile").instance() + assertEquals("map:profile@1000", mapInst!!.asLiveMap().id) + } + + /** + * @UTS objects/unit/RTINS4/value-counter-0 + */ + @Test + fun `RTINS4 - value returns counter number or primitive`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val counterInst = root.get("score").instance() + assertEquals(100.0, counterInst!!.asLiveCounter().value()) + + // DEVIATION (RTINS4d): spec asserts `map_inst.value() == null`, but ably-java's typed + // LiveMapInstance has no value() accessor (partitioned per RTTS10) — "value() on a map" is not + // expressible. See deviations.md. + } + + /** + * @UTS objects/unit/RTINS5/get-wraps-entry-0 + */ + @Test + fun `RTINS5 - get returns Instance wrapping entry value`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + val rootInst = root.instance()!!.asLiveMap() + + val nameInst = rootInst.get("name") + assertNotNull(nameInst) // RTINS5c: IS Instance + assertEquals("Alice", nameInst!!.asString().value()) + + val scoreInst = rootInst.get("score") + assertEquals("counter:score@1000", scoreInst!!.asLiveCounter().id) + + val nullInst = rootInst.get("nonexistent") + assertNull(nullInst) + } + + /** + * @UTS objects/unit/RTINS6/entries-yields-instances-0 + */ + @Test + fun `RTINS6 - entries returns key Instance pairs`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + val rootInst = root.instance()!!.asLiveMap() + + val entries = mutableMapOf() + for ((key, inst) in rootInst.entries()) { + entries[key] = inst + } + + assertEquals(7, entries.size) + assertNotNull(entries["name"]) // IS Instance + assertEquals("Alice", entries["name"]!!.asString().value()) + } + + /** + * @UTS objects/unit/RTINS9/size-0 + */ + @Test + fun `RTINS9 - size returns non-tombstoned count`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val rootInst = root.instance()!!.asLiveMap() + assertEquals(7L, rootInst.size()) + + // DEVIATION (RTINS9c): spec asserts `counter_inst.size() == null`, but ably-java's typed + // LiveCounterInstance has no size() accessor (partitioned per RTTS10) — not expressible. + // See deviations.md. + } + + /** + * @UTS objects/unit/RTINS10/compact-0 + */ + @Test + fun `RTINS10 - compact recursively compacts`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + val rootInst = root.instance()!!.asLiveMap() + + // DEVIATION (RTINS10): ably-java does not implement `compact()` (RTTS7d); `compactJson()` is the + // supported recursively-compacted snapshot. Assertions navigate the JsonObject. See deviations.md. + val snapshot = rootInst.compactJson() + + assertEquals("Alice", snapshot.get("name").asString) + assertEquals(100, snapshot.get("score").asInt) + assertEquals("alice@example.com", snapshot.getAsJsonObject("profile").get("email").asString) + } + + /** + * @UTS objects/unit/RTINS12/set-delegates-0 + */ + @Test + fun `RTINS12 - set delegates to LiveMap set`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + val rootInst = root.instance()!!.asLiveMap() + + rootInst.set("name", LiveMapValue.of("Bob")).await() + + assertEquals("Bob", root.get("name").asString().value()) + } + + /** + * @UTS objects/unit/RTINS12d/set-non-map-throws-0 + */ + @Test + fun `RTINS12d - set on non-LiveMap throws`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + val counterInst = root.get("score").instance() + + // DEVIATION (RTINS12d): spec expects `set()` to fail with ErrorInfo 92007. ably-java has no `set` + // on a non-map typed view; the failure surfaces as the `asLiveMap()` cast throwing + // IllegalStateException (RTTS9d). See deviations.md. + assertFailsWith { counterInst!!.asLiveMap() } + } + + /** + * @UTS objects/unit/RTINS13/remove-delegates-0 + */ + @Test + fun `RTINS13 - remove delegates to LiveMap remove`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + val rootInst = root.instance()!!.asLiveMap() + + rootInst.remove("name").await() + + assertNull(root.get("name").asString().value()) + } + + /** + * @UTS objects/unit/RTINS14/increment-delegates-0 + */ + @Test + fun `RTINS14 - increment delegates to LiveCounter increment`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + val counterInst = root.get("score").instance()!!.asLiveCounter() + + counterInst.increment(25).await() + + assertEquals(125.0, root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTINS14d/increment-non-counter-throws-0 + */ + @Test + fun `RTINS14d - increment on non-LiveCounter throws`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + val mapInst = root.instance() + + // DEVIATION (RTINS14d): spec expects ErrorInfo 92007; ably-java has no `increment` on a non-counter + // typed view, so the failure surfaces as `asLiveCounter()` throwing IllegalStateException (RTTS9d). + // See deviations.md. + assertFailsWith { mapInst!!.asLiveCounter() } + } + + /** + * @UTS objects/unit/RTINS15/decrement-delegates-0 + */ + @Test + fun `RTINS15 - decrement delegates to LiveCounter decrement`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + val counterInst = root.get("score").instance()!!.asLiveCounter() + + counterInst.decrement(10).await() + + assertEquals(90.0, root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTINS14a/increment-default-0 + */ + @Test + fun `RTINS14a - increment defaults to 1`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + val counterInst = root.get("score").instance()!!.asLiveCounter() + + counterInst.increment().await() + + assertEquals(101.0, root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTINS15a/decrement-default-0 + */ + @Test + fun `RTINS15a - decrement defaults to 1`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + val counterInst = root.get("score").instance()!!.asLiveCounter() + + counterInst.decrement().await() + + assertEquals(99.0, root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTINS16/subscribe-receives-events-0 + */ + @Test + fun `RTINS16 - subscribe receives InstanceSubscriptionEvent`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val counterInst = root.get("score").instance()!!.asLiveCounter() + val events = mutableListOf() + val sub: Subscription = counterInst.subscribe(InstanceListener { events.add(it) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), + ) + pollUntil(5.seconds) { events.size >= 1 } + + assertNotNull(sub) // IS Subscription + assertEquals(1, events.size) + assertNotNull(events[0].getObject()) // IS Instance + assertEquals("counter:score@1000", events[0].getObject().asLiveCounter().id) + } + + /** + * @UTS objects/unit/RTINS16c/subscribe-primitive-throws-0 + */ + @Test + fun `RTINS16c - subscribe on primitive throws`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + val nameInst = root.instance()!!.asLiveMap().get("name") + + // DEVIATION (RTINS16c): spec expects ErrorInfo 92007. ably-java's primitive instances expose no + // `subscribe`; obtaining a subscribable (map/counter) view of a primitive fails fast with + // IllegalStateException (RTTS9d). See deviations.md. + assertFailsWith { nameInst!!.asLiveMap() } + } + + /** + * @UTS objects/unit/RTINS16e2/subscription-event-message-0 + */ + @Test + fun `RTINS16e2 - InstanceSubscriptionEvent contains ObjectMessage`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val rootInst = root.instance()!!.asLiveMap() + val events = mutableListOf() + rootInst.subscribe(InstanceListener { events.add(it) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), "99", "remote"))), + ) + pollUntil(5.seconds) { events.size >= 1 } + + val event = events[0] + assertNotNull(event.getObject()) // IS Instance + assertEquals("root", event.getObject().asLiveMap().id) + val message = event.getMessage() + assertNotNull(message) + assertEquals("test", message!!.channel) + assertEquals(ObjectOperationAction.MAP_SET, message.operation.action) + assertEquals("root", message.operation.objectId) + assertEquals("name", message.operation.mapSet!!.key) + } + + /** + * @UTS objects/unit/RTINS16f/subscribe-returns-subscription-0 + */ + @Test + fun `RTINS16f - subscribe returns Subscription for deregistration`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val counterInst = root.get("score").instance()!!.asLiveCounter() + val events = mutableListOf() + val sub = counterInst.subscribe(InstanceListener { events.add(it) }) + sub.unsubscribe() + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), + ) + + assertEquals(0, events.size) + } + + /** + * @UTS objects/unit/RTINS16g/subscription-follows-identity-0 + */ + @Test + fun `RTINS16g - Instance subscription follows identity not path`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val counterInst = root.get("score").instance()!!.asLiveCounter() + val events = mutableListOf() + counterInst.subscribe(InstanceListener { events.add(it) }) + + // Re-point root.score at a different counter, then increment the original counter by identity. + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf(buildMapSet("root", "score", dataObjectId("counter:new@2000"), "99", "remote")), + ), + ) + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, "100", "remote"))), + ) + pollUntil(5.seconds) { events.size >= 1 } + + assertTrue(events.size >= 1) + assertEquals("counter:score@1000", counterInst.id) + } + + /** + * @UTS objects/unit/RTINS16h/subscribe-no-side-effects-0 + */ + @Test + fun `RTINS16h - subscribe has no side effects`() = runTest { + val (_, channel, root, _) = setupSyncedChannel("test") + val counterInst = root.get("score").instance()!!.asLiveCounter() + val channelStateBefore = channel.state + + counterInst.subscribe(InstanceListener { }) + + assertEquals(channelStateBefore, channel.state) + } +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveCounterApiTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveCounterApiTest.kt new file mode 100644 index 000000000..c5fd1b3bd --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveCounterApiTest.kt @@ -0,0 +1,197 @@ +package io.ably.lib.uts.unit.liveobjects + +import io.ably.lib.liveobjects.instance.InstanceListener +import io.ably.lib.liveobjects.instance.InstanceSubscriptionEvent +import io.ably.lib.liveobjects.message.ObjectOperationAction +import io.ably.lib.types.AblyException +import io.ably.lib.types.ProtocolMessage +import io.ably.lib.uts.infra.pollUntil +import io.ably.lib.uts.infra.unit.MockEvent +import io.ably.lib.uts.infra.unit.MockWebSocket +import kotlinx.coroutines.future.await +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.time.Duration.Companion.seconds + +/** + * Derived from UTS `objects/unit/live_counter_api.md` (RTLC5, RTLC11–RTLC13) — the **public** read/write + * surface of a LiveCounter via `PathObject` / `Instance`. + * + * ably-java implements the typed-SDK variant (RTTS): the spec's polymorphic `root.get("score")` is a base + * `PathObject`; counter reads/writes live on `LiveCounterPathObject`, reached via `asLiveCounter()`. So + * `counter.value()` → `root.get("score").asLiveCounter().value(): Double?` (assert `100.0`), and + * `counter.increment(n)` / `.decrement(n)` → `…asLiveCounter().increment(n)` returning + * `CompletableFuture` (`.await()`). + * + * Two translation notes (recorded in `deviations.md`): + * - The "increment sends a v6 COUNTER_INC wire message" / "decrement negates the amount" assertions + * (RTLC12e2/e3/e5, RTLC13b) inspect the **outbound wire `ObjectMessage`** (`captured.state[0].operation`). + * That wire form (`WireObjectMessage` / `WireObjectOperation`) is `internal` to `:liveobjects` and not part + * of the public API, so it is read by reflection off the captured `ProtocolMessage.state` (the same + * reflection pattern `helpers.kt` / `PublicObjectMessageTest.kt` already use). The observable public-API + * outcome (counter value after the await) is asserted alongside where the spec provides it. + * - RTLC12e1 feeds non-`Number` increment amounts and expects `40003`. ably-java's + * `increment(@NotNull Number)` signature rejects every one of those at compile time, so the cases are not + * expressible as runtime assertions — see deviations.md. + * + * All tests use `setupSyncedChannel` (helpers.kt), which needs the SDK's OBJECT_SYNC processing + + * `RealtimeObject.get()` — still TODO — so these compile now and run once that lands (translate-only). + */ +class LiveCounterApiTest { + + /** + * @UTS objects/unit/RTLC5/value-returns-data-0 + */ + @Test + fun `RTLC5 - value returns current counter data`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val counter = root.get("score") + assertEquals(100.0, counter.asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTLC12/increment-sends-counter-inc-0 + */ + @Test + fun `RTLC12 - increment sends v6 COUNTER_INC message`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().increment(25).await() + + // DEVIATION (RTLC12e2/e3/e5): the spec asserts on the outbound wire ObjectMessage + // (captured.state[0].operation.action/objectId/counterInc.number). The wire form + // (WireObjectMessage/WireObjectOperation) is internal to :liveobjects; read it by reflection off the + // captured ProtocolMessage.state. See deviations.md. + val captured = capturedObjectMessages(mockWs) + assertEquals(1, captured.size) + val op = wireOperation(captured[0].state!![0]!!) + assertEquals("CounterInc", wireActionName(op)) + assertEquals("counter:score@1000", wireObjectId(op)) + assertEquals(25.0, wireCounterIncNumber(op)) + } + + /** + * @UTS objects/unit/RTLC12/increment-applies-locally-0 + */ + @Test + fun `RTLC12 - increment applies locally after ACK`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().increment(50).await() + + assertEquals(150.0, root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTLC12e1/increment-non-number-0 + */ + @Test + fun `RTLC12e1 - increment with non-number throws`() = runTest { + setupSyncedChannel("test") + + // DEVIATION (RTLC12e1): spec calls `increment("not_a_number")` and expects ErrorInfo 40003. ably-java's + // `LiveCounterPathObject.increment(@NotNull Number)` rejects a String at compile time, so the case is + // not expressible as a runtime assertion. See deviations.md. + } + + /** + * @UTS objects/unit/RTLC13/decrement-negates-0 + */ + @Test + fun `RTLC13 - decrement delegates to increment with negated amount`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().decrement(15).await() + + // DEVIATION (RTLC13b): the spec asserts the outbound wire counterInc.number == -15 (decrement is an + // alias for increment with a negated amount). Read the internal wire form by reflection; see + // deviations.md. The public value outcome is asserted directly. + val captured = capturedObjectMessages(mockWs) + assertEquals(-15.0, wireCounterIncNumber(wireOperation(captured[0].state!![0]!!))) + assertEquals(85.0, root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTLC11/counter-update-on-inc-0 + */ + @Test + fun `RTLC11 - LiveCounterUpdate emitted on increment`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + + val updates = mutableListOf() + val instance = root.get("score").instance()!!.asLiveCounter() + instance.subscribe(InstanceListener { updates.add(it) }) + + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf(buildCounterInc("counter:score@1000", 7, "99", "remote-site")), + ), + ) + pollUntil(5.seconds) { updates.size >= 1 } + + // DEVIATION (RTLC11b1): the spec reads `updates[0].message.operation.counterInc.number == 7`. ably-java's + // public InstanceSubscriptionEvent carries no LiveCounterUpdate diff (no `update.amount`), but it does + // expose the originating public ObjectMessage via getMessage(); assert the counterInc on that. See + // deviations.md. + val message = updates[0].getMessage()!! + assertEquals(ObjectOperationAction.COUNTER_INC, message.operation.action) + assertEquals(7.0, message.operation.counterInc!!.number) + } + + /** + * @UTS objects/unit/RTLC12e1/increment-invalid-amounts-table-0 + */ + @Test + fun `RTLC12e1 - Table-driven invalid increment amounts`() = runTest { + // DEVIATION (RTLC12e1): the table feeds null / NaN / ±Infinity / String / Boolean / array / object as + // the increment amount, each expecting ErrorInfo 40003. ably-java's `increment(@NotNull Number)` + // signature makes the non-Number rows (null, String, Boolean, array, object) compile errors, so they + // are not expressible. The numeric-but-invalid rows (NaN, +Infinity, -Infinity) ARE expressible as + // runtime assertions and are exercised below. See deviations.md. + val (_, _, root, _) = setupSyncedChannel("test") + val counter = root.get("score").asLiveCounter() + + for (invalid in listOf(Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY)) { + // The non-finite amounts must be rejected with 40003 (RTLC12e1). + val ex = assertFailsWith { counter.increment(invalid).await() } + assertEquals(40003, ex.errorInfo.code) + } + } +} + +// --------------------------------------------------------------------------- +// Reflective access to the outbound wire ObjectMessage (internal to :liveobjects). +// +// The SDK serializes a published OBJECT operation into ProtocolMessage.state as internal +// WireObjectMessage instances. These are decoded back by the mock and recorded in mockWs.events. Their +// operation/action/objectId/counterInc fields are internal Kotlin data-class properties — addressable by +// their declared field names on the JVM (Kotlin `internal` is not name-mangled here), reached with +// isAccessible since they are package-private/internal. Mirrors the reflection pattern in helpers.kt. +// --------------------------------------------------------------------------- + +private fun capturedObjectMessages(mockWs: MockWebSocket): List = + mockWs.events + .filterIsInstance() + .map { it.message } + .filter { it.action == ProtocolMessage.Action.`object` } + +private fun field(target: Any, name: String): Any? = + target.javaClass.getDeclaredField(name).apply { isAccessible = true }.get(target) + +private fun wireOperation(wireObjectMessage: Any): Any = + field(wireObjectMessage, "operation") ?: error("wire ObjectMessage has no operation") + +private fun wireActionName(wireOperation: Any): String = + (field(wireOperation, "action") as Enum<*>).name + +private fun wireObjectId(wireOperation: Any): String? = + field(wireOperation, "objectId") as String? + +private fun wireCounterIncNumber(wireOperation: Any): Double { + val counterInc = field(wireOperation, "counterInc") ?: error("wire operation has no counterInc") + return (field(counterInc, "number") as Number).toDouble() +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveMapApiTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveMapApiTest.kt new file mode 100644 index 000000000..99070c8ec --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveMapApiTest.kt @@ -0,0 +1,289 @@ +package io.ably.lib.uts.unit.liveobjects + +import com.google.gson.JsonObject +import io.ably.lib.liveobjects.ValueType +import io.ably.lib.liveobjects.value.LiveCounter +import io.ably.lib.liveobjects.value.LiveMap +import io.ably.lib.liveobjects.value.LiveMapValue +import io.ably.lib.uts.infra.pollUntil +import kotlinx.coroutines.future.await +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Derived from UTS `objects/unit/live_map_api.md` (RTLM5, RTLM10–RTLM13, RTLM20–RTLM21, RTLM24, RTLMV4, + * RTLCV4) — the public LiveMap read/write surface (`get` / `size` / `entries` / `keys` / `set` / `remove`). + * + * ably-java implements the typed-SDK variant (RTTS): the root from `setupSyncedChannel` is a + * `LiveMapPathObject`, so `get`/`set`/`remove`/`size`/`entries`/`keys` need no cast; navigated `PathObject`s + * are narrowed with `as*` casts before a typed read (`asString().value()`, `asNumber().value()`, + * `asLiveCounter().value()`, …). Write values are wrapped in the `LiveMapValue` union and + * `LiveMap.create` / `LiveCounter.create` value types. + * + * Several spec cases assert on the **wire form of the sent OBJECT ProtocolMessage** + * (`captured_messages[...].state[...].operation.action / mapSet.value.*`, the COUNTER_CREATE/MAP_CREATE + * ordering for value-type sets, the MAP_REMOVE wire shape) — that is the internal `WireObjectMessage` + * graph (objects-mapping §13), not the public API. Those are translated to the equivalent **observable + * public effect** (the local round-trip read after the auto-ACK echo applies), with the wire-shape + * sub-assertions recorded as deviations. The table-driven invalid-value case (function / undefined / symbol) + * is rejected at compile time by the `LiveMapValue` union, so it is not expressible (deviation, §6). + * + * All tests use `setupSyncedChannel` (helpers.kt), which needs the SDK's OBJECT_SYNC processing + + * `RealtimeObject.get()` — still TODO — so these compile now and run once that lands (translate-only). + */ +class LiveMapApiTest { + + /** + * @UTS objects/unit/RTLM5/get-string-value-0 + */ + @Test + fun `RTLM5 - get returns resolved value from LiveMap`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + assertEquals("Alice", root.get("name").asString().value()) + assertEquals(30.0, root.get("age").asNumber().value()?.toDouble()) + assertEquals(true, root.get("active").asBoolean().value()) + } + + /** + * @UTS objects/unit/RTLM5/get-nonexistent-key-0 + */ + @Test + fun `RTLM5 - get returns null for non-existent key`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + // No entry at this path: getType() is null and a typed read returns null. + assertNull(root.get("nonexistent").getType()) + assertNull(root.get("nonexistent").asString().value()) + } + + /** + * @UTS objects/unit/RTLM5/get-objectid-reference-0 + */ + @Test + fun `RTLM5 - get resolves objectId to LiveObject`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + // score -> counter:score@1000 (value 100); spec `value() == 100` on a counter. + assertEquals(100.0, root.get("score").asLiveCounter().value()) + // profile -> map:profile@1000; navigate the nested map and read the email primitive. + assertEquals("alice@example.com", root.get("profile").asLiveMap().get("email").asString().value()) + } + + /** + * @UTS objects/unit/RTLM10/size-non-tombstoned-0 + */ + @Test + fun `RTLM10 - size returns non-tombstoned entry count`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + assertEquals(7L, root.size()) + } + + /** + * @UTS objects/unit/RTLM11/entries-yields-pairs-0 + */ + @Test + fun `RTLM11 - entries yields key value pairs`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val entries = mutableListOf() + for ((key, _) in root.entries()) { + entries.add(key) + } + + assertTrue("name" in entries) + assertTrue("age" in entries) + assertTrue("active" in entries) + assertTrue("score" in entries) + assertTrue("profile" in entries) + assertTrue("data" in entries) + assertTrue("avatar" in entries) + assertEquals(7, entries.size) + } + + /** + * @UTS objects/unit/RTLM12/keys-0 + */ + @Test + fun `RTLM12 - keys yields only keys`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val keys = root.keys().toList() + + assertEquals(7, keys.size) + assertTrue("name" in keys) + } + + /** + * @UTS objects/unit/RTLM20/set-sends-map-set-0 + */ + @Test + fun `RTLM20 - set sends MAP_SET message`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + root.set("name", LiveMapValue.of("Bob")).await() + + // DEVIATION (RTLM20e2/e3/e6/e7c, RTLM20h2): the spec asserts on the sent OBJECT ProtocolMessage's + // wire shape (`captured_messages[0].state[0].operation.action == "MAP_SET"`, `objectId == "root"`, + // `mapSet.key`, `mapSet.value.string`). That is the internal WireObjectMessage graph (§13), not the + // public API. We assert the equivalent observable effect: the MAP_SET applies locally after the ACK + // echo, so the typed read returns the new value. See deviations.md. + pollUntil(5.seconds) { root.get("name").asString().value() == "Bob" } + assertEquals("Bob", root.get("name").asString().value()) + } + + /** + * @UTS objects/unit/RTLM20/set-value-types-0 + */ + @Test + fun `RTLM20 - set with different value types`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + root.set("num_key", LiveMapValue.of(42)).await() + root.set("bool_key", LiveMapValue.of(false)).await() + val nested = JsonObject().apply { addProperty("nested", true) } + root.set("json_key", LiveMapValue.of(nested)).await() + + // DEVIATION (RTLM20e7b/d/e): the spec asserts on the sent wire `mapSet.value.number / .boolean / + // .json`. Those are internal WireObjectMessage fields (§13). We assert the equivalent observable + // effect: each value round-trips through the local graph as the matching typed value. + pollUntil(5.seconds) { root.get("json_key").getType() == ValueType.JSON_OBJECT } + assertEquals(42.0, root.get("num_key").asNumber().value()?.toDouble()) + assertEquals(false, root.get("bool_key").asBoolean().value()) + assertEquals(nested, root.get("json_key").asJsonObject().value()) + } + + /** + * @UTS objects/unit/RTLM20/set-bytes-value-0 + */ + @Test + fun `RTLM20 - set with bytes value type`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val bytes = byteArrayOf(1, 2, 3) + root.set("binary_data", LiveMapValue.of(bytes)).await() + + // DEVIATION (RTLM20e7f): the spec asserts the sent wire `mapSet.value.bytes == "AQID"` (base64). + // That is the internal WireObjectMessage encoding (§13). We assert the equivalent observable effect: + // the binary value round-trips through the local graph byte-for-byte. + pollUntil(5.seconds) { root.get("binary_data").getType() == ValueType.BINARY } + assertEquals(bytes.toList(), root.get("binary_data").asBinary().value()?.toList()) + } + + /** + * @UTS objects/unit/RTLM20e7g/set-counter-value-type-0 + */ + @Test + fun `RTLM20e7g - set with LiveCounterValueType generates COUNTER_CREATE plus MAP_SET`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + root.set("new_counter", LiveMapValue.of(LiveCounter.create(50))).await() + + // DEVIATION (RTLM20e7g1/e7g2, RTLM20h1): the spec asserts the sent OBJECT carries two wire messages, + // a COUNTER_CREATE (objectId starts with "counter:") followed by a MAP_SET whose value.objectId + // references it. That ordered wire-message generation (RTLCV4) is internal (§13). We assert the + // equivalent observable effect: a new LiveCounter is created and reachable at the key with its + // initial value. + pollUntil(5.seconds) { root.get("new_counter").getType() == ValueType.LIVE_COUNTER } + assertEquals(ValueType.LIVE_COUNTER, root.get("new_counter").getType()) + assertEquals(50.0, root.get("new_counter").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTLM20e7g/set-map-value-type-0 + */ + @Test + fun `RTLM20e7g - set with LiveMapValueType generates nested CREATE plus MAP_SET`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + root.set("nested_map", LiveMapValue.of(LiveMap.create(mapOf("key1" to LiveMapValue.of("value1"))))).await() + + // DEVIATION (RTLM20e7g1/e7g2, RTLM20h1): the spec asserts the sent OBJECT carries an ordered list of + // wire messages (MAP_CREATE with objectId starting "map:" followed by MAP_SET referencing it). That + // wire-message generation (RTLMV4) is internal (§13). We assert the equivalent observable effect: a + // new LiveMap is created at the key with its initial entry. + pollUntil(5.seconds) { root.get("nested_map").getType() == ValueType.LIVE_MAP } + assertEquals(ValueType.LIVE_MAP, root.get("nested_map").getType()) + assertEquals("value1", root.get("nested_map").asLiveMap().get("key1").asString().value()) + } + + /** + * @UTS objects/unit/RTLM20h1/set-nested-value-types-0 + */ + @Test + fun `RTLM20h1 - set with nested LiveMapValueType containing LiveCounterValueType`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + root.set( + "stats", + LiveMapValue.of( + LiveMap.create( + mapOf( + "count" to LiveMapValue.of(LiveCounter.create(0)), + "label" to LiveMapValue.of("test"), + ), + ), + ), + ).await() + + // DEVIATION (RTLM20h1, RTLMV4d1/d2): the spec asserts the sent OBJECT carries COUNTER_CREATE, + // MAP_CREATE, MAP_SET in depth-first order with cross-referencing objectIds. That ordered wire-message + // generation is internal (§13). We assert the equivalent observable effect: the nested map and its + // nested counter / primitive resolve locally. + pollUntil(5.seconds) { root.get("stats").getType() == ValueType.LIVE_MAP } + val stats = root.get("stats").asLiveMap() + assertEquals(0.0, stats.get("count").asLiveCounter().value()) + assertEquals("test", stats.get("label").asString().value()) + } + + /** + * @UTS objects/unit/RTLM21/remove-sends-map-remove-0 + */ + @Test + fun `RTLM21 - remove sends MAP_REMOVE message`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + root.remove("name").await() + + // DEVIATION (RTLM21e2/e5): the spec asserts the sent wire `operation.action == "MAP_REMOVE"`, + // `objectId == "root"`, `mapRemove.key == "name"`. That is the internal WireObjectMessage graph + // (§13). We assert the equivalent observable effect: the MAP_REMOVE applies locally after the ACK + // echo, so the key no longer resolves. + pollUntil(5.seconds) { root.get("name").getType() == null } + assertNull(root.get("name").asString().value()) + assertNull(root.get("name").getType()) + } + + /** + * @UTS objects/unit/RTLM20/set-applies-locally-0 + */ + @Test + fun `RTLM20 - set applies locally after ACK`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + root.set("name", LiveMapValue.of("Bob")).await() + + pollUntil(5.seconds) { root.get("name").asString().value() == "Bob" } + assertEquals("Bob", root.get("name").asString().value()) + } + + /** + * @UTS objects/unit/RTLM20/set-invalid-values-table-0 + */ + @Test + fun `RTLM20 - invalid set value types`() = runTest { + setupSyncedChannel("test") + + // DEVIATION (RTLM20e1, RTLMV4c): the spec feeds deliberately unsupported values (a function, + // `undefined`, a symbol) and expects ErrorInfo 40013 at runtime. ably-java's `set` takes a + // `LiveMapValue`, and `LiveMapValue.of(...)` is only overloaded for the supported types + // (Boolean/Binary/Number/String/JsonArray/JsonObject/LiveCounter/LiveMap) — there is no way to + // construct a LiveMapValue from a function / undefined / symbol, so these cases are rejected at + // compile time and are not expressible as a runtime 40013 assertion (§6). See deviations.md. + } +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveObjectSubscribeTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveObjectSubscribeTest.kt new file mode 100644 index 000000000..029c496f6 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/LiveObjectSubscribeTest.kt @@ -0,0 +1,258 @@ +package io.ably.lib.uts.unit.liveobjects + +import io.ably.lib.liveobjects.Subscription +import io.ably.lib.liveobjects.instance.InstanceListener +import io.ably.lib.liveobjects.instance.InstanceSubscriptionEvent +import io.ably.lib.liveobjects.message.ObjectOperationAction +import io.ably.lib.uts.infra.pollUntil +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.time.Duration.Companion.seconds + +/** + * Derived from UTS `objects/unit/live_object_subscribe.md` (RTLO4b, RTLO4b3, RTLO4b4c1, RTLO4b4c3a, + * RTLO4b4c3c, RTLO4b4d, RTLO4b4e, RTLO4b6, RTLO4b7) — registering a listener for LiveObject data updates. + * + * The spec subscribes through the **public** `instance.subscribe(...)` (RTINS16) and cites the *internal* + * `RTLO4b` `LiveObjectUpdate` diff (fields `update` / `noop` / `objectMessage` / `tombstone`). In ably-java + * the public event is [InstanceSubscriptionEvent], which exposes only `getObject()` and `getMessage()` — no + * diff / `noop` / `tombstone` accessors (mapping §8). So: + * - "listener fired N times" and "returns a Subscription" translate directly (`events.size`, `Subscription`). + * - The noop case (RTLO4b4c1) is observed only as *suppressed delivery* (no event), since there is no public + * `update.noop` flag to assert — adapted, recorded in deviations.md. + * - The tombstone diff flag (RTLO4b4c3c / RTLO4b4e) is observed through `message.operation.action == + * OBJECT_DELETE` (the spec itself prescribes this public-API proxy), not a `tombstone` boolean. + * + * All tests use `setupSyncedChannel` (helpers.kt), which needs the SDK's OBJECT_SYNC processing + + * `RealtimeObject.get()` — still TODO — so these compile now and run once that lands (translate-only). + */ +class LiveObjectSubscribeTest { + + /** + * @UTS objects/unit/RTLO4b/subscribe-receives-updates-0 + */ + @Test + fun `RTLO4b - subscribe registers listener for data updates`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val updates = mutableListOf() + val instance = root.get("score").instance()!!.asLiveCounter() + val sub: Subscription = instance.subscribe(InstanceListener { updates.add(it) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), + ) + pollUntil(5.seconds) { updates.size >= 1 } + + assertNotNull(sub) // IS Subscription + assertEquals(1, updates.size) + } + + /** + * @UTS objects/unit/RTLO4b7/subscribe-returns-subscription-0 + */ + @Test + fun `RTLO4b7 - subscribe returns Subscription with unsubscribe method`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + val instance = root.get("score").instance()!!.asLiveCounter() + + val sub: Subscription = instance.subscribe(InstanceListener { }) + + assertNotNull(sub) // IS Subscription + // `sub.unsubscribe IS Function` -> the Subscription exposes a callable unsubscribe(); calling it is a no-op. + sub.unsubscribe() + } + + /** + * @UTS objects/unit/RTLO4b7/subscription-unsubscribe-stops-delivery-0 + */ + @Test + fun `RTLO4b7 - Subscription unsubscribe stops delivery`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val updates = mutableListOf() + val instance = root.get("score").instance()!!.asLiveCounter() + val sub = instance.subscribe(InstanceListener { updates.add(it) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 5, "01", "remote"))), + ) + pollUntil(5.seconds) { updates.size >= 1 } + + sub.unsubscribe() + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, "02", "remote"))), + ) + + assertEquals(1, updates.size) + } + + /** + * @UTS objects/unit/RTLO4b7/subscription-unsubscribe-idempotent-0 + */ + @Test + fun `RTLO4b7 - Subscription unsubscribe is idempotent`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + val instance = root.get("score").instance()!!.asLiveCounter() + val sub = instance.subscribe(InstanceListener { }) + + // No error thrown — both calls complete without error. + sub.unsubscribe() + sub.unsubscribe() + } + + /** + * @UTS objects/unit/RTLO4b4c1/noop-no-trigger-0 + */ + @Test + fun `RTLO4b4c1 - noop update does not trigger listener`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val updates = mutableListOf() + val instance = root.get("score").instance()!!.asLiveCounter() + instance.subscribe(InstanceListener { updates.add(it) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 5, "01", "remote"))), + ) + pollUntil(5.seconds) { updates.size >= 1 } + + // Serial "02" passes the newness check (RTLO4a6); the zero increment is the noop. + // DEVIATION (RTLO4b4c1): the spec asserts on the internal `LiveObjectUpdate.noop` flag. The public + // InstanceSubscriptionEvent has no `noop` accessor (mapping §8), so the noop is observed only as + // suppressed delivery — the listener is not fired a second time. See deviations.md. + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 0, "02", "remote"))), + ) + + assertEquals(1, updates.size) + } + + /** + * @UTS objects/unit/RTLO4b6/subscribe-no-side-effects-0 + */ + @Test + fun `RTLO4b6 - subscribe has no side effects`() = runTest { + val (_, channel, root, _) = setupSyncedChannel("test") + val stateBefore = channel.state + val instance = root.get("score").instance()!!.asLiveCounter() + + instance.subscribe(InstanceListener { }) + + assertEquals(stateBefore, channel.state) + } + + /** + * @UTS objects/unit/RTLO4b/subscribe-map-update-0 + */ + @Test + fun `RTLO4b - subscribe on LiveMap receives update`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val updates = mutableListOf() + val instance = root.instance()!!.asLiveMap() + instance.subscribe(InstanceListener { updates.add(it) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), "99", "remote"))), + ) + pollUntil(5.seconds) { updates.size >= 1 } + + assertEquals(1, updates.size) + } + + /** + * @UTS objects/unit/RTLO4b4c3c/tombstone-deregisters-listeners-0 + */ + @Test + fun `RTLO4b4c3c - tombstone update deregisters all Instance subscribe listeners`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val updatesA = mutableListOf() + val updatesB = mutableListOf() + val instance = root.get("score").instance()!!.asLiveCounter() + instance.subscribe(InstanceListener { updatesA.add(it) }) + instance.subscribe(InstanceListener { updatesB.add(it) }) + + // Send an OBJECT_DELETE which causes a tombstone. + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildObjectDelete("counter:score@1000", "50", "remote"))), + ) + pollUntil(5.seconds) { updatesA.size >= 1 } + + // Both listeners should have received the tombstone update. The tombstone is identified by the + // OBJECT_DELETE action (spec-prescribed public-API proxy for the internal `tombstone` flag). + assertEquals(1, updatesA.size) + assertEquals(ObjectOperationAction.OBJECT_DELETE, updatesA[0].getMessage()!!.operation.action) + assertEquals(1, updatesB.size) + assertEquals(ObjectOperationAction.OBJECT_DELETE, updatesB[0].getMessage()!!.operation.action) + + // Send another update — listeners should have been deregistered by the tombstone. + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 3, "51", "remote"))), + ) + + assertEquals(1, updatesA.size) + assertEquals(1, updatesB.size) + } + + /** + * @UTS objects/unit/RTLO4b4d/update-has-object-message-0 + */ + @Test + fun `RTLO4b4d - InstanceSubscriptionEvent message is populated from source ObjectMessage`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val updates = mutableListOf() + val instance = root.get("score").instance()!!.asLiveCounter() + instance.subscribe(InstanceListener { updates.add(it) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), + ) + pollUntil(5.seconds) { updates.size >= 1 } + + assertEquals(1, updates.size) + val message = updates[0].getMessage() + assertNotNull(message) + assertEquals("99", message!!.serial) + assertEquals("remote", message.siteCode) + assertEquals(ObjectOperationAction.COUNTER_INC, message.operation.action) + assertEquals("counter:score@1000", message.operation.objectId) + } + + /** + * @UTS objects/unit/RTLO4b4e/tombstone-flag-true-0 + */ + @Test + fun `RTLO4b4e - tombstone update identified by OBJECT_DELETE action`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val updates = mutableListOf() + val instance = root.get("score").instance()!!.asLiveCounter() + instance.subscribe(InstanceListener { updates.add(it) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildObjectDelete("counter:score@1000", "50", "remote"))), + ) + pollUntil(5.seconds) { updates.size >= 1 } + + assertEquals(1, updates.size) + assertEquals(ObjectOperationAction.OBJECT_DELETE, updates[0].getMessage()!!.operation.action) + } + + /** + * @UTS objects/unit/RTLO4b4e/tombstone-flag-false-0 + */ + @Test + fun `RTLO4b4e - normal update carries non-tombstone action`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val updates = mutableListOf() + val instance = root.get("score").instance()!!.asLiveCounter() + instance.subscribe(InstanceListener { updates.add(it) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), + ) + pollUntil(5.seconds) { updates.size >= 1 } + + assertEquals(1, updates.size) + assertEquals(ObjectOperationAction.COUNTER_INC, updates[0].getMessage()!!.operation.action) + } +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectMutationsTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectMutationsTest.kt new file mode 100644 index 000000000..351e1c94d --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectMutationsTest.kt @@ -0,0 +1,200 @@ +package io.ably.lib.uts.unit.liveobjects + +import io.ably.lib.liveobjects.value.LiveMapValue +import io.ably.lib.types.AblyException +import kotlinx.coroutines.future.await +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +/** + * Derived from UTS `objects/unit/path_object_mutations.md` (RTPO15–RTPO18, RTPO3c2) — write operations + * through the typed `PathObject` view. + * + * ably-java implements the typed-SDK variant (RTTS): the spec's single polymorphic `PathObject` partitions + * `set`/`remove` onto `LiveMapPathObject` (via `asLiveMap()`) and `increment`/`decrement` onto + * `LiveCounterPathObject` (via `asLiveCounter()`). The root from `setupSyncedChannel` is already a + * `LiveMapPathObject`, so `root.set(...)`/`root.remove(...)` need no cast; deeper navigated nodes do. + * + * Wrong-type write cases (RTPO15d/16d/17d/18d) and unresolvable-path cases (RTPO3c2) are fully expressible: + * the `as*` cast never throws (RTTS5d), so we cast to the view whose write method we need, then assert the + * **operation** itself throws `AblyException` with the spec's error code (92007 wrong type, 92005 + * unresolvable path). No deviations. + * + * All tests use `setupSyncedChannel` (helpers.kt), which needs the SDK's OBJECT_SYNC processing + + * `RealtimeObject.get()` — still TODO — so these compile now and run once that lands (translate-only). + */ +class PathObjectMutationsTest { + + /** + * @UTS objects/unit/RTPO15/set-delegates-to-map-0 + */ + @Test + fun `RTPO15 - set delegates to LiveMap set`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + root.set("name", LiveMapValue.of("Bob")).await() + + assertEquals("Bob", root.get("name").asString().value()) + } + + /** + * @UTS objects/unit/RTPO15/set-nested-path-0 + */ + @Test + fun `RTPO15 - set on nested path`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + root.get("profile").asLiveMap().set("email", LiveMapValue.of("bob@example.com")).await() + + assertEquals( + "bob@example.com", + root.get("profile").asLiveMap().get("email").asString().value(), + ) + } + + /** + * @UTS objects/unit/RTPO15d/set-non-map-throws-0 + */ + @Test + fun `RTPO15d - set on non-LiveMap throws 92007`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + // The cast never throws (RTTS5d); the MAP_SET operation on a counter fails with 92007. + val ex = assertFailsWith { + root.get("score").asLiveMap().set("key", LiveMapValue.of("value")).await() + } + assertEquals(92007, ex.errorInfo.code) + } + + /** + * @UTS objects/unit/RTPO16/remove-delegates-to-map-0 + */ + @Test + fun `RTPO16 - remove delegates to LiveMap remove`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + root.remove("name").await() + + assertNull(root.get("name").asString().value()) + } + + /** + * @UTS objects/unit/RTPO16d/remove-non-map-throws-0 + */ + @Test + fun `RTPO16d - remove on non-LiveMap throws 92007`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val ex = assertFailsWith { + root.get("score").asLiveMap().remove("key").await() + } + assertEquals(92007, ex.errorInfo.code) + } + + /** + * @UTS objects/unit/RTPO17/increment-delegates-to-counter-0 + */ + @Test + fun `RTPO17 - increment delegates to LiveCounter increment`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().increment(25).await() + + assertEquals(125.0, root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTPO17/increment-default-amount-0 + */ + @Test + fun `RTPO17 - increment defaults to 1`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().increment().await() + + assertEquals(101.0, root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTPO17d/increment-non-counter-throws-0 + */ + @Test + fun `RTPO17d - increment on non-LiveCounter throws 92007`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + // increment on the root map: cast never throws (RTTS5d); the COUNTER_INC operation fails with 92007. + val ex = assertFailsWith { + root.asLiveCounter().increment(5).await() + } + assertEquals(92007, ex.errorInfo.code) + } + + /** + * @UTS objects/unit/RTPO18/decrement-delegates-to-counter-0 + */ + @Test + fun `RTPO18 - decrement delegates to LiveCounter decrement`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().decrement(10).await() + + assertEquals(90.0, root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTPO18/decrement-default-amount-0 + */ + @Test + fun `RTPO18 - decrement defaults to 1`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().decrement().await() + + assertEquals(99.0, root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTPO18d/decrement-non-counter-throws-0 + */ + @Test + fun `RTPO18d - decrement on non-LiveCounter throws 92007`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val ex = assertFailsWith { + root.asLiveCounter().decrement(5).await() + } + assertEquals(92007, ex.errorInfo.code) + } + + /** + * @UTS objects/unit/RTPO3c2/set-unresolvable-throws-0 + */ + @Test + fun `RTPO3c2 - set on unresolvable path throws 92005`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val ex = assertFailsWith { + root.get("nonexistent").asLiveMap().get("deep").asLiveMap() + .set("key", LiveMapValue.of("value")).await() + } + assertEquals(92005, ex.errorInfo.code) + assertEquals(400, ex.errorInfo.statusCode) + } + + /** + * @UTS objects/unit/RTPO3c2/increment-unresolvable-throws-0 + */ + @Test + fun `RTPO3c2 - increment on unresolvable path throws 92005`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val ex = assertFailsWith { + root.get("nonexistent").asLiveCounter().increment(5).await() + } + assertEquals(92005, ex.errorInfo.code) + assertEquals(400, ex.errorInfo.statusCode) + } +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectSubscribeTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectSubscribeTest.kt new file mode 100644 index 000000000..0d47ba1ac --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectSubscribeTest.kt @@ -0,0 +1,543 @@ +package io.ably.lib.uts.unit.liveobjects + +import io.ably.lib.liveobjects.Subscription +import io.ably.lib.liveobjects.message.ObjectOperationAction +import io.ably.lib.liveobjects.path.PathObjectListener +import io.ably.lib.liveobjects.path.PathObjectSubscriptionEvent +import io.ably.lib.liveobjects.path.PathObjectSubscriptionOptions +import io.ably.lib.realtime.ChannelState +import io.ably.lib.types.AblyException +import io.ably.lib.types.ProtocolMessage +import io.ably.lib.uts.infra.awaitChannelState +import io.ably.lib.uts.infra.pollUntil +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Derived from UTS `objects/unit/path_object_subscribe.md` (RTPO19, RTO24, RTO25) — path-based + * subscriptions on the typed `PathObject`. + * + * Translation rules (mapping §8): `pathObj.subscribe(closure)` becomes + * `pathObj.subscribe(PathObjectListener { event -> … })` returning a `Subscription` synchronously; + * `{ depth: n }` becomes `PathObjectSubscriptionOptions(n)` (non-positive depth throws AblyException + * 400/40003, RTPO19c1a); `event.object` / `event.message` become `event.getObject()` / + * `event.getMessage()`. Counter `value()` is `Double` (assert `107.0`). The DETACHED-precondition case + * (RTPO19b) drives the channel to DETACHED via a server-sent DETACHED protocol message over the existing + * mock (the shared helper's mock does not respond to DETACH), then asserts the synchronous failure. + * + * All tests use `setupSyncedChannel` (helpers.kt), which needs the SDK's OBJECT_SYNC processing + + * `RealtimeObject.get()` — still TODO — so these compile now and run once that lands (translate-only). + */ +class PathObjectSubscribeTest { + + /** + * @UTS objects/unit/RTPO19/subscribe-receives-events-0 + */ + @Test + fun `RTPO19 - subscribe returns Subscription and receives events`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val events = mutableListOf() + val sub: Subscription = root.get("score").subscribe(PathObjectListener { events.add(it) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), + ) + pollUntil(5.seconds) { events.size >= 1 } + + assertNotNull(sub) // IS Subscription + assertEquals(1, events.size) + assertNotNull(events[0].getObject()) // IS PathObject + assertEquals("score", events[0].getObject().path()) + val message = events[0].getMessage() + assertNotNull(message) + assertEquals("99", message!!.serial) + assertEquals("remote", message.siteCode) + assertNotNull(message.operation) + assertEquals(ObjectOperationAction.COUNTER_INC, message.operation.action) + assertEquals("test", message.channel) + } + + /** + * @UTS objects/unit/RTPO19b/subscribe-precondition-detached-0 + */ + @Test + fun `RTPO19b - subscribe on DETACHED channel throws 90001`() = runTest { + val (_, channel, root, mockWs) = setupSyncedChannel("test") + + // Drive the channel to DETACHED. The shared helper's mock does not respond to DETACH, so the + // server-sent DETACHED is injected directly over the existing mock. + channel.detach() + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.detached).apply { this.channel = "test" }, + ) + awaitChannelState(channel, ChannelState.detached) + + val ex = assertFailsWith { root.subscribe(PathObjectListener { }) } + assertEquals(90001, ex.errorInfo.code) + assertEquals(400, ex.errorInfo.statusCode) + } + + /** + * @UTS objects/unit/RTPO19c1a/subscribe-non-positive-depth-throws-0 + */ + @Test + fun `RTPO19c1a - subscribe with non-positive depth throws 40003`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val ex = assertFailsWith { + root.subscribe(PathObjectListener { }, PathObjectSubscriptionOptions(0)) + } + assertEquals(40003, ex.errorInfo.code) + } + + /** + * @UTS objects/unit/RTPO19c1a/subscribe-negative-depth-throws-0 + */ + @Test + fun `RTPO19c1a - subscribe with negative depth throws 40003`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val ex = assertFailsWith { + root.subscribe(PathObjectListener { }, PathObjectSubscriptionOptions(-1)) + } + assertEquals(40003, ex.errorInfo.code) + } + + /** + * @UTS objects/unit/RTPO19c1/subscribe-depth-1-self-only-0 + */ + @Test + fun `RTPO19c1 - subscribe with depth 1 only receives self events`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val events = mutableListOf() + root.subscribe(PathObjectListener { events.add(it) }, PathObjectSubscriptionOptions(1)) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), "99", "remote"))), + ) + pollUntil(5.seconds) { events.size >= 1 } + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "100", "remote"))), + ) + + assertEquals(1, events.size) + } + + /** + * @UTS objects/unit/RTPO19c1/subscribe-depth-2-children-0 + */ + @Test + fun `RTPO19c1 - subscribe with depth 2 receives self and children`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val events = mutableListOf() + root.subscribe(PathObjectListener { events.add(it) }, PathObjectSubscriptionOptions(2)) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), "99", "remote"))), + ) + pollUntil(5.seconds) { events.size >= 1 } + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "100", "remote"))), + ) + pollUntil(5.seconds) { events.size >= 2 } + + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf(buildMapSet("map:profile@1000", "email", dataString("bob@example.com"), "101", "remote")), + ), + ) + + assertEquals(2, events.size) + } + + /** + * @UTS objects/unit/RTPO19c1/subscribe-unlimited-depth-0 + */ + @Test + fun `RTPO19c1 - subscribe with no depth receives all descendants`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val events = mutableListOf() + root.subscribe(PathObjectListener { events.add(it) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), "99", "remote"))), + ) + pollUntil(5.seconds) { events.size >= 1 } + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "100", "remote"))), + ) + pollUntil(5.seconds) { events.size >= 2 } + + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf(buildMapSet("map:prefs@1000", "theme", dataString("light"), "101", "remote")), + ), + ) + pollUntil(5.seconds) { events.size >= 3 } + + assertTrue(events.size >= 3) + } + + /** + * @UTS objects/unit/RTPO19d/subscribe-returns-subscription-0 + */ + @Test + fun `RTPO19d - subscribe returns Subscription with unsubscribe`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val events = mutableListOf() + val sub = root.get("score").subscribe(PathObjectListener { events.add(it) }) + + assertNotNull(sub) // IS Subscription + sub.unsubscribe() + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), + ) + + assertEquals(0, events.size) + } + + /** + * @UTS objects/unit/RTPO19e1/event-path-object-correct-0 + */ + @Test + fun `RTPO19e1 - subscribe event provides correct PathObject`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val events = mutableListOf() + root.subscribe(PathObjectListener { events.add(it) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), + ) + pollUntil(5.seconds) { events.size >= 1 } + + assertNotNull(events[0].getObject()) // IS PathObject + assertEquals("score", events[0].getObject().path()) + assertEquals(107.0, events[0].getObject().asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTPO19e2/event-message-delivery-0 + */ + @Test + fun `RTPO19e2 - subscribe event delivers ObjectMessage for operations`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val events = mutableListOf() + root.get("score").subscribe(PathObjectListener { events.add(it) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 42, "serial-1", "site-a"))), + ) + pollUntil(5.seconds) { events.size >= 1 } + + val message = events[0].getMessage() + assertNotNull(message) + assertEquals("test", message!!.channel) + assertEquals("serial-1", message.serial) + assertEquals("site-a", message.siteCode) + assertNotNull(message.operation) + assertEquals(ObjectOperationAction.COUNTER_INC, message.operation.action) + assertEquals("counter:score@1000", message.operation.objectId) + assertEquals(42.0, message.operation.counterInc!!.number) + } + + /** + * @UTS objects/unit/RTPO19e2/event-message-omitted-no-operation-0 + */ + @Test + fun `RTPO19e2 - subscribe event omits message when objectMessage has no operation`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val events = mutableListOf() + root.subscribe(PathObjectListener { events.add(it) }) + + // OBJECT_SYNC that changes counter:score@1000's state without an operation field — the resulting + // update flows through replaceData, so the delivered event carries no ObjectMessage. + mockWs.sendToClient( + buildObjectSyncMessage( + "test", + "sync2:", + listOf( + buildObjectState( + "counter:score@1000", + mapOf("aaa" to "t:1"), + counter = counterState(200), + createOp = counterCreateOp(200), + ), + ), + ), + ) + pollUntil(5.seconds) { events.size >= 1 } + + for (event in events) { + assertNull(event.getMessage()) + } + } + + /** + * @UTS objects/unit/RTPO19f/subscribe-follows-path-0 + */ + @Test + fun `RTPO19f - subscribe follows path not identity`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val events = mutableListOf() + root.get("score").subscribe(PathObjectListener { events.add(it) }) + + // Replace the counter at "score" with a new counter, then increment the new counter. + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf(buildMapSet("root", "score", dataObjectId("counter:new@2000"), "99", "remote")), + ), + ) + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:new@2000", 10, "100", "remote"))), + ) + pollUntil(5.seconds) { events.size >= 1 } + + var foundNew = false + for (event in events) { + if (event.getObject().path() == "score") foundNew = true + } + assertTrue(foundNew) + } + + /** + * @UTS objects/unit/RTPO19g/subscribe-no-side-effects-0 + */ + @Test + fun `RTPO19g - subscribe has no side effects`() = runTest { + val (_, channel, root, _) = setupSyncedChannel("test") + val stateBefore = channel.state + + root.get("score").subscribe(PathObjectListener { }) + + assertEquals(stateBefore, channel.state) + } + + /** + * @UTS objects/unit/RTPO19/subscribe-primitive-path-0 + */ + @Test + fun `RTPO19 - subscribe on primitive path receives change events`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val events = mutableListOf() + root.get("name").subscribe(PathObjectListener { events.add(it) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), "99", "remote"))), + ) + pollUntil(5.seconds) { events.size >= 1 } + + assertEquals(1, events.size) + assertEquals("name", events[0].getObject().path()) + } + + /** + * @UTS objects/unit/RTPO19/map-clear-triggers-child-events-0 + */ + @Test + fun `RTPO19 - MAP_CLEAR triggers subscription events on child paths`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val events = mutableListOf() + root.subscribe(PathObjectListener { events.add(it) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildMapClear("root", "99", "remote"))), + ) + pollUntil(5.seconds) { events.size >= 1 } + + assertTrue(events.size >= 1) + } + + /** + * @UTS objects/unit/RTPO19/child-events-bubble-0 + */ + @Test + fun `RTPO19 - child events bubble up to parent subscription`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val events = mutableListOf() + root.get("profile").subscribe(PathObjectListener { events.add(it) }) + + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf(buildMapSet("map:profile@1000", "email", dataString("bob@example.com"), "99", "remote")), + ), + ) + pollUntil(5.seconds) { events.size >= 1 } + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:nested@1000", 3, "100", "remote"))), + ) + pollUntil(5.seconds) { events.size >= 2 } + + assertTrue(events.size >= 2) + } + + /** + * @UTS objects/unit/RTO24c1/depth-filtering-formula-0 + */ + @Test + fun `RTO24c1 - depth filtering formula`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val events = mutableListOf() + root.get("profile").subscribe(PathObjectListener { events.add(it) }, PathObjectSubscriptionOptions(2)) + + // Self event (profile map update). + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf(buildMapSet("map:profile@1000", "email", dataString("bob@example.com"), "99", "remote")), + ), + ) + pollUntil(5.seconds) { events.size >= 1 } + + // Child event (nested counter). + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:nested@1000", 3, "100", "remote"))), + ) + pollUntil(5.seconds) { events.size >= 2 } + + // Grandchild event (prefs.theme) — should NOT be received. + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf(buildMapSet("map:prefs@1000", "theme", dataString("light"), "101", "remote")), + ), + ) + + assertEquals(2, events.size) + } + + /** + * @UTS objects/unit/RTO24c1/prefix-mismatch-0 + */ + @Test + fun `RTO24c1 - prefix mismatch does not trigger subscription`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val profileEvents = mutableListOf() + root.get("profile").subscribe(PathObjectListener { profileEvents.add(it) }) + + // Change at "score" — "profile" is not a prefix of "score". + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 7, "99", "remote"))), + ) + + // Change at "name" — "profile" is not a prefix of "name". + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), "100", "remote"))), + ) + + assertEquals(0, profileEvents.size) + } + + /** + * @UTS objects/unit/RTO24b2a/candidate-paths-map-keys-0 + */ + @Test + fun `RTO24b2a - candidate path construction includes map update keys`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val scoreEvents = mutableListOf() + val rootEvents = mutableListOf() + root.get("score").subscribe(PathObjectListener { scoreEvents.add(it) }) + root.subscribe(PathObjectListener { rootEvents.add(it) }) + + // MAP_SET on root with key "score" — candidates [] (root) and ["score"]; both subscriptions fire. + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf(buildMapSet("root", "score", dataObjectId("counter:new@2000"), "99", "remote")), + ), + ) + pollUntil(5.seconds) { scoreEvents.size >= 1 } + pollUntil(5.seconds) { rootEvents.size >= 1 } + + assertEquals(1, scoreEvents.size) + assertEquals("score", scoreEvents[0].getObject().path()) + assertEquals(1, rootEvents.size) + } + + /** + * @UTS objects/unit/RTO24b2c/listener-exception-caught-0 + */ + @Test + fun `RTO24b2c - listener exception does not affect other listeners`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val events = mutableListOf() + root.subscribe(PathObjectListener { throw RuntimeException("boom") }) + root.subscribe(PathObjectListener { events.add(it) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildMapSet("root", "name", dataString("Bob"), "99", "remote"))), + ) + pollUntil(5.seconds) { events.size >= 1 } + + assertEquals(1, events.size) + } + + /** + * @UTS objects/unit/RTO24b1/multi-path-dispatch-0 + */ + @Test + fun `RTO24b1 - dispatch via getFullPaths for multi-path objects`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val eventsScore = mutableListOf() + val eventsAlias = mutableListOf() + + // Add a second reference "alias" -> counter:score@1000 so it has two paths. + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf(buildMapSet("root", "alias", dataObjectId("counter:score@1000"), "98", "remote")), + ), + ) + + root.get("score").subscribe(PathObjectListener { eventsScore.add(it) }) + root.get("alias").subscribe(PathObjectListener { eventsAlias.add(it) }) + + // Increment counter:score@1000 — getFullPaths returns ["score"] and ["alias"]. + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 5, "99", "remote"))), + ) + pollUntil(5.seconds) { eventsScore.size >= 1 } + pollUntil(5.seconds) { eventsAlias.size >= 1 } + + assertEquals(1, eventsScore.size) + assertEquals("score", eventsScore[0].getObject().path()) + assertEquals(1, eventsAlias.size) + assertEquals("alias", eventsAlias[0].getObject().path()) + } + + /** + * @UTS objects/unit/RTO24b2b/fires-once-per-dispatch-0 + */ + @Test + fun `RTO24b2b - subscription fires exactly once per dispatch`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + val events = mutableListOf() + // Subscribe at root (unlimited depth) — covers both [] and ["score"]. + root.subscribe(PathObjectListener { events.add(it) }) + + // MAP_SET on root with key "score" — candidates [] and ["score"]; root fires exactly once. + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf(buildMapSet("root", "score", dataObjectId("counter:new@2000"), "99", "remote")), + ), + ) + pollUntil(5.seconds) { events.size >= 1 } + + assertEquals(1, events.size) + } +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectTest.kt new file mode 100644 index 000000000..6a1ff3452 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PathObjectTest.kt @@ -0,0 +1,454 @@ +package io.ably.lib.uts.unit.liveobjects + +import io.ably.lib.liveobjects.path.PathObject +import io.ably.lib.uts.infra.pollUntil +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Derived from UTS `objects/unit/path_object.md` (RTPO1–RTPO14) — the typed `PathObject` read/navigation + * surface: `path()`, `get()` / `at()`, `value()`, `instance()`, `entries()` / `keys()` / `values()`, + * `size()`, `getType()`, and the compacted-snapshot accessor. + * + * ably-java implements the typed-SDK variant (RTTS), so the spec's single polymorphic `PathObject.value()` + * splits across typed `as*()` accessors, each returning `null` (never throwing) on a type mismatch (RTTS5d / + * RTTS6g). `root` (from `setupSyncedChannel`) is already a `LiveMapPathObject`, so `root.get(...)` needs no + * cast; deeper navigated nodes are `asLiveMap()`-ed before map ops. Number gotchas: counter `value()` is + * `Double` (100.0), primitive `asNumber().value()` is a boxed `Number` (normalise with `?.toDouble()`), + * `size()` is `Long` (7L). Three deviations recorded in `deviations.md`: + * - `get(non-string)` / `at(non-string)` failing with 40003 (RTPO5b / RTPO6b) is not expressible — the + * signatures take `@NotNull String`, so a non-string argument is a compile error. + * - `compact()` is not implemented (RTTS3f); `compactJson()` is the supported snapshot (RTPO13 / RTPO13b5 / + * RTPO13c, and the `compact()` sub-assertion of RTPO3c1). + * + * All tests use `setupSyncedChannel` (helpers.kt), which needs the SDK's OBJECT_SYNC processing + + * `RealtimeObject.get()` — still TODO — so these compile now and run once that lands (translate-only). + */ +class PathObjectTest { + + /** + * @UTS objects/unit/RTPO4/path-string-representation-0 + */ + @Test + fun `RTPO4 - path returns dot-delimited string`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + assertEquals("", root.path()) + assertEquals("profile", root.get("profile").path()) + assertEquals("profile.email", root.get("profile").asLiveMap().get("email").path()) + } + + /** + * @UTS objects/unit/RTPO4b/path-escapes-dots-0 + */ + @Test + fun `RTPO4b - path escapes dots in segments`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val po = root.get("a.b").asLiveMap().get("c") + + assertEquals("a\\.b.c", po.path()) + } + + /** + * @UTS objects/unit/RTPO5/get-appends-key-0 + */ + @Test + fun `RTPO5 - get returns new PathObject with appended key`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val child = root.get("profile") + val grandchild = child.asLiveMap().get("email") + + assertEquals("profile", child.path()) + assertEquals("profile.email", grandchild.path()) + assertTrue(child !== root) // RTPO5c: new PathObject, not the same instance as root + } + + /** + * @UTS objects/unit/RTPO5b/get-non-string-throws-0 + */ + @Test + fun `RTPO5b - get throws on non-string key`() = runTest { + setupSyncedChannel("test") + + // DEVIATION (RTPO5b): spec passes a non-string key (`root.get(123)`) and expects ErrorInfo 40003. + // ably-java's `LiveMapPathObject.get(@NotNull String)` only accepts a String, so a non-string + // argument is a compile error, not a runtime failure — the case is not expressible. See deviations.md. + } + + /** + * @UTS objects/unit/RTPO6/at-parses-path-0 + */ + @Test + fun `RTPO6 - at parses dot-delimited path`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val po = root.at("profile.email") + + assertEquals("profile.email", po.path()) + assertEquals("alice@example.com", po.asString().value()) + } + + /** + * @UTS objects/unit/RTPO6/at-escaped-dots-0 + */ + @Test + fun `RTPO6 - at respects escaped dots`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val po = root.at("a\\.b.c") // segments ["a.b", "c"] + + assertEquals("a\\.b.c", po.path()) + } + + /** + * @UTS objects/unit/RTPO6b/at-non-string-throws-0 + */ + @Test + fun `RTPO6b - at throws for non-string input`() = runTest { + setupSyncedChannel("test") + + // DEVIATION (RTPO6b): spec passes a non-string path (`root.at(123)`) and expects ErrorInfo 40003. + // ably-java's `LiveMapPathObject.at(@NotNull String)` only accepts a String, so a non-string argument + // is a compile error, not a runtime failure — the case is not expressible. See deviations.md. + } + + /** + * @UTS objects/unit/RTPO7/value-counter-0 + */ + @Test + fun `RTPO7 - value returns counter numeric value`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + // Counter value() is Double (RTPO7c -> LiveCounter#value); assert 100.0. + assertEquals(100.0, root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTPO7/value-primitive-0 + */ + @Test + fun `RTPO7 - value returns primitive value`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + assertEquals("Alice", root.get("name").asString().value()) + assertEquals(30.0, root.get("age").asNumber().value()?.toDouble()) + assertEquals(true, root.get("active").asBoolean().value()) + } + + /** + * @UTS objects/unit/RTPO7d/value-livemap-null-0 + */ + @Test + fun `RTPO7d - value returns null for LiveMap`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + // RTPO7e: a LiveMap has no scalar value; the typed counter/primitive accessors return null. + assertNull(root.get("profile").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTPO7e/value-unresolvable-null-0 + */ + @Test + fun `RTPO7e - value returns null on resolution failure`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + assertNull(root.get("nonexistent").asLiveMap().get("deep").asString().value()) + } + + /** + * @UTS objects/unit/RTPO7/value-bytes-0 + */ + @Test + fun `RTPO7 - value returns bytes for binary entry`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + // STANDARD_POOL_OBJECTS stores avatar as base64 "AQID" == bytes [1, 2, 3]. + assertEquals(listOf(1, 2, 3), root.get("avatar").asBinary().value()?.toList()) + } + + /** + * @UTS objects/unit/RTPO8/instance-live-object-0 + */ + @Test + fun `RTPO8 - instance returns Instance for LiveObject`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val counterInst = root.get("score").instance() + assertNotNull(counterInst) // RTPO8c: IS Instance + assertEquals("counter:score@1000", counterInst!!.asLiveCounter().id) + + val mapInst = root.get("profile").instance() + assertNotNull(mapInst) // RTPO8c: IS Instance + assertEquals("map:profile@1000", mapInst!!.asLiveMap().id) + } + + /** + * @UTS objects/unit/RTPO8c/instance-primitive-null-0 + */ + @Test + fun `RTPO8c - instance returns null for primitive`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + assertNull(root.get("name").instance()) + } + + /** + * @UTS objects/unit/RTPO9/entries-yields-pairs-0 + */ + @Test + fun `RTPO9 - entries returns key PathObject pairs`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val entries = mutableMapOf() + for ((key, pathObj) in root.entries()) { + entries[key] = pathObj.path() + } + + assertEquals("name", entries["name"]) + assertEquals("profile", entries["profile"]) + assertEquals(7, entries.size) + } + + /** + * @UTS objects/unit/RTPO9d/entries-non-map-empty-0 + */ + @Test + fun `RTPO9d - entries returns empty for non-LiveMap`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val entries = root.get("score").asLiveMap().entries().toList() + + assertEquals(0, entries.size) + } + + /** + * @UTS objects/unit/RTPO10/keys-returns-array-0 + */ + @Test + fun `RTPO10 - keys returns array of key strings`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val keys = root.keys().toList() + + assertEquals(7, keys.size) + assertTrue("name" in keys) + assertTrue("profile" in keys) + assertTrue("score" in keys) + } + + /** + * @UTS objects/unit/RTPO10d/keys-non-map-empty-0 + */ + @Test + fun `RTPO10d - keys returns empty for non-LiveMap`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val keys = root.get("score").asLiveMap().keys().toList() + + assertEquals(0, keys.size) + } + + /** + * @UTS objects/unit/RTPO11/values-returns-array-0 + */ + @Test + fun `RTPO11 - values returns array of PathObjects`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val vals = root.values().toList() + + assertEquals(7, vals.size) + // Each element is a PathObject whose path is the key. + val paths = mutableSetOf() + for (v in vals) { + paths.add(v.path()) + } + assertTrue("name" in paths) + assertTrue("profile" in paths) + assertTrue("score" in paths) + } + + /** + * @UTS objects/unit/RTPO11d/values-non-map-empty-0 + */ + @Test + fun `RTPO11d - values returns empty for non-LiveMap`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val vals = root.get("score").asLiveMap().values().toList() + + assertEquals(0, vals.size) + } + + /** + * @UTS objects/unit/RTPO12/size-count-0 + */ + @Test + fun `RTPO12 - size returns non-tombstoned count`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + assertEquals(7L, root.size()) + assertEquals(3L, root.get("profile").asLiveMap().size()) + } + + /** + * @UTS objects/unit/RTPO12c/size-non-map-null-0 + */ + @Test + fun `RTPO12c - size returns null for non-LiveMap`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + assertNull(root.get("score").asLiveMap().size()) + assertNull(root.get("name").asLiveMap().size()) + } + + /** + * @UTS objects/unit/RTPO13/compact-recursive-0 + */ + @Test + fun `RTPO13 - compact recursively compacts LiveMap tree`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + // DEVIATION (RTPO13): ably-java does not implement `compact()` (RTTS3f); `compactJson()` is the + // supported recursively-compacted snapshot. Binary is base64-encoded rather than raw bytes, so the + // avatar assertion checks the base64 string. Assertions navigate the JsonObject. See deviations.md. + val result = root.compactJson()!!.asJsonObject + + assertEquals("Alice", result.get("name").asString) + assertEquals(30, result.get("age").asInt) + assertEquals(true, result.get("active").asBoolean) + assertEquals(100, result.get("score").asInt) + assertEquals("a", result.getAsJsonObject("data").getAsJsonArray("tags").get(0).asString) + assertEquals("b", result.getAsJsonObject("data").getAsJsonArray("tags").get(1).asString) + assertEquals("AQID", result.get("avatar").asString) // base64 of bytes [1, 2, 3] + val profile = result.getAsJsonObject("profile") + assertEquals("alice@example.com", profile.get("email").asString) + assertEquals(5, profile.get("nested_counter").asInt) + assertEquals("dark", profile.getAsJsonObject("prefs").get("theme").asString) + } + + /** + * @UTS objects/unit/RTPO13b5/compact-cycle-detection-0 + */ + @Test + fun `RTPO13b5 - compact handles cycles via shared reference`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + + // Introduce a cycle: map:prefs@1000.back_ref points back at map:profile@1000. + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf(buildMapSet("map:prefs@1000", "back_ref", dataObjectId("map:profile@1000"), "99", "remote")), + ), + ) + pollUntil(5.seconds) { root.get("profile").asLiveMap().get("prefs").asLiveMap().get("back_ref").exists() } + + // DEVIATION (RTPO13b5): spec asserts `result["prefs"]["back_ref"] IS result` — native object identity + // from the unimplemented `compact()` (RTTS3f). `compactJson()` represents the cycle as an + // `{ "objectId": ... }` marker instead (see RTPO14), so the identity assertion is replaced by the + // objectId-marker assertion. See deviations.md. + val result = root.get("profile").compactJson()!!.asJsonObject + + assertEquals( + "map:profile@1000", + result.getAsJsonObject("prefs").getAsJsonObject("back_ref").get("objectId").asString, + ) + } + + /** + * @UTS objects/unit/RTPO13c/compact-counter-0 + */ + @Test + fun `RTPO13c - compact returns number for LiveCounter`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + // DEVIATION (RTPO13c): `compact()` is unimplemented (RTTS3f); `compactJson()` is used. A LiveCounter + // compacts to its numeric JSON value. See deviations.md. + assertEquals(100, root.get("score").compactJson()!!.asInt) + } + + /** + * @UTS objects/unit/RTPO14/compact-json-0 + */ + @Test + fun `RTPO14 - compactJson encodes cycles as objectId`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + + mockWs.sendToClient( + buildObjectMessage( + "test", + listOf(buildMapSet("map:prefs@1000", "back_ref", dataObjectId("map:profile@1000"), "99", "remote")), + ), + ) + pollUntil(5.seconds) { root.get("profile").asLiveMap().get("prefs").asLiveMap().get("back_ref").exists() } + + val result = root.get("profile").compactJson()!!.asJsonObject + + assertEquals( + "map:profile@1000", + result.getAsJsonObject("prefs").getAsJsonObject("back_ref").get("objectId").asString, + ) + } + + /** + * @UTS objects/unit/RTPO14/compact-json-bytes-0 + */ + @Test + fun `RTPO14 - compactJson encodes bytes as base64 string`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val result = root.compactJson()!!.asJsonObject + + assertEquals("AQID", result.get("avatar").asString) + } + + /** + * @UTS objects/unit/RTPO3/path-resolution-walk-0 + */ + @Test + fun `RTPO3 - path resolution walks through LiveMaps`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + // RTPO3b: empty path resolves to root (a LiveMap) -> no scalar value. + assertNull(root.asLiveCounter().value()) + assertEquals( + "dark", + root.get("profile").asLiveMap().get("prefs").asLiveMap().get("theme").asString().value(), + ) + } + + /** + * @UTS objects/unit/RTPO3a1/intermediate-not-map-0 + */ + @Test + fun `RTPO3a1 - resolution fails if intermediate is not LiveMap`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + // score resolves to a counter, so navigating past it fails to resolve -> read returns null. + assertNull(root.get("score").asLiveMap().get("something").asString().value()) + } + + /** + * @UTS objects/unit/RTPO3c1/read-null-on-failure-0 + */ + @Test + fun `RTPO3c1 - read operation returns null on resolution failure`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + val nonexistent: PathObject = root.get("nonexistent") + assertNull(nonexistent.asString().value()) + assertNull(nonexistent.instance()) + assertNull(nonexistent.asLiveMap().size()) + // DEVIATION (RTPO3c1): spec asserts `compact() == null` on resolution failure; `compact()` is + // unimplemented (RTTS3f), so `compactJson()` is asserted null instead. See deviations.md. + assertNull(nonexistent.compactJson()) + } +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PublicObjectMessageTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PublicObjectMessageTest.kt new file mode 100644 index 000000000..840e2821a --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/PublicObjectMessageTest.kt @@ -0,0 +1,367 @@ +package io.ably.lib.uts.unit.liveobjects + +import com.google.gson.JsonObject +import io.ably.lib.liveobjects.message.ObjectMessage +import io.ably.lib.liveobjects.message.ObjectOperationAction +import io.ably.lib.liveobjects.message.ObjectsMapSemantics +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * Derived from UTS `objects/unit/public_object_message.md` — construction of the public-facing + * `ObjectMessage` (PAOM3) and `ObjectOperation` (PAOOP3) from a source wire object message. + * + * Pure data-structure construction, no mocks. The spec's `PublicObjectMessage.fromObjectMessage(source, + * channel)` / `PublicObjectOperation.fromObjectOperation(op)` (PAOM3 / PAOOP3) are `internal` in + * `:liveobjects`; [buildPublicObjectMessage] (in `helpers.kt`) reaches them by reflection, so the source is + * built with the wire op builders (`buildMapSet`, `buildCounterInc`, …) and the public getters are asserted + * on the result. Spec `op.action == "MAP_SET"` (string tag) becomes the `ObjectOperationAction` enum + * constant; `op.mapCreate == null` becomes `assertNull`; getters read as Kotlin properties. + */ +class PublicObjectMessageTest { + + /** + * @UTS objects/unit/PAOM3/construction-all-fields-0 + */ + @Test + fun `PAOM3 - construction copies all fields from source ObjectMessage`() { + val extras = JsonObject().apply { addProperty("key", "value") } + // MAP_SET operation + every optional top-level field. The op builders cover serial/siteCode/the + // operation; the remaining top-level fields aren't builder params, so augment the wire JSON directly. + val source = buildMapSet("map:abc@1000", "name", dataString("Alice"), serial = "01", siteCode = "site1").apply { + addProperty("id", "msg-id-1") + addProperty("clientId", "client-1") + addProperty("connectionId", "conn-1") + addProperty("timestamp", 1700000000000L) + addProperty("serialTimestamp", 1700000001000L) + add("extras", extras) + } + + val publicMsg = buildPublicObjectMessage(source, "test-channel") + + assertEquals("msg-id-1", publicMsg.id) + assertEquals("client-1", publicMsg.clientId) + assertEquals("conn-1", publicMsg.connectionId) + assertEquals(1700000000000L, publicMsg.timestamp) + assertEquals("test-channel", publicMsg.channel) + assertEquals("01", publicMsg.serial) + assertEquals(1700000001000L, publicMsg.serialTimestamp) + assertEquals("site1", publicMsg.siteCode) + assertEquals(extras, publicMsg.extras) + assertNotNull(publicMsg.operation) + assertEquals(ObjectOperationAction.MAP_SET, publicMsg.operation.action) + assertEquals("map:abc@1000", publicMsg.operation.objectId) + assertEquals("name", publicMsg.operation.mapSet!!.key) + } + + /** + * @UTS objects/unit/PAOM3/construction-optional-fields-missing-0 + */ + @Test + fun `PAOM3 - construction with optional fields missing`() { + // Only the required operation present; all optional top-level fields absent. + val source = buildCounterInc("counter:abc@1000", 5) + + val publicMsg = buildPublicObjectMessage(source, "my-channel") + + assertNull(publicMsg.id) + assertNull(publicMsg.clientId) + assertNull(publicMsg.connectionId) + assertNull(publicMsg.timestamp) + assertEquals("my-channel", publicMsg.channel) + assertNull(publicMsg.serial) + assertNull(publicMsg.serialTimestamp) + assertNull(publicMsg.siteCode) + assertNull(publicMsg.extras) + assertNotNull(publicMsg.operation) + assertEquals(ObjectOperationAction.COUNTER_INC, publicMsg.operation.action) + } + + /** + * @UTS objects/unit/PAOM3/channel-from-channel-name-0 + */ + @Test + fun `PAOM3b - channel is set from channel name not from ObjectMessage`() { + val source = buildObjectDelete("counter:abc@1000") + + val publicMsg = buildPublicObjectMessage(source, "different-channel-name") + + assertEquals("different-channel-name", publicMsg.channel) + } + + /** + * @UTS objects/unit/PAOOP3/map-set-copies-fields-0 + */ + @Test + fun `PAOOP3a - MAP_SET operation copies mapSet, omits unrelated fields`() { + val source = buildMapSet("map:abc@1000", "color", dataString("blue")) + + val op = buildPublicObjectMessage(source, "test-channel").operation + + assertEquals(ObjectOperationAction.MAP_SET, op.action) + assertEquals("map:abc@1000", op.objectId) + assertEquals("color", op.mapSet!!.key) + assertEquals("blue", op.mapSet!!.value.string) + assertNull(op.mapCreate) + assertNull(op.mapRemove) + assertNull(op.counterCreate) + assertNull(op.counterInc) + assertNull(op.objectDelete) + assertNull(op.mapClear) + } + + /** + * @UTS objects/unit/PAOOP3/map-remove-copies-fields-0 + */ + @Test + fun `PAOOP3a - MAP_REMOVE operation copies mapRemove, omits unrelated fields`() { + val source = buildMapRemove("map:abc@1000", "old-key") + + val op = buildPublicObjectMessage(source, "test-channel").operation + + assertEquals(ObjectOperationAction.MAP_REMOVE, op.action) + assertEquals("map:abc@1000", op.objectId) + assertEquals("old-key", op.mapRemove!!.key) + assertNull(op.mapCreate) + assertNull(op.mapSet) + assertNull(op.counterCreate) + assertNull(op.counterInc) + assertNull(op.objectDelete) + assertNull(op.mapClear) + } + + /** + * @UTS objects/unit/PAOOP3/counter-inc-copies-fields-0 + */ + @Test + fun `PAOOP3a - COUNTER_INC operation copies counterInc, omits unrelated fields`() { + val source = buildCounterInc("counter:abc@1000", 42) + + val op = buildPublicObjectMessage(source, "test-channel").operation + + assertEquals(ObjectOperationAction.COUNTER_INC, op.action) + assertEquals("counter:abc@1000", op.objectId) + assertEquals(42.0, op.counterInc!!.number) + assertNull(op.mapCreate) + assertNull(op.mapSet) + assertNull(op.mapRemove) + assertNull(op.counterCreate) + assertNull(op.objectDelete) + assertNull(op.mapClear) + } + + /** + * @UTS objects/unit/PAOOP3/object-delete-copies-fields-0 + */ + @Test + fun `PAOOP3a - OBJECT_DELETE operation copies objectDelete, omits unrelated fields`() { + val source = buildObjectDelete("counter:abc@1000") + + val op = buildPublicObjectMessage(source, "test-channel").operation + + assertEquals(ObjectOperationAction.OBJECT_DELETE, op.action) + assertEquals("counter:abc@1000", op.objectId) + assertNotNull(op.objectDelete) + assertNull(op.mapCreate) + assertNull(op.mapSet) + assertNull(op.mapRemove) + assertNull(op.counterCreate) + assertNull(op.counterInc) + assertNull(op.mapClear) + } + + /** + * @UTS objects/unit/PAOOP3/map-clear-copies-fields-0 + */ + @Test + fun `PAOOP3a - MAP_CLEAR operation copies mapClear, omits unrelated fields`() { + val source = buildMapClear("map:abc@1000") + + val op = buildPublicObjectMessage(source, "test-channel").operation + + assertEquals(ObjectOperationAction.MAP_CLEAR, op.action) + assertEquals("map:abc@1000", op.objectId) + assertNotNull(op.mapClear) + assertNull(op.mapCreate) + assertNull(op.mapSet) + assertNull(op.mapRemove) + assertNull(op.counterCreate) + assertNull(op.counterInc) + assertNull(op.objectDelete) + } + + /** + * @UTS objects/unit/PAOOP3/map-create-direct-0 + */ + @Test + fun `PAOOP3b1 - MAP_CREATE with mapCreate directly present`() { + val source = buildMapCreate( + "map:new@2000", + mapState(linkedMapOf("key1" to mapEntry(dataString("val1")))), + ) + + val op = buildPublicObjectMessage(source, "test-channel").operation + + assertEquals(ObjectOperationAction.MAP_CREATE, op.action) + assertEquals("map:new@2000", op.objectId) + assertNotNull(op.mapCreate) + assertEquals(ObjectsMapSemantics.LWW, op.mapCreate!!.semantics) + assertEquals("val1", op.mapCreate!!.entries["key1"]!!.data!!.string) + assertNull(op.counterCreate) + } + + /** + * @UTS objects/unit/PAOOP3/map-create-from-with-object-id-0 + */ + @Test + fun `PAOOP3b2 - MAP_CREATE resolved from mapCreateWithObjectId`() { + // The source carries mapCreateWithObjectId (no direct mapCreate); the public op must resolve + // mapCreate from the MapCreate the WithObjectId variant was derived from. In ably-java that derived + // form (WireMapCreateWithObjectId.derivedFrom) is @Transient/outbound-only and never arrives over the + // wire, so it can't be carried by the wire-JSON helper — reconstruct it reflectively (see + // publicMessageWithDerivedCreate). + val withObjectId = JsonObject().apply { + add( + "operation", + JsonObject().apply { + addProperty("action", 0) // MAP_CREATE wire code + addProperty("objectId", "map:derived@3000") + add( + "mapCreateWithObjectId", + JsonObject().apply { + addProperty("initialValue", "stub-initial-value") + addProperty("nonce", "stub-nonce") + }, + ) + }, + ) + } + val derived = buildMapCreate( + "map:derived@3000", + mapState(linkedMapOf("x" to mapEntry(dataNumber(10)))), + ) + + val op = publicMessageWithDerivedCreate(withObjectId, derived, "test-channel").operation + + assertEquals(ObjectOperationAction.MAP_CREATE, op.action) + assertEquals("map:derived@3000", op.objectId) + assertNotNull(op.mapCreate) + assertEquals(ObjectsMapSemantics.LWW, op.mapCreate!!.semantics) + assertEquals(10.0, op.mapCreate!!.entries["x"]!!.data!!.number) + assertNull(op.counterCreate) + } + + /** + * @UTS objects/unit/PAOOP3/counter-create-from-with-object-id-0 + */ + @Test + fun `PAOOP3c2 - COUNTER_CREATE resolved from counterCreateWithObjectId`() { + // As PAOOP3b2 but for the counter variant — counterCreate resolved from the derived CounterCreate. + val withObjectId = JsonObject().apply { + add( + "operation", + JsonObject().apply { + addProperty("action", 3) // COUNTER_CREATE wire code + addProperty("objectId", "counter:derived@3000") + add( + "counterCreateWithObjectId", + JsonObject().apply { + addProperty("initialValue", "stub-initial-value") + addProperty("nonce", "stub-nonce") + }, + ) + }, + ) + } + val derived = buildCounterCreate("counter:derived@3000", counterState(100)) + + val op = publicMessageWithDerivedCreate(withObjectId, derived, "test-channel").operation + + assertEquals(ObjectOperationAction.COUNTER_CREATE, op.action) + assertEquals("counter:derived@3000", op.objectId) + assertNotNull(op.counterCreate) + assertEquals(100.0, op.counterCreate!!.count) + assertNull(op.mapCreate) + } + + /** + * @UTS objects/unit/PAOOP3/create-payloads-omitted-0 + */ + @Test + fun `PAOOP3b3, PAOOP3c3 - create payloads omitted when neither variant is present`() { + val source = buildMapSet("map:abc@1000", "k", dataString("v")) + + val op = buildPublicObjectMessage(source, "test-channel").operation + + assertNull(op.mapCreate) + assertNull(op.counterCreate) + } + + /** + * @UTS objects/unit/PAOOP3/only-relevant-field-per-action-0 + */ + @Test + fun `PAOOP3 - only the relevant operation field is present per action type`() { + val source = buildCounterCreate("counter:new@2000", counterState(50)) + + val op = buildPublicObjectMessage(source, "test-channel").operation + + assertEquals(ObjectOperationAction.COUNTER_CREATE, op.action) + assertEquals("counter:new@2000", op.objectId) + assertNotNull(op.counterCreate) + assertEquals(50.0, op.counterCreate!!.count) + assertNull(op.mapCreate) + assertNull(op.mapSet) + assertNull(op.mapRemove) + assertNull(op.counterInc) + assertNull(op.objectDelete) + assertNull(op.mapClear) + } +} + +/** + * Builds a public [ObjectMessage] whose operation carries a `*CreateWithObjectId` variant resolved to its + * derived `MapCreate` / `CounterCreate` (PAOOP3b2 / PAOOP3c2). + * + * Why this exists: `WireMapCreateWithObjectId.derivedFrom` / `WireCounterCreateWithObjectId.derivedFrom` are + * `@Transient` — populated only when the SDK constructs an outbound create operation locally, never + * deserialized from the wire. `buildPublicObjectMessage`'s wire-JSON path therefore cannot carry it. This + * helper reconstructs it: it deserializes [withObjectIdMessage] to its internal `WireObjectMessage`, + * manufactures the derived `WireMapCreate` / `WireCounterCreate` by deserializing [derivedCreateMessage] + * (a normal direct-create message), grafts it onto the WithObjectId variant's `derivedFrom` field, then + * builds the public message via the same `DefaultObjectMessage(WireObjectMessage, String)` constructor that + * `buildPublicObjectMessage` uses. All access is by reflection because the wire/Default types are `internal` + * to `:liveobjects` (runtime-only on the uts classpath). + */ +private fun publicMessageWithDerivedCreate( + withObjectIdMessage: JsonObject, + derivedCreateMessage: JsonObject, + channelName: String, +): ObjectMessage { + val serializationKt = Class.forName("io.ably.lib.liveobjects.serialization.JsonSerializationKt") + val toWire = serializationKt.getMethod("toObjectMessage", JsonObject::class.java) + val mainWire = toWire.invoke(null, withObjectIdMessage) + val derivedWire = toWire.invoke(null, derivedCreateMessage) + + val operationField = mainWire.javaClass.getDeclaredField("operation").apply { isAccessible = true } + val mainOp = operationField.get(mainWire) + val derivedOp = operationField.get(derivedWire) + + fun graft(withObjectIdFieldName: String, derivedCreateFieldName: String) { + val withObjectId = mainOp.javaClass.getDeclaredField(withObjectIdFieldName) + .apply { isAccessible = true }.get(mainOp) ?: return + val derivedCreate = derivedOp.javaClass.getDeclaredField(derivedCreateFieldName) + .apply { isAccessible = true }.get(derivedOp) + withObjectId.javaClass.getDeclaredField("derivedFrom") + .apply { isAccessible = true }.set(withObjectId, derivedCreate) + } + graft("mapCreateWithObjectId", "mapCreate") + graft("counterCreateWithObjectId", "counterCreate") + + val wireClass = Class.forName("io.ably.lib.liveobjects.message.WireObjectMessage") + return Class.forName("io.ably.lib.liveobjects.message.DefaultObjectMessage") + .getConstructor(wireClass, String::class.java) + .newInstance(mainWire, channelName) as ObjectMessage +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/RealtimeObjectTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/RealtimeObjectTest.kt new file mode 100644 index 000000000..6fac9053a --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/RealtimeObjectTest.kt @@ -0,0 +1,1091 @@ +package io.ably.lib.uts.unit.liveobjects + +import io.ably.lib.liveobjects.path.PathObjectListener +import io.ably.lib.liveobjects.path.PathObjectSubscriptionEvent +import io.ably.lib.liveobjects.path.PathObjectSubscriptionOptions +import io.ably.lib.liveobjects.state.ObjectStateChange +import io.ably.lib.liveobjects.state.ObjectStateEvent +import io.ably.lib.liveobjects.value.LiveMapValue +import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.ChannelState +import io.ably.lib.types.AblyException +import io.ably.lib.types.ChannelMode +import io.ably.lib.types.ChannelOptions +import io.ably.lib.types.ErrorInfo +import io.ably.lib.types.ProtocolMessage +import io.ably.lib.types.PublishResult +import io.ably.lib.uts.infra.awaitChannelState +import io.ably.lib.uts.infra.pollUntil +import io.ably.lib.uts.infra.unit.ConnectionDetails +import io.ably.lib.uts.infra.unit.FakeClock +import io.ably.lib.uts.infra.unit.MockWebSocket +import io.ably.lib.uts.infra.unit.TestRealtimeClient +import kotlinx.coroutines.future.await +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Derived from UTS `objects/unit/realtime_object.md` (RTO2, RTO10, RTO15, RTO17–RTO20, RTO22–RTO26) — the + * `RealtimeObject` entry point (`channel.object`): the `get()` root accessor, its access/write/mode + * preconditions, publish-and-apply local-apply / echo-dedup behaviour, the sync-state event API + * (`on(SYNCING/SYNCED)` / `off` / `unsubscribe`), the single subscription register, depth coverage, and GC. + * + * This is a **mixed** spec (mapping §13). The public-API parts translate directly: + * - `channel.object.get()` (§2) → `CompletableFuture`, awaited with `.await()`. + * - Precondition failures (§12): `40024` (missing OBJECT_SUBSCRIBE/OBJECT_PUBLISH mode), `90001` + * (DETACHED/FAILED channel), `92008` (channel leaves ATTACHED while awaiting SYNCED), `40000` + * (`echoMessages` false) — all `AblyException` with those int codes. + * - Sync-state events (§9): `channel.object.on(ObjectStateEvent.SYNCING/SYNCED, listener)` returning a + * `Subscription`, `off(listener)`, and `Subscription.unsubscribe()`. + * - publishAndApply (RTO20) and GC (RTO10) effects are asserted **observably** through the public read API + * (counter `value()`), since the apply/echo/dedup/GC machinery is internal (§13). + * + * The one internal-only case is `publish` (RTO15): `channel.object.publish(...)` is marked `internal` in the + * IDL and its OBJECT/ACK wire-message assertions reach `ProtocolMessage.state` wire objects (§13). There is + * no public `publish` on `RealtimeObject`, so that test is a documented deviation (see deviations.md). + * + * Most tests use `setupSyncedChannel` (helpers.kt), which needs the SDK's OBJECT_SYNC processing + + * `RealtimeObject.get()` — still TODO — so these compile now and run once that lands (translate-only). + */ +class RealtimeObjectTest { + + private fun connected(withSiteCode: Boolean = true, gcGracePeriodMs: Long? = 86_400_000L): ProtocolMessage = + ProtocolMessage(ProtocolMessage.Action.connected).apply { + connectionId = "conn-1" + connectionDetails = ConnectionDetails { + connectionKey = "key-1" + if (withSiteCode) siteCode = "test-site" + gcGracePeriodMs?.let { objectsGCGracePeriod = it } + } + } + + /** + * @UTS objects/unit/RTO23/get-returns-path-object-0 + */ + @Test + fun `RTO23d - get returns PathObject wrapping root`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + // root IS PathObject (always a LiveMapPathObject, RTO23f); path is the empty list -> "". + assertEquals("", root.path()) + } + + /** + * @UTS objects/unit/RTO23a/get-requires-subscribe-mode-0 + */ + @Test + fun `RTO23a - get requires OBJECT_SUBSCRIBE mode`() = runTest { + lateinit var mockWs: MockWebSocket + mockWs = MockWebSocket { + onConnectionAttempt = { it.respondWithSuccess(connected(gcGracePeriodMs = null)) } + onMessageFromClient = { msg -> + if (msg.action == ProtocolMessage.Action.attach) { + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + channel = msg.channel + channelSerial = "sync1:" + setFlag(ProtocolMessage.Flag.has_objects) + }, + ) + } + } + } + val client = TestRealtimeClient { key = "fake:key"; install(mockWs) } + val channel = client.channels.get( + "test", + ChannelOptions().apply { modes = arrayOf(ChannelMode.object_publish) }, + ) + + val ex = assertFailsWith { channel.`object`.get().await() } + assertEquals(40024, ex.errorInfo.code) + } + + /** + * @UTS objects/unit/RTO23b/get-throws-detached-0 + */ + @Test + fun `RTO23b - get throws on DETACHED channel`() = runTest { + lateinit var mockWs: MockWebSocket + mockWs = MockWebSocket { + onConnectionAttempt = { it.respondWithSuccess(connected(gcGracePeriodMs = null)) } + onMessageFromClient = { msg -> + when (msg.action) { + ProtocolMessage.Action.attach -> { + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + channel = msg.channel + channelSerial = "sync1:" + setFlag(ProtocolMessage.Flag.has_objects) + }, + ) + mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } + ProtocolMessage.Action.detach -> + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.detached).apply { channel = msg.channel }, + ) + else -> Unit + } + } + } + val client = TestRealtimeClient { key = "fake:key"; install(mockWs) } + val channel = client.channels.get( + "test", + ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe) }, + ) + + channel.`object`.get().await() + channel.detach() + awaitChannelState(channel, ChannelState.detached) + + val ex = assertFailsWith { channel.`object`.get().await() } + assertEquals(90001, ex.errorInfo.code) + assertEquals(400, ex.errorInfo.statusCode) + } + + /** + * @UTS objects/unit/RTO23c/get-waits-for-synced-0 + */ + @Test + fun `RTO23c - get waits for SYNCED state`() = runTest { + var attachSent = false + lateinit var mockWs: MockWebSocket + mockWs = MockWebSocket { + onConnectionAttempt = { it.respondWithSuccess(connected()) } + onMessageFromClient = { msg -> + if (msg.action == ProtocolMessage.Action.attach) { + attachSent = true + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + channel = msg.channel + channelSerial = "sync1:cursor" + setFlag(ProtocolMessage.Flag.has_objects) + }, + ) + } + } + } + val client = TestRealtimeClient { key = "fake:key"; install(mockWs) } + val channel = client.channels.get( + "test", + ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) }, + ) + + val getFuture = channel.`object`.get() + pollUntil(5.seconds) { attachSent } + + mockWs.sendToClient(buildObjectSyncMessage("test", "sync1:", STANDARD_POOL_OBJECTS)) + + val root = getFuture.await() + assertEquals("", root.path()) + } + + /** + * @UTS objects/unit/RTO15/publish-sends-object-pm-0 + */ + @Test + fun `RTO15 - publish sends OBJECT ProtocolMessage`() = runTest { + // DEVIATION (RTO15): the spec calls the internal `channel.object.publish([...])` and asserts on the + // captured OBJECT ProtocolMessage's wire form (action/channel/state) and the PublishResult.serials + // from the ACK. ably-java's `RealtimeObject` exposes no public `publish` method (RTO15 is `internal` + // in the IDL), and the wire `state` objects + ACK PublishResult are internal `:liveobjects` types + // not reachable through the public API (mapping §13). Not expressible against the public surface. + // See deviations.md. + } + + /** + * @UTS objects/unit/RTO20/publish-and-apply-local-0 + */ + @Test + fun `RTO20 - publishAndApply applies locally on ACK`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().increment(10).await() + + assertEquals(110.0, root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTO20c/missing-site-code-0 + */ + @Test + fun `RTO20c - publishAndApply logs error when siteCode missing`() = runTest { + lateinit var mockWs: MockWebSocket + mockWs = MockWebSocket { + onConnectionAttempt = { it.respondWithSuccess(connected(withSiteCode = false)) } + onMessageFromClient = { msg -> + when (msg.action) { + ProtocolMessage.Action.attach -> { + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + channel = msg.channel + channelSerial = "sync1:" + setFlag(ProtocolMessage.Flag.has_objects) + }, + ) + mockWs.sendToClient(buildObjectSyncMessage("test", "sync1:", STANDARD_POOL_OBJECTS)) + } + ProtocolMessage.Action.`object` -> + mockWs.sendToClient(buildAckMessage(msg.msgSerial, listOf("serial-0"))) + else -> Unit + } + } + } + val client = TestRealtimeClient { key = "fake:key"; install(mockWs) } + val channel = client.channels.get( + "test", + ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) }, + ) + val root = channel.`object`.get().await() + + root.get("score").asLiveCounter().increment(10).await() + + // With no siteCode in ConnectionDetails, the synthetic message cannot be applied locally (RTO20c1), + // so the score stays at its synced value of 100. + assertEquals(100.0, root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTO20d1/null-serial-skipped-0 + */ + @Test + fun `RTO20d1 - null serial in PublishResult is skipped`() = runTest { + lateinit var mockWs: MockWebSocket + mockWs = MockWebSocket { + onConnectionAttempt = { it.respondWithSuccess(connected()) } + onMessageFromClient = { msg -> + when (msg.action) { + ProtocolMessage.Action.attach -> { + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + channel = msg.channel + channelSerial = "sync1:" + setFlag(ProtocolMessage.Flag.has_objects) + }, + ) + mockWs.sendToClient(buildObjectSyncMessage("test", "sync1:", STANDARD_POOL_OBJECTS)) + } + ProtocolMessage.Action.`object` -> + // A single null serial in the PublishResult — built directly since the + // buildAckMessage helper only accepts non-null serials. + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.ack).apply { + msgSerial = msg.msgSerial + res = arrayOf(PublishResult(arrayOf(null))) + }, + ) + else -> Unit + } + } + } + val client = TestRealtimeClient { key = "fake:key"; install(mockWs) } + val channel = client.channels.get( + "test", + ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) }, + ) + val root = channel.`object`.get().await() + + root.get("score").asLiveCounter().increment(10).await() + + // Null serial in the PublishResult means the synthetic message is skipped (RTO20d1), so the local + // apply does not happen and the score stays at the synced value of 100. + assertEquals(100.0, root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTO20e/waits-for-synced-0 + */ + @Test + fun `RTO20e - publishAndApply waits for SYNCED during SYNCING`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + + // Begin a new sync (channel re-ATTACHED with a new cursor and HAS_OBJECTS). + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + this.channel = "test" + channelSerial = "sync2:cursor" + setFlag(ProtocolMessage.Flag.has_objects) + }, + ) + + val incFuture = root.get("score").asLiveCounter().increment(10) + + // Complete the sync — the pending increment can now apply. + mockWs.sendToClient(buildObjectSyncMessage("test", "sync2:", STANDARD_POOL_OBJECTS)) + + incFuture.await() + assertEquals(110.0, root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTO20e1/fails-on-channel-failed-0 + */ + @Test + fun `RTO20e1 - publishAndApply fails when channel enters FAILED during sync wait`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + this.channel = "test" + channelSerial = "sync2:cursor" + setFlag(ProtocolMessage.Flag.has_objects) + }, + ) + + val incFuture = root.get("score").asLiveCounter().increment(10) + + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.detached).apply { + this.channel = "test" + error = ErrorInfo("Channel detached", 400, 90000) + }, + ) + + val ex = assertFailsWith { incFuture.await() } + assertEquals(92008, ex.errorInfo.code) + } + + /** + * @UTS objects/unit/RTO17/sync-state-events-0 + */ + @Test + fun `RTO17 RTO18 - Sync state events`() = runTest { + lateinit var mockWs: MockWebSocket + mockWs = MockWebSocket { + onConnectionAttempt = { it.respondWithSuccess(connected()) } + onMessageFromClient = { msg -> + if (msg.action == ProtocolMessage.Action.attach) { + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + channel = msg.channel + channelSerial = "sync1:cursor" + setFlag(ProtocolMessage.Flag.has_objects) + }, + ) + } + } + } + val client = TestRealtimeClient { key = "fake:key"; install(mockWs) } + val channel = client.channels.get( + "test", + ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) }, + ) + + val events = mutableListOf() + channel.`object`.on(ObjectStateEvent.SYNCING, ObjectStateChange.Listener { events.add("SYNCING") }) + channel.`object`.on(ObjectStateEvent.SYNCED, ObjectStateChange.Listener { events.add("SYNCED") }) + + val getFuture = channel.`object`.get() + pollUntil(5.seconds) { events.size >= 1 } + + mockWs.sendToClient(buildObjectSyncMessage("test", "sync1:", STANDARD_POOL_OBJECTS)) + getFuture.await() + + pollUntil(5.seconds) { events.size >= 2 } + assertEquals(listOf("SYNCING", "SYNCED"), events) + } + + /** + * @UTS objects/unit/RTO18d/duplicate-listener-0 + */ + @Test + fun `RTO18d - Duplicate listener registered twice fires twice`() = runTest { + val (_, channel, _, mockWs) = setupSyncedChannel("test") + var callCount = 0 + val listener = ObjectStateChange.Listener { callCount++ } + channel.`object`.on(ObjectStateEvent.SYNCED, listener) + channel.`object`.on(ObjectStateEvent.SYNCED, listener) + + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + this.channel = "test" + channelSerial = "sync2:cursor" + setFlag(ProtocolMessage.Flag.has_objects) + }, + ) + mockWs.sendToClient(buildObjectSyncMessage("test", "sync2:", STANDARD_POOL_OBJECTS)) + + pollUntil(5.seconds) { callCount >= 2 } + assertEquals(2, callCount) + } + + /** + * @UTS objects/unit/RTO19/off-deregisters-0 + */ + @Test + fun `RTO19 - off deregisters listener`() = runTest { + val (_, channel, _, mockWs) = setupSyncedChannel("test") + var callCount = 0 + val listener = ObjectStateChange.Listener { callCount++ } + // The spec's `sub.off()` maps to the returned Subscription's unsubscribe() (§9). + val sub = channel.`object`.on(ObjectStateEvent.SYNCED, listener) + sub.unsubscribe() + + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + this.channel = "test" + channelSerial = "sync2:cursor" + setFlag(ProtocolMessage.Flag.has_objects) + }, + ) + mockWs.sendToClient(buildObjectSyncMessage("test", "sync2:", STANDARD_POOL_OBJECTS)) + + assertEquals(0, callCount) + } + + /** + * @UTS objects/unit/RTO2/mode-enforcement-0 + */ + @Test + fun `RTO2 - Channel mode enforcement`() = runTest { + lateinit var mockWs: MockWebSocket + mockWs = MockWebSocket { + onConnectionAttempt = { it.respondWithSuccess(connected()) } + onMessageFromClient = { msg -> + if (msg.action == ProtocolMessage.Action.attach) { + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + channel = msg.channel + channelSerial = "sync1:" + setFlag(ProtocolMessage.Flag.has_objects) + // Server grants only OBJECT_SUBSCRIBE (RTO2a checks granted modes when ATTACHED); + // granted modes are carried as flag bits, not a `modes` field, on ProtocolMessage. + setFlag(ProtocolMessage.Flag.object_subscribe) + }, + ) + mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } + } + } + val client = TestRealtimeClient { key = "fake:key"; install(mockWs) } + val channel = client.channels.get( + "test", + ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) }, + ) + val root = channel.`object`.get().await() + + val ex = assertFailsWith { root.set("name", LiveMapValue.of("Bob")).await() } + assertEquals(40024, ex.errorInfo.code) + } + + /** + * @UTS objects/unit/RTO25a/access-requires-subscribe-mode-0 + */ + @Test + fun `RTO25a - Access API requires OBJECT_SUBSCRIBE mode`() = runTest { + lateinit var mockWs: MockWebSocket + mockWs = MockWebSocket { + onConnectionAttempt = { it.respondWithSuccess(connected()) } + onMessageFromClient = { msg -> + if (msg.action == ProtocolMessage.Action.attach) { + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + channel = msg.channel + channelSerial = "sync1:" + setFlag(ProtocolMessage.Flag.has_objects) + setFlag(ProtocolMessage.Flag.object_publish) + }, + ) + mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } + } + } + val client = TestRealtimeClient { key = "fake:key"; install(mockWs) } + val channel = client.channels.get( + "test", + ChannelOptions().apply { modes = arrayOf(ChannelMode.object_publish) }, + ) + + val ex = assertFailsWith { channel.`object`.get().await() } + assertEquals(40024, ex.errorInfo.code) + assertEquals(400, ex.errorInfo.statusCode) + } + + /** + * @UTS objects/unit/RTO25b/access-throws-detached-0 + */ + @Test + fun `RTO25b - Access API throws on DETACHED channel`() = runTest { + lateinit var mockWs: MockWebSocket + mockWs = MockWebSocket { + onConnectionAttempt = { it.respondWithSuccess(connected(gcGracePeriodMs = null)) } + onMessageFromClient = { msg -> + when (msg.action) { + ProtocolMessage.Action.attach -> { + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + channel = msg.channel + channelSerial = "sync1:" + setFlag(ProtocolMessage.Flag.has_objects) + }, + ) + mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } + ProtocolMessage.Action.detach -> + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.detached).apply { channel = msg.channel }, + ) + else -> Unit + } + } + } + val client = TestRealtimeClient { key = "fake:key"; install(mockWs) } + val channel = client.channels.get( + "test", + ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe) }, + ) + + channel.`object`.get().await() + channel.detach() + awaitChannelState(channel, ChannelState.detached) + + val ex = assertFailsWith { channel.`object`.get().await() } + assertEquals(90001, ex.errorInfo.code) + assertEquals(400, ex.errorInfo.statusCode) + } + + /** + * @UTS objects/unit/RTO25b/access-throws-failed-0 + */ + @Test + fun `RTO25b - Access API throws on FAILED channel`() = runTest { + lateinit var mockWs: MockWebSocket + mockWs = MockWebSocket { + onConnectionAttempt = { it.respondWithSuccess(connected(gcGracePeriodMs = null)) } + onMessageFromClient = { msg -> + if (msg.action == ProtocolMessage.Action.attach) { + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.error).apply { + channel = msg.channel + error = ErrorInfo("Channel error", 400, 90000) + }, + ) + } + } + } + val client = TestRealtimeClient { key = "fake:key"; install(mockWs) } + val channel = client.channels.get( + "test", + ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe) }, + ) + + channel.attach() + awaitChannelState(channel, ChannelState.failed) + + val ex = assertFailsWith { channel.`object`.get().await() } + assertEquals(90001, ex.errorInfo.code) + assertEquals(400, ex.errorInfo.statusCode) + } + + /** + * @UTS objects/unit/RTO26a/write-requires-publish-mode-0 + */ + @Test + fun `RTO26a - Write API requires OBJECT_PUBLISH mode`() = runTest { + lateinit var mockWs: MockWebSocket + mockWs = MockWebSocket { + onConnectionAttempt = { it.respondWithSuccess(connected()) } + onMessageFromClient = { msg -> + if (msg.action == ProtocolMessage.Action.attach) { + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + channel = msg.channel + channelSerial = "sync1:" + setFlag(ProtocolMessage.Flag.has_objects) + setFlag(ProtocolMessage.Flag.object_subscribe) + }, + ) + mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } + } + } + val client = TestRealtimeClient { key = "fake:key"; install(mockWs) } + val channel = client.channels.get( + "test", + ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe) }, + ) + val root = channel.`object`.get().await() + + val ex = assertFailsWith { + root.set("name", LiveMapValue.of("Bob")).await() + } + assertEquals(40024, ex.errorInfo.code) + assertEquals(400, ex.errorInfo.statusCode) + } + + /** + * @UTS objects/unit/RTO26b/write-throws-detached-0 + */ + @Test + fun `RTO26b - Write API throws on DETACHED channel`() = runTest { + val (_, channel, root, mockWs) = setupSyncedChannel("test") + + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.detached).apply { + this.channel = "test" + error = ErrorInfo("Channel detached", 400, 90000) + }, + ) + awaitChannelState(channel, ChannelState.detached) + + val ex = assertFailsWith { + root.set("name", LiveMapValue.of("Bob")).await() + } + assertEquals(90001, ex.errorInfo.code) + assertEquals(400, ex.errorInfo.statusCode) + } + + /** + * @UTS objects/unit/RTO26b/write-throws-failed-0 + */ + @Test + fun `RTO26b - Write API throws on FAILED channel`() = runTest { + val (_, channel, root, mockWs) = setupSyncedChannel("test") + + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.error).apply { + this.channel = "test" + error = ErrorInfo("Channel error", 400, 90000) + }, + ) + awaitChannelState(channel, ChannelState.failed) + + val ex = assertFailsWith { + root.set("name", LiveMapValue.of("Bob")).await() + } + assertEquals(90001, ex.errorInfo.code) + assertEquals(400, ex.errorInfo.statusCode) + } + + /** + * @UTS objects/unit/RTO26c/write-throws-echo-disabled-0 + */ + @Test + fun `RTO26c - Write API throws when echoMessages is false`() = runTest { + lateinit var mockWs: MockWebSocket + mockWs = MockWebSocket { + onConnectionAttempt = { it.respondWithSuccess(connected()) } + onMessageFromClient = { msg -> + if (msg.action == ProtocolMessage.Action.attach) { + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + channel = msg.channel + channelSerial = "sync1:" + setFlag(ProtocolMessage.Flag.has_objects) + }, + ) + mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } + } + } + val client = TestRealtimeClient { + key = "fake:key" + echoMessages = false + install(mockWs) + } + val channel = client.channels.get( + "test", + ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) }, + ) + val root = channel.`object`.get().await() + + val ex = assertFailsWith { + root.set("name", LiveMapValue.of("Bob")).await() + } + assertEquals(40000, ex.errorInfo.code) + assertEquals(400, ex.errorInfo.statusCode) + } + + /** + * @UTS objects/unit/RTO24a/single-register-instance-0 + */ + @Test + fun `RTO24a - RealtimeObject maintains a single PathObjectSubscriptionRegister`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + + val eventsRoot = mutableListOf() + val eventsScore = mutableListOf() + + root.subscribe(PathObjectListener { eventsRoot.add(it) }) + val scorePath = root.get("score") + scorePath.subscribe(PathObjectListener { eventsScore.add(it) }) + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 5, "s:1", "aaa"))), + ) + pollUntil(5.seconds) { eventsScore.size >= 1 } + + // Both subscriptions are managed by the same register and both fire. + assertTrue(eventsRoot.size >= 1) + assertTrue(eventsScore.size >= 1) + } + + /** + * @UTS objects/unit/RTO24c1/coverage-prefix-depth-0 + */ + @Test + fun `RTO24c1 - Subscription coverage prefix match with depth constraint`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + + val shallowEvents = mutableListOf() + val deepEvents = mutableListOf() + + // depth 1 — covers root and immediate children only. + root.subscribe(PathObjectListener { shallowEvents.add(it) }, PathObjectSubscriptionOptions(1)) + // no depth limit — covers everything. + root.subscribe(PathObjectListener { deepEvents.add(it) }) + + // Update a direct child of root (path ["score"]) — depth 1 from root. + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 5, "s:1", "aaa"))), + ) + pollUntil(5.seconds) { deepEvents.size >= 1 } + + // Update a nested object (path ["profile", "nested_counter"]) — depth 2 from root. + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:nested@1000", 1, "s:2", "aaa"))), + ) + pollUntil(5.seconds) { deepEvents.size >= 2 } + + assertEquals(1, shallowEvents.size) + assertTrue(deepEvents.size >= 2) + } + + /** + * @UTS objects/unit/RTO10/gc-tombstoned-objects-0 + */ + @Test + fun `RTO10 - GC removes tombstoned objects past grace period`() = runTest { + val fakeClock = FakeClock() + lateinit var mockWs: MockWebSocket + mockWs = MockWebSocket { + onConnectionAttempt = { it.respondWithSuccess(connected()) } + onMessageFromClient = { msg -> + when (msg.action) { + ProtocolMessage.Action.attach -> { + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + channel = msg.channel + channelSerial = "sync1:" + setFlag(ProtocolMessage.Flag.has_objects) + }, + ) + mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } + ProtocolMessage.Action.`object` -> { + val serials = (msg.state?.indices ?: IntRange.EMPTY).map { "ack-${msg.msgSerial}:$it" } + mockWs.sendToClient(buildAckMessage(msg.msgSerial, serials)) + } + else -> Unit + } + } + } + val client = TestRealtimeClient { + key = "fake:key" + install(mockWs) + enableFakeTimers(fakeClock) + } + val channel = client.channels.get( + "test", + ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) }, + ) + val root = channel.`object`.get().await() + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildObjectDelete("counter:score@1000", "99", "site1", 1000))), + ) + + // Advance past the GC grace period (86400000ms) plus the check interval. + fakeClock.advance(86_400_000L + 300_000L) + + assertNull(root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTO10b1/gc-grace-period-source-0 + */ + @Test + fun `RTO10b1 - GC grace period from ConnectionDetails`() = runTest { + val fakeClock = FakeClock() + lateinit var mockWs: MockWebSocket + mockWs = MockWebSocket { + onConnectionAttempt = { it.respondWithSuccess(connected(gcGracePeriodMs = 5000L)) } + onMessageFromClient = { msg -> + when (msg.action) { + ProtocolMessage.Action.attach -> { + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + channel = msg.channel + channelSerial = "sync1:" + setFlag(ProtocolMessage.Flag.has_objects) + }, + ) + mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } + ProtocolMessage.Action.`object` -> { + val serials = (msg.state?.indices ?: IntRange.EMPTY).map { "ack-${msg.msgSerial}:$it" } + mockWs.sendToClient(buildAckMessage(msg.msgSerial, serials)) + } + else -> Unit + } + } + } + val client = TestRealtimeClient { + key = "fake:key" + install(mockWs) + enableFakeTimers(fakeClock) + } + val channel = client.channels.get( + "test", + ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) }, + ) + val root = channel.`object`.get().await() + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildObjectDelete("counter:score@1000", "99", "site1", 1000))), + ) + + // Short grace period (5000ms) — advance past it. + fakeClock.advance(5000L + 1000L) + + assertNull(root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTO20/echo-dedup-0 + */ + @Test + fun `RTO20 - Echo deduplication via appliedOnAckSerials`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().increment(10).await() + val scoreAfterApply = root.get("score").asLiveCounter().value() + + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, "ack-0:0", "test-site"))), + ) + val scoreAfterEcho = root.get("score").asLiveCounter().value() + + assertEquals(110.0, scoreAfterApply) + assertEquals(110.0, scoreAfterEcho) + } + + /** + * @UTS objects/unit/RTO20f/ack-no-site-timeserials-update-0 + */ + @Test + fun `RTO20f - Apply-on-ACK does not update siteTimeserials`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().increment(10).await() + assertEquals(110.0, root.get("score").asLiveCounter().value()) + + // Inbound COUNTER_INC from siteCode "test" with serial "t:1:0" (same as the ACK). If LOCAL had + // incorrectly written siteTimeserials, the newness check would reject this as stale. + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, "t:1:0", "test"))), + ) + pollUntil(5.seconds) { root.get("score").asLiveCounter().value() == 120.0 } + + assertEquals(120.0, root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTO20/ack-after-echo-no-double-apply-0 + */ + @Test + fun `RTO20 - ACK after echo does not double-apply`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannelNoAck("test") + + val incFuture = root.get("score").asLiveCounter().increment(10) + + // Send the echo BEFORE the ACK. + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, "ack-0:0", "test-site"))), + ) + + // Now send the ACK. + mockWs.sendToClient(buildAckMessage(0, listOf("ack-0:0"))) + + incFuture.await() + assertEquals(110.0, root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTO5c9-RTO20/ack-serials-cleared-on-resync-0 + */ + @Test + fun `RTO5c9 RTO20 - appliedOnAckSerials cleared on re-sync`() = runTest { + val (_, _, root, mockWs) = setupSyncedChannel("test") + + root.get("score").asLiveCounter().increment(10).await() + assertEquals(110.0, root.get("score").asLiveCounter().value()) + + // Trigger re-sync — appliedOnAckSerials should be cleared per RTO5c9; score resets to synced 100. + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + this.channel = "test" + channelSerial = "sync2:cursor" + setFlag(ProtocolMessage.Flag.has_objects) + }, + ) + mockWs.sendToClient(buildObjectSyncMessage("test", "sync2:", STANDARD_POOL_OBJECTS)) + pollUntil(5.seconds) { root.get("score").asLiveCounter().value() == 100.0 } + assertEquals(100.0, root.get("score").asLiveCounter().value()) + + // Replay the same serial used for apply-on-ACK. If cleared, this applies normally. + mockWs.sendToClient( + buildObjectMessage("test", listOf(buildCounterInc("counter:score@1000", 10, "t:1:0", "test"))), + ) + pollUntil(5.seconds) { root.get("score").asLiveCounter().value() == 110.0 } + + assertEquals(110.0, root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTO20/subscription-fires-on-ack-apply-0 + */ + @Test + fun `RTO20 - Subscription fires on apply-on-ACK`() = runTest { + val (_, _, root, _) = setupSyncedChannel("test") + val events = mutableListOf() + root.get("score").subscribe(PathObjectListener { events.add(it) }) + + root.get("score").asLiveCounter().increment(10).await() + + assertTrue(events.size >= 1) + assertEquals(110.0, root.get("score").asLiveCounter().value()) + } + + /** + * @UTS objects/unit/RTO23/get-implicit-attach-0 + */ + @Test + fun `RTO23 - get implicitly attaches channel`() = runTest { + lateinit var mockWs: MockWebSocket + mockWs = MockWebSocket { + onConnectionAttempt = { it.respondWithSuccess(connected()) } + onMessageFromClient = { msg -> + when (msg.action) { + ProtocolMessage.Action.attach -> { + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + channel = msg.channel + channelSerial = "sync1:" + setFlag(ProtocolMessage.Flag.has_objects) + }, + ) + mockWs.sendToClient(buildObjectSyncMessage(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } + ProtocolMessage.Action.`object` -> { + val serials = (msg.state?.indices ?: IntRange.EMPTY).map { "ack-${msg.msgSerial}:$it" } + mockWs.sendToClient(buildAckMessage(msg.msgSerial, serials)) + } + else -> Unit + } + } + } + val client = TestRealtimeClient { key = "fake:key"; install(mockWs) } + val channel = client.channels.get( + "test", + ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) }, + ) + + assertEquals(ChannelState.initialized, channel.state) + val root = channel.`object`.get().await() + + assertEquals("", root.path()) + assertEquals(ChannelState.attached, channel.state) + } + + /** + * @UTS objects/unit/RTO23d/get-resolves-immediately-synced-0 + */ + @Test + fun `RTO23d - get resolves immediately when already SYNCED`() = runTest { + val (_, channel, _, _) = setupSyncedChannel("test") + + val root2 = channel.`object`.get().await() + assertEquals("", root2.path()) + } + + /** + * @UTS objects/unit/RTO17-RTO18/sync-event-sequences-0 + */ + @Test + fun `RTO17 RTO18 - Sync event sequences for all state transitions`() = runTest { + data class Scenario( + val name: String, + val trigger: (channel: Channel, mockWs: MockWebSocket) -> Unit, + val expectedEvents: List, + ) + + val scenarios = listOf( + Scenario( + name = "initial attach", + trigger = { channel, _ -> channel.attach() }, + expectedEvents = listOf("SYNCING", "SYNCED"), + ), + Scenario( + name = "re-attach after detach", + trigger = { _, mockWs -> + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.detached).apply { channel = "test" }, + ) + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + this.channel = "test" + channelSerial = "sync2:cursor" + setFlag(ProtocolMessage.Flag.has_objects) + }, + ) + mockWs.sendToClient(buildObjectSyncMessage("test", "sync2:", STANDARD_POOL_OBJECTS)) + }, + expectedEvents = listOf("SYNCING", "SYNCED"), + ), + Scenario( + name = "re-sync on new ATTACHED", + trigger = { _, mockWs -> + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + this.channel = "test" + channelSerial = "sync3:cursor" + setFlag(ProtocolMessage.Flag.has_objects) + }, + ) + mockWs.sendToClient(buildObjectSyncMessage("test", "sync3:", STANDARD_POOL_OBJECTS)) + }, + expectedEvents = listOf("SYNCING", "SYNCED"), + ), + Scenario( + name = "ATTACHED without HAS_OBJECTS", + trigger = { _, mockWs -> + mockWs.sendToClient( + ProtocolMessage(ProtocolMessage.Action.attached).apply { + this.channel = "test" + channelSerial = "sync4:" + }, + ) + }, + expectedEvents = listOf("SYNCED"), + ), + ) + + for (scenario in scenarios) { + val (_, channel, _, mockWs) = setupSyncedChannel("test") + val events = mutableListOf() + channel.`object`.on(ObjectStateEvent.SYNCING, ObjectStateChange.Listener { events.add("SYNCING") }) + channel.`object`.on(ObjectStateEvent.SYNCED, ObjectStateChange.Listener { events.add("SYNCED") }) + + scenario.trigger(channel, mockWs) + pollUntil(5.seconds) { events.size >= scenario.expectedEvents.size } + + assertEquals(scenario.expectedEvents, events, scenario.name) + } + } +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/ValueTypesTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/ValueTypesTest.kt new file mode 100644 index 000000000..f41aa5356 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/unit/liveobjects/ValueTypesTest.kt @@ -0,0 +1,339 @@ +package io.ably.lib.uts.unit.liveobjects + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import io.ably.lib.liveobjects.value.LiveCounter +import io.ably.lib.liveobjects.value.LiveMap +import io.ably.lib.liveobjects.value.LiveMapValue +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.assertContentEquals + +/** + * Derived from UTS `objects/unit/value_types.md` (RTLCV1–RTLCV4, RTLMV1–RTLMV4) — the immutable + * creation value types `LiveCounter` / `LiveMap` (the spec's `LiveCounterValueType` / + * `LiveMapValueType`) obtained from the static `LiveCounter.create(...)` / `LiveMap.create(...)` + * factories and the `LiveMapValue` union (mapping §6). + * + * This is a MIXED spec, and almost all of its assertions land on internal/wire-level state that + * ably-java's typed-SDK public API does not expose: + * + * - The value type's internal blueprint (`vt.count`, `vt.entries[...]`) has **no public accessor** + * — `LiveCounter`/`LiveMap` are opaque holders (see their Javadoc: "held internally ... no public + * accessor"). So `vt.count == 42` / `vt.entries["name"] == "Alice"` are not expressible; only + * construction and the abstract type identity (`is LiveCounter` / `is LiveMap`) are observable. + * - The `evaluate(vt)` → `ObjectMessage` generation half (COUNTER_CREATE / MAP_CREATE messages, + * nonce / `initialValue` / `objectId` derivation, the `*WithObjectId` wire forms, depth-first + * ordering, empty-entries) is internal/wire-level (mapping §13) — there is no public `evaluate`. + * - Wrong-typed `create` args (`LiveCounter.create("not_a_number")`, `LiveMap.create(null)`, + * non-String keys, unsupported value types) are rejected at **compile time** by the + * `create(Number)` / `create(Map)` signatures and the `LiveMapValue` union + * (mapping §6) — they cannot be written as runtime `40003` / `40013` assertions. + * + * What IS faithfully translatable is the public `LiveMapValue` union surface (§6): constructing a + * value via `LiveMapValue.of(...)` and inspecting it with `isString()`/`getAsString()` etc. The + * entry-value-type-mapping cases (RTLMV4d) are adapted to assert on that public union instead of on + * the internal generated `ObjectData`. All internal cases carry an inline `// DEVIATION` and a + * matching entry in deviations.md. + * + * These tests are pure construction — no mocks / `setupSyncedChannel` — so they run today. + */ +class ValueTypesTest { + + /** + * @UTS objects/unit/RTLCV3/create-with-count-0 + */ + @Test + fun `RTLCV3 - LiveCounter create with initial count`() = runTest { + val vt = LiveCounter.create(42) + + assertTrue(vt is LiveCounter) // RTLCV3b: returns the LiveCounter value type + + // DEVIATION (RTLCV3b): spec asserts `vt.count == 42`, but the value type's internal count + // has no public accessor in ably-java (opaque immutable holder). Not expressible. + // See deviations.md. + } + + /** + * @UTS objects/unit/RTLCV3/create-default-zero-0 + */ + @Test + fun `RTLCV3 - LiveCounter create defaults to 0`() = runTest { + val vt = LiveCounter.create() + + assertTrue(vt is LiveCounter) + + // DEVIATION (RTLCV3a1): spec asserts the omitted-count default `vt.count == 0`, but there + // is no public accessor for the internal count. Not expressible. See deviations.md. + } + + /** + * @UTS objects/unit/RTLCV4/evaluate-generates-message-0 + */ + @Test + fun `RTLCV4 - Evaluation generates COUNTER_CREATE ObjectMessage`() = runTest { + // DEVIATION (RTLCV4): the spec calls the internal `evaluate(vt)` and asserts on the generated + // COUNTER_CREATE ObjectMessage (action, objectId prefix/derivation, nonce length, + // counterCreateWithObjectId.initialValue). There is no public `evaluate`; message generation, + // nonce/objectId derivation and the `*WithObjectId` wire form are internal/wire-level + // (mapping §13). Only the public construction is exercised here. See deviations.md. + val vt = LiveCounter.create(42) + assertTrue(vt is LiveCounter) + } + + /** + * @UTS objects/unit/RTLCV4g5/retains-local-counter-create-0 + */ + @Test + fun `RTLCV4g5 - Evaluation retains local CounterCreate`() = runTest { + // DEVIATION (RTLCV4g5): asserts the evaluated message retains the local `counterCreate` + // (`counterCreate.count == 42`) alongside `counterCreateWithObjectId`. Both the evaluation + // and the retained CounterCreate are internal/wire-level — not reachable through the public + // value type. See deviations.md. + val vt = LiveCounter.create(42) + assertTrue(vt is LiveCounter) + } + + /** + * @UTS objects/unit/RTLCV4a/evaluate-validates-count-0 + */ + @Test + fun `RTLCV4a - Evaluation validates count type`() = runTest { + // DEVIATION (RTLCV4a): spec passes a non-Number (`LiveCounter.create("not_a_number")`) and + // expects evaluation to fail with 40003. ably-java's `LiveCounter.create(@NotNull Number)` + // rejects a String at compile time (mapping §6), so the runtime 40003 assertion is not + // expressible. See deviations.md. + } + + /** + * @UTS objects/unit/RTLCV4/evaluate-zero-count-0 + */ + @Test + fun `RTLCV4 - Evaluation with count 0`() = runTest { + // DEVIATION (RTLCV4): asserts the evaluated message's `counterCreate.count == 0`. The + // evaluation and generated CounterCreate are internal/wire-level (mapping §13). Only the + // public construction with count 0 is exercised. See deviations.md. + val vt = LiveCounter.create(0) + assertTrue(vt is LiveCounter) + } + + /** + * @UTS objects/unit/RTLMV3/create-with-entries-0 + */ + @Test + fun `RTLMV3 - LiveMap create with entries`() = runTest { + val vt = LiveMap.create( + mapOf( + "name" to LiveMapValue.of("Alice"), + "age" to LiveMapValue.of(30), + ), + ) + + assertTrue(vt is LiveMap) // RTLMV3b: returns the LiveMap value type + + // DEVIATION (RTLMV3b): spec asserts `vt.entries["name"] == "Alice"` / `vt.entries["age"] == 30`, + // but the value type's internal entries have no public accessor (opaque immutable holder). + // Not expressible. See deviations.md. + } + + /** + * @UTS objects/unit/RTLMV3/create-no-entries-0 + */ + @Test + fun `RTLMV3 - LiveMap create with no entries`() = runTest { + val vt = LiveMap.create() + + assertTrue(vt is LiveMap) // RTLMV3a1: omitted entries still returns a LiveMap value type + } + + /** + * @UTS objects/unit/RTLMV4/evaluate-generates-message-0 + */ + @Test + fun `RTLMV4 - Evaluation generates MAP_CREATE ObjectMessage`() = runTest { + // DEVIATION (RTLMV4): spec calls internal `evaluate(vt)` and asserts on the generated + // MAP_CREATE ObjectMessage (action, objectId `map:` prefix, nonce length, + // mapCreateWithObjectId.initialValue). There is no public `evaluate`; message generation and + // the `*WithObjectId` wire form are internal/wire-level (mapping §13). Only the public + // construction is exercised. See deviations.md. + val vt = LiveMap.create(mapOf("name" to LiveMapValue.of("Alice"))) + assertTrue(vt is LiveMap) + } + + /** + * @UTS objects/unit/RTLMV4j5/retains-local-map-create-0 + */ + @Test + fun `RTLMV4j5 - Evaluation retains local MapCreate`() = runTest { + // DEVIATION (RTLMV4j5): asserts the evaluated message retains the local `mapCreate` + // (`mapCreate.semantics == "LWW"`, `mapCreate.entries["name"].data.string == "Alice"`) + // alongside `mapCreateWithObjectId`. Evaluation and the retained MapCreate are + // internal/wire-level. See deviations.md. + val vt = LiveMap.create(mapOf("name" to LiveMapValue.of("Alice"))) + assertTrue(vt is LiveMap) + } + + /** + * @UTS objects/unit/RTLMV4d/entry-value-types-0 + * + * The spec asserts the value-type → `data.*` field mapping on the generated `MapCreate` entries + * (internal/wire-level). Adapted to assert the public `LiveMapValue` union surface (mapping §6): + * each input wraps to a `LiveMapValue` whose `is*` discriminant and `getAs*` accessor match. + */ + @Test + fun `RTLMV4d - Entry value type mapping`() = runTest { + val str = LiveMapValue.of("hello") + assertTrue(str.isString) + assertEquals("hello", str.asString) // RTLMV4d4: String -> data.string + + val num = LiveMapValue.of(42) + assertTrue(num.isNumber) + assertEquals(42, num.asNumber.toInt()) // RTLMV4d5: Number -> data.number + + val bool = LiveMapValue.of(true) + assertTrue(bool.isBoolean) + assertEquals(true, bool.asBoolean) // RTLMV4d6: Boolean -> data.boolean + + val jsonArr = JsonArray().apply { + add(1) + add(2) + add(3) + } + val arrValue = LiveMapValue.of(jsonArr) + assertTrue(arrValue.isJsonArray) + assertEquals(jsonArr, arrValue.asJsonArray) // RTLMV4d3: JsonArray -> data.json + + val jsonObj = JsonObject().apply { add("key", JsonPrimitive("value")) } + val objValue = LiveMapValue.of(jsonObj) + assertTrue(objValue.isJsonObject) + assertEquals(jsonObj, objValue.asJsonObject) // RTLMV4d3: JsonObject -> data.json + + val bytes = byteArrayOf(1, 2, 3) + val binValue = LiveMapValue.of(bytes) + assertTrue(binValue.isBinary) + assertContentEquals(bytes, binValue.asBinary) // RTLMV4d7: Binary -> data.bytes + + // DEVIATION (RTLMV4d): the spec inspects the generated `MapCreate.entries[k].data.` + // (internal/wire-level). The public union inspection above is the equivalent observable + // surface; the generated message itself is not reachable. See deviations.md. + } + + /** + * @UTS objects/unit/RTLMV4d1/nested-value-types-0 + */ + @Test + fun `RTLMV4d1, RTLMV4d2 - Nested value types produce depth-first ObjectMessages`() = runTest { + // DEVIATION (RTLMV4d1/RTLMV4d2/RTLMV4k): the spec evaluates a nested value-type tree and + // asserts on the generated, depth-first-ordered COUNTER_CREATE/MAP_CREATE messages, their + // `objectId` prefixes, and the cross-referencing `entries[k].data.objectId` linking nested + // creates. Evaluation, objectId derivation and message ordering are internal/wire-level + // (mapping §13). Only the public nested construction is exercised. See deviations.md. + val innerCounter = LiveCounter.create(10) + val innerMap = LiveMap.create(mapOf("nested_count" to LiveMapValue.of(innerCounter))) + val outer = LiveMap.create(mapOf("child" to LiveMapValue.of(innerMap))) + + assertTrue(outer is LiveMap) + } + + /** + * @UTS objects/unit/RTLMV4a/evaluate-validates-entries-0 + */ + @Test + fun `RTLMV4a - Evaluation validates entries type`() = runTest { + // DEVIATION (RTLMV4a): spec passes `LiveMap.create(null)` and expects evaluation to fail with + // 40003. ably-java's `LiveMap.create(@NotNull Map)` rejects null at + // compile time (mapping §6), so the runtime 40003 assertion is not expressible. + // See deviations.md. + } + + /** + * @UTS objects/unit/RTLMV4b/evaluate-validates-keys-0 + */ + @Test + fun `RTLMV4b - Evaluation validates key types`() = runTest { + // DEVIATION (RTLMV4b): spec passes a non-String key (`{ 123: "value" }`) and expects 40003. + // ably-java's `create(Map)` enforces String keys at compile time + // (mapping §6); a non-String key cannot be constructed. Not expressible. See deviations.md. + } + + /** + * @UTS objects/unit/RTLMV4c/evaluate-validates-values-0 + */ + @Test + fun `RTLMV4c - Evaluation validates value types`() = runTest { + // DEVIATION (RTLMV4c): spec passes an unsupported value (a function) and expects 40013. + // ably-java's `LiveMapValue` union only constructs from the supported types (Boolean, byte[], + // Number, String, JsonArray, JsonObject, LiveCounter, LiveMap), so an unsupported value is + // rejected at compile time (mapping §6). Not expressible. See deviations.md. + } + + /** + * @UTS objects/unit/RTLMV4e2/empty-entries-0 + */ + @Test + fun `RTLMV4e2 - Empty entries produces MapCreate with empty entries`() = runTest { + // DEVIATION (RTLMV4e2): asserts the evaluated message's `mapCreate.entries == {}` for a + // no-entries value type. Evaluation and the generated MapCreate are internal/wire-level + // (mapping §13). Only the public empty construction is exercised. See deviations.md. + val vt = LiveMap.create() + assertTrue(vt is LiveMap) + } + + /** + * @UTS objects/unit/RTLMV4d/map-set-all-types-table-0 + * + * Spec table asserts every supported value type maps to the correct generated `data.*` field. + * Adapted to the public `LiveMapValue` union (mapping §6): each input wraps and is inspected via + * its `is*` discriminant + `getAs*` accessor. The generated `MapCreate` `data` is internal. + */ + @Test + fun `RTLMV4d - Table-driven MAP_SET value type mapping`() = runTest { + // string -> isString / "hello" + LiveMapValue.of("hello").let { + assertTrue(it.isString) + assertEquals("hello", it.asString) + } + // number 42 / 3.14 / 0 / -1 -> isNumber + listOf(42, 3.14, 0, -1).forEach { n -> + val v = LiveMapValue.of(n) + assertTrue(v.isNumber) + assertEquals(n.toDouble(), v.asNumber.toDouble()) + } + // boolean true / false -> isBoolean + listOf(true, false).forEach { b -> + val v = LiveMapValue.of(b) + assertTrue(v.isBoolean) + assertEquals(b, v.asBoolean) + } + // json array [1, "a", null] -> isJsonArray + val arr = JsonArray().apply { + add(1) + add("a") + add(com.google.gson.JsonNull.INSTANCE) + } + LiveMapValue.of(arr).let { + assertTrue(it.isJsonArray) + assertEquals(arr, it.asJsonArray) + } + // json object { "k": "v" } -> isJsonObject + val obj = JsonObject().apply { add("k", JsonPrimitive("v")) } + LiveMapValue.of(obj).let { + assertTrue(it.isJsonObject) + assertEquals(obj, it.asJsonObject) + } + // bytes([1,2,3]) -> isBinary (spec's "AQID" is the base64 of the generated wire bytes) + val bytes = byteArrayOf(1, 2, 3) + LiveMapValue.of(bytes).let { + assertTrue(it.isBinary) + assertContentEquals(bytes, it.asBinary) + } + + // DEVIATION (RTLMV4d): the spec asserts on the generated `MapCreate.entries["test_key"] + // .data[field]` (internal/wire-level), including the base64 "AQID" wire encoding of the + // binary. The public union inspection above is the equivalent observable surface; the + // generated message and its base64 encoding are not reachable. See deviations.md. + } +} From 43a3ab24ebf672fb56b7daad4514e098c1a8dc07 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 26 Jun 2026 15:20:10 +0530 Subject: [PATCH 2/4] docs(uts): document untranslatable objects specs in private_deviations.md Record the five objects/unit UTS specs (live_counter, live_map, object_id, objects_pool, parent_references) that cannot be translated into the :uts module. They assert on the internal CRDT graph (ObjectsPool, live nodes, applyOperation, object-id generation, parent references), which is (a) not yet implemented in :liveobjects and (b) Kotlin-`internal`, so unreachable from the :uts test module at compile time. Covers: status of all 15 objects/unit specs (10 translated, 5 blocked), why these target internals, the two blockers, and the realistic visibility options (java-test-fixtures bridge, tests in :liveobjects/src/test, or reflection) with their trade-offs and a recommendation. --- .../io/ably/lib/uts/private_deviations.md | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/private_deviations.md diff --git a/uts/src/test/kotlin/io/ably/lib/uts/private_deviations.md b/uts/src/test/kotlin/io/ably/lib/uts/private_deviations.md new file mode 100644 index 000000000..2abf2c27b --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/private_deviations.md @@ -0,0 +1,286 @@ +# Private Deviations — Objects UTS specs that cannot (yet) be translated + +> **Scope.** This file complements [`deviations.md`](./deviations.md). `deviations.md` records +> *per-test* deviations inside tests that **were** translated and compile. This file records the +> opposite: whole UTS spec files from the `objects` module that **could not be translated into the +> `uts` module at all**, why, and what would unblock them. It is written for a human reviewer / the +> LiveObjects implementers — not consumed by any tooling. + +--- + +## 1. Status of all 15 `objects/unit` specs + +| # | UTS spec (`objects/unit/…`) | ably-java test class | Status | Layer it targets | +|---|---|---|---|---| +| 1 | `instance.md` | `InstanceTest` | ✅ Translated | Public view (`Instance`) | +| 2 | `live_counter.md` | `LiveCounterTest` | ⛔ **Blocked** | **Internal CRDT node** | +| 3 | `live_counter_api.md` | `LiveCounterApiTest` | ✅ Translated | Public view | +| 4 | `live_map.md` | `LiveMapTest` | ⛔ **Blocked** | **Internal CRDT node** | +| 5 | `live_map_api.md` | `LiveMapApiTest` | ✅ Translated | Public view | +| 6 | `live_object_subscribe.md` | `LiveObjectSubscribeTest` | ✅ Translated | Public view | +| 7 | `object_id.md` | `ObjectIdTest` | ⛔ **Blocked** | **Internal (object-id gen)** | +| 8 | `objects_pool.md` | `ObjectsPoolTest` | ⛔ **Blocked** | **Internal (`ObjectsPool`)** | +| 9 | `parent_references.md` | `ParentReferencesTest` | ⛔ **Blocked** | **Internal (parent graph)** | +| 10 | `path_object.md` | `PathObjectTest` | ✅ Translated | Public view (`PathObject`) | +| 11 | `path_object_mutations.md` | `PathObjectMutationsTest` | ✅ Translated | Public view | +| 12 | `path_object_subscribe.md` | `PathObjectSubscribeTest` | ✅ Translated | Public view | +| 13 | `public_object_message.md` | `PublicObjectMessageTest` | ✅ Translated | Public message layer | +| 14 | `realtime_object.md` | `RealtimeObjectTest` | ✅ Translated (mixed) | Public `get()` + sync events | +| 15 | `value_types.md` | `ValueTypesTest` | ✅ Translated (mixed) | Public `create` surface | + +**10 translated, 5 blocked.** The 5 blocked specs are the subject of this document. + +> Note: the translated specs that depend on `setupSyncedChannel` (most of the public-view tests) +> compile today but only *run* once the SDK's `OBJECT_SYNC` processing + `RealtimeObject.get()` land. +> That is the same missing engine described below — see [`deviations.md`](./deviations.md) and the +> `helpers.kt` header for the per-test runtime caveat. The blocked specs below are a stronger case: +> they cannot even be *written*. + +--- + +## 2. Why these 5 specs target internals + +The objects spec is layered into three tiers (see the skill's `objects-mapping.md`): + +1. **Creation value types** — the immutable `LiveMap` / `LiveCounter` blueprints you pass *into* `set`. +2. **Public read/write view** — `PathObject` / `Instance`, what user code navigates and subscribes on. +3. **Internal CRDT graph** — the live conflict-free replicated nodes, the object pool, object-id + generation and the parent-reference graph. This is the convergence engine. + +The 10 translated specs live in tiers 1–2. The 5 blocked specs **are** tier 3. They have to assert on +internal state because the behaviour they pin down — last-write-wins arbitration by site-serial, +idempotent re-application, tombstones, create-op merging, garbage collection, object-id derivation — +is **not observable through the public API**. You cannot verify "the second of two concurrent ops +loses by site-serial" with `get()`/`value()`; you have to reach the node's `siteTimeserials` and call +`applyOperation` directly. So the spec is correct to test internals — that is where the hard logic is. + +--- + +## 3. The two blockers (in order of severity) + +### Blocker A — the internal implementation does not exist yet *(primary)* + +`:liveobjects` currently implements the **view** layer only. A symbol search of +`liveobjects/src/main/kotlin/io/ably/lib/liveobjects` confirms the CRDT engine these specs assert on +is absent: + +| Symbol required by the blocked specs | Found in `:liveobjects`? | +|---|---| +| `ObjectsPool` (the live object pool) | ❌ 0 references | +| `generateObjectId` / object-id derivation (`RTO14`) | ❌ 0 references | +| `applyOperation(...)` (apply op to a live node) | ❌ 0 references | +| `replaceData(...)` | ❌ 0 references | +| `createOperationIsMerged` | ❌ 0 references | +| parent-reference graph (`parentRef…`) | ❌ 0 references | +| pool `syncState` | ❌ 0 references | +| `siteTimeserials` | ⚠️ only on the **wire DTO** (`WireObjectState` / `WireObjectsMapEntry`), not on a live CRDT node | + +What *does* exist: `DefaultPathObject`, `DefaultInstance`, the typed `Default*PathObject` / +`Default*Instance` views, the `value/` creation types, and the `message/` + `serialization/` wire layer. +There is **no live `InternalLiveMap` / `InternalLiveCounter` node, no `ObjectsPool`, and no +operation-application engine.** + +**Consequence:** even with perfect cross-module visibility there is nothing to instantiate or assert +against. These tests cannot be authored until the engine is implemented. + +### Blocker B — Kotlin `internal` is not visible across the module boundary *(secondary, applies once A is done)* + +When the engine *is* implemented it will (by the codebase's convention, and because `:liveobjects` +uses `explicitApi()`) be declared `internal` — exactly like the existing `Default*` classes +(`internal class DefaultLiveMap`, `internal class DefaultPathObject`, …). + +Kotlin's `internal` is scoped to a **module** = one compilation unit = one Gradle source set's compile +task. The `:uts` test source set is a *different* module from `:liveobjects`'s `main`. The Kotlin +compiler enforces `internal` across that boundary **regardless of dependency classpath scope**. So +`:uts` test code cannot name those declarations at compile time. + +This is why the existing helper `buildPublicObjectMessage` (in `helpers.kt`) reaches the internal +wire/message classes by **reflection** (`Class.forName(...)`), enabled by the current +`testRuntimeOnly(project(":liveobjects"))` — runtime-only access. Reflection works for a handful of +constructor/field hops but is the wrong tool for whole-CRDT-state assertions (no type safety, brittle, +unreadable). + +--- + +## 4. Per-spec detail + +| Spec | What it asserts on | Required internal symbols | Blocked by | +|---|---|---|---| +| **2 `live_counter.md`** | internal counter node state after applying ops | `InternalLiveCounter` (`.data`, `.siteTimeserials`, `.createOperationIsMerged`, `applyOperation`, `replaceData`) | A + B | +| **4 `live_map.md`** | internal map node state after applying ops | `InternalLiveMap` (`.data`, `.siteTimeserials`, `.isTombstone`, `applyOperation`, `replaceData`) | A + B | +| **7 `object_id.md`** | object-id generation & parsing | `generateObjectId` / object-id type (`RTO14`), `*WithObjectId` derivation | A + B | +| **8 `objects_pool.md`** | the object pool and its sync lifecycle | `ObjectsPool`, `.syncState`, pool entry add/get/clear | A + B | +| **9 `parent_references.md`** | the reverse parent-reference graph | parent-reference tracking on the pool/nodes | A + B | + +> Specs 2 and 4 have public counterparts (`live_counter_api.md` / `live_map_api.md`, both translated) +> that cover the *outcome* of these operations through the public API. Specs 7–9 have **no** public +> counterpart — they are purely internal and have no representation in tiers 1–2. + +--- + +## 5. Solution options + +The ask was to make all of `liveobjects/src/main/kotlin/io/ably/lib/liveobjects` visible to `:uts`, +probably by changing `testRuntimeOnly(project(":liveobjects"))` to `testImplementation(...)`. Here is +the accurate picture, as lead dev. + +### 5.1 Why the `testRuntimeOnly` → `testImplementation` swap alone is *not* sufficient + +```kotlin +// uts/build.gradle.kts — current +testRuntimeOnly(project(":liveobjects")) // runtime classpath only → reflection-only access + +// proposed +testImplementation(project(":liveobjects")) // adds COMPILE classpath too +``` + +`testImplementation` puts `:liveobjects` on the **compile** classpath, which lets `:uts` reference its +**public** API directly (and lets the reflection helpers drop some `Class.forName`). But it does **not** +grant access to `internal` declarations: Kotlin enforces `internal` at the *module* boundary at +compile time, and a dependency-scope change does not cross that boundary. The CRDT engine will be +`internal`, so the swap by itself does not unblock these tests. It is **necessary but not sufficient.** + +### 5.2 The Gradle/Kotlin configs that *can* expose internals + +There is exactly **one** primitive that grants one Kotlin compilation access to another's `internal` +declarations: the compiler flag **`-Xfriend-paths`**. Everything below is either that flag directly, or +a higher-level wrapper around it. + +**(a) `associateWith()` — the *supported* form, but intra-project only.** +The Kotlin Gradle plugin exposes friend-paths through the `associateWith` API on compilations: + +```kotlin +kotlin.target.compilations.getByName("test") + .associateWith(kotlin.target.compilations.getByName("main")) +``` + +This is how a module's own `test` source set sees its `main` internals (the plugin wires it up +automatically), and how you'd give a *custom* source set (e.g. `integrationTest`) the same access. It is +stable and IDE-aware — **but only between compilations of the same Gradle project.** There is no +supported way to `associateWith` a compilation in a *different* project (`:uts` test ↔ `:liveobjects` +main). Source: KTIJ-7662, KT associated-compilations docs. + +**(b) Raw `-Xfriend-paths` across projects — works, but unstable/unsupported.** +You can manually point `:uts`'s test-compile task at `:liveobjects`'s `main` output: +`-Xfriend-paths=…/liveobjects/build/classes/kotlin/main`. Per the Kotlin team this flag has *"no syntax, +no IDE support, and no guarantees of stability — a compiler implementation detail, not a language +feature."* It also hard-couples `:uts` to `:liveobjects`'s internal compile output path. **Not +recommended** for production build config. + +**(c) The future fix (not available yet): `shared internal` (KEEP-0451).** +The Kotlin team is *not* stabilizing `-Xfriend-paths`; instead KEEP-0451 proposes a first-class +`shared internal` visibility modifier — declarations visible to designated dependent modules but not +the general public. When it ships this is the clean answer, but it is a proposal today, not usable. + +### 5.3 Cleanest technical approach — write the internal-graph tests in `:liveobjects`'s own test source set + +> This is the lowest-ceremony option and what the SDK already does for its own internals, but it places +> the tests **outside `uts/unit`**. If keeping them under `uts/unit` is required, prefer §5.4(a) instead. + +`:liveobjects` **already has** a unit-test source set and task: + +```kotlin +// liveobjects/build.gradle.kts (existing) +tasks.register("runLiveObjectsUnitTests") { + filter { includeTestsMatching("io.ably.lib.liveobjects.unit.*") } +} +``` + +Tests placed under `liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/…` see **all** of `main`'s +`internal` declarations automatically (the plugin sets the friend-path for a module's own tests). This +is the standard, supported way to test internal Kotlin code. + +**Plan once Blocker A is resolved (engine implemented):** +1. Author specs 2, 4, 7, 8, 9 in `:liveobjects`'s own test source set, e.g. package + `io.ably.lib.liveobjects.unit.uts`, mirroring the `uts` conventions (one `@Test` per spec case, a + `/** @UTS objects/unit/… */` KDoc tag, the `deviations.md` discipline). +2. Specs 7–9 (`object_id`, `objects_pool`, `parent_references`) are pure logic with no network/sync — + they will **run immediately** there, no mock-WebSocket harness needed. +3. Specs 2 and 4 need object state applied to a node; reuse / port the relevant `helpers.kt` builders. +4. Keep `:uts`'s `testRuntimeOnly(project(":liveobjects"))` as-is (reflection helpers stay valid), or + optionally promote to `testImplementation` purely for compile-time access to the **public** API. + +**Trade-off:** these five tests then live outside the `uts` module the skill normally targets. That is +acceptable and correct — they are internal-implementation tests, and the SDK already groups its own +internal tests under `:liveobjects`. The `@UTS` id convention keeps them traceable to the spec. + +### 5.4 Keeping the tests under `uts/unit` — what actually works + +This is the stated preference, so it gets its own analysis. To assert on `:liveobjects` internals from +test code that physically lives in `:uts`, the realistic options are: + +**(a) `java-test-fixtures` bridge — the recommended way to honour the `uts/unit` preference.** +Apply the `java-test-fixtures` plugin to `:liveobjects` and put a thin **inspection/bridge** layer in +`liveobjects/src/testFixtures/kotlin`. Fixture code *belongs to the module*, so it can touch +`:liveobjects` internals; it then re-exposes them as a small **public** API (e.g. +`fun applyAndSnapshot(...): PublicCounterSnapshot`). `:uts` consumes it with: + +```kotlin +// uts/build.gradle.kts +testImplementation(testFixtures(project(":liveobjects"))) +``` + +The **assertions stay in `uts/unit`** (calling the fixture's public API) — your preference is satisfied — +while no raw internal is leaked onto `:uts`'s classpath. Caveat: for the *fixture itself* to see Kotlin +`internal`, its compilation must be associated with `main` (Android exposes +`android.experimental.enableTestFixturesKotlinSupport`; for plain JVM Kotlin verify the testFixtures→main +`associateWith` is wired — it reduces back to §5.2(a), which is supported because it is intra-project). +Cost: you design and maintain the bridge surface. + +**(b) Reflection from `:uts` (status quo, no build change).** +Current `testRuntimeOnly(project(":liveobjects"))` already lets `:uts` reach internals by reflection at +runtime — this is what `buildPublicObjectMessage` does. Keeps tests in `uts/unit` with zero build +changes, but: stringly-typed, no compile-time safety, brittle to refactors, and verbose for whole-CRDT +assertions. Fine for a couple of accessors; poor for five spec files of state assertions. + +> **On `@VisibleForTesting`:** it does **not** change visibility. It is a documentation/lint hint that +> records what the visibility *would* be if not for tests; the actual access is still governed by the +> `public`/`internal` modifier. So "mark it `@VisibleForTesting`" only helps if you *also* make the +> member `public` (e.g. `@VisibleForTesting(otherwise = PRIVATE) public fun …`). It is not, by itself, a +> cross-module visibility mechanism. + +--- + +## 6. The realistic options + +Only **three** approaches are real candidates for our situation. (The mechanisms in §5.2 — +`associateWith`, raw `-Xfriend-paths`, `shared internal` — and the bare dependency-scope swap in §5.1 are +*not* viable on their own; see the note below.) + +| Option | Keeps tests in `uts/unit`? | Compile-safe? | Trade-off | +|---|---|---|---| +| **A. `java-test-fixtures` bridge** (§5.4a) | ✅ yes | ✅ yes | Design a small public snapshot surface in `:liveobjects`'s `testFixtures`. **Best fit for the preference.** | +| **B. Tests in `:liveobjects/src/test`** (§5.3) | ❌ no — live in `:liveobjects` | ✅ yes | Least effort; internals visible by design. The SDK already tests its own internals this way. | +| **C. Reflection from `:uts`** (§5.4b) | ✅ yes | ❌ no | No build change, but stringly-typed and brittle — fine for a few hops, poor for 5 spec files. | + +**Not viable on their own:** the bare `testRuntimeOnly` → `testImplementation` swap (exposes only the +*public* API, not `internal`); `associateWith` (supported but *intra-project* — cannot bridge `:uts` ↔ +`:liveobjects`); raw cross-project `-Xfriend-paths` (unstable/unsupported); `shared internal` / +KEEP-0451 (future proposal, not available yet). + +## 7. Recommendation & sequencing + +1. **Now:** nothing to translate for specs 2, 4, 7, 8, 9 — the internal engine they test is unbuilt + (Blocker A). Leave them blocked; this document is the record. +2. **When the LiveObjects CRDT engine (`ObjectsPool`, internal live nodes + `applyOperation`, + object-id generation, parent references) is implemented**, pick the visibility approach: + - **To honour the `uts/unit` preference (recommended):** the **`java-test-fixtures` bridge** (§5.4a) — + assertions stay in `uts/unit`, a small fixture in `:liveobjects` exposes the needed internal state + as a public snapshot. Compile-safe and supported. + - **If colocation isn't required:** author them in **`:liveobjects/src/test`** (§5.3) — least + ceremony, internals visible by design (the SDK already tests its own internals this way). +3. **Avoid** the bare `testImplementation` swap *as the internal-access mechanism* (it only exposes the + public API) and the manual cross-project `-Xfriend-paths` hack (unsupported, fragile). + +--- + +## 8. References + +- Kotlin associated compilations / `associateWith` (intra-project internal access): + KTIJ-7662, Kotlin Multiplatform "Configure compilations" docs. +- `-Xfriend-paths` is an unstable compiler detail; future `shared internal` modifier: KEEP-0451 + ("Shared Internals" proposal). +- `@VisibleForTesting` is a lint/documentation hint and does not change visibility. +- Gradle `java-test-fixtures`: test-fixtures code has access to the module's internal API and is + consumed via `testImplementation(testFixtures(project(":…")))`; Kotlin support may require + associating the testFixtures compilation with `main`. From 60dccf4065a5f5bd5e0b7ba75e690391dd581096 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 26 Jun 2026 16:21:11 +0530 Subject: [PATCH 3/4] feat(uts): add LiveObjects integration REST fixture-provisioning helper Add the integration-tier translation of standard_test_pool.md's REST fixture provisioning (`provision_objects_via_rest`) under uts/.../integration/standard/liveobjects/helpers.kt: `provisionObjectsViaRest` plus op builders (mapSet/mapRemove/mapCreate/counterCreate/counterInc) and value builders (string/number/boolean/bytes/objectId). The helper follows the LiveObjects V2 objects REST API (per the OpenAPI), not the legacy pseudocode: POST /channels/{channel}/object (singular), body is a single operation or a bare array (no "messages" envelope), each op identified by its payload key with an objectId/path target. Compiles against :java only (AblyRest + HttpUtils); used by objects/integration/RTPO15. Document it in the uts-to-kotlin skill's objects-mapping.md as a new section ("14. Integration-test helpers"), promoted out of the unit-only internal-graph section, with the TOC renumbered accordingly. --- .../references/objects-mapping.md | 43 ++++- .../standard/liveobjects/helpers.kt | 148 ++++++++++++++++++ 2 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/helpers.kt diff --git a/.claude/skills/uts-to-kotlin/references/objects-mapping.md b/.claude/skills/uts-to-kotlin/references/objects-mapping.md index cbe977f0c..e27e789e8 100644 --- a/.claude/skills/uts-to-kotlin/references/objects-mapping.md +++ b/.claude/skills/uts-to-kotlin/references/objects-mapping.md @@ -27,8 +27,9 @@ doubt, that IDL is the source of truth; this doc is the applied version of it fo 11. [Message / operation types (`PublicAPI::ObjectMessage` →)](#11-messages) 12. [Errors & error codes](#12-errors) 13. [Internal-graph types (unit specs) — important caveat](#13-internal-graph) -14. [Worked example](#14-worked-example) -15. [Quick symbol index](#15-symbol-index) +14. [Integration-test helpers — REST fixture provisioning](#14-integration-helpers) +15. [Worked example](#15-worked-example) +16. [Quick symbol index](#16-symbol-index) --- @@ -554,9 +555,43 @@ wire `action` / `semantics` are integer enum codes — the builders emit the cod > is implemented. (`buildPublicObjectMessage` does *not* depend on this — the message/operation layer is > implemented, so those tests can run now.) +(For the **integration** tier's REST fixture helper — `provision_objects_via_rest` — see §14.) + +--- + +## 14. Integration-test helpers — REST fixture provisioning (`standard_test_pool.md` → integration `helpers.kt`) + +Some objects **integration** specs (tier `integration/standard`) seed object state over REST *before* the +realtime client connects, via the spec's `## REST Fixture Provisioning` helper `provision_objects_via_rest`. +Its ably-java translation lives in +`uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/helpers.kt` (package +`io.ably.lib.uts.integration.standard.liveobjects`) — **call it; don't hand-roll the REST request or payload +JSON.** (Currently only `objects/integration/RTPO15` uses it.) Unlike the unit helpers (§13), this needs no +reflection and no `:liveobjects` dependency — it compiles and runs against `:java`'s public `AblyRest`. + +| Spec helper / operation shape | integration `helpers.kt` | +|---|---| +| `provision_objects_via_rest(api_key, channel_name, operations)` | `provisionObjectsViaRest(apiKey, channelName, operations: List): List` (POSTs the op(s); returns created/updated `objectIds`) | +| op `{ mapSet: { key, value }, objectId/path }` | `mapSetOp(key, value, objectId = …, path = …, id = …)` | +| op `{ mapRemove: { key }, objectId/path }` | `mapRemoveOp(key, objectId = …, path = …, id = …)` | +| op `{ mapCreate: { semantics: 0, entries }, [objectId/path] }` | `mapCreateOp(entries: Map, semantics = 0, objectId = …, path = …, id = …)` | +| op `{ counterCreate: { count }, [objectId/path] }` | `counterCreateOp(count, objectId = …, path = …, id = …)` | +| op `{ counterInc: { number }, objectId/path }` | `counterIncOp(number, objectId = …, path = …, id = …)` | +| value `{ string }` / `{ number }` / `{ boolean }` / `{ bytes }` / `{ objectId }` | `valueString` / `valueNumber` / `valueBoolean` / `valueBytes` / `valueObjectId` (each → `JsonObject`; `valueString` / `valueBytes` take an optional `encoding`) | + +> **V2 REST format.** These builders follow the LiveObjects **V2** objects REST API (the OpenAPI is the +> source of truth), **not** the literal `standard_test_pool.md` pseudocode — which still showed the legacy +> `POST …/objects` + `{ messages: [...] }` envelope. V2: `POST /channels/{channel}/object` (**singular**), +> body is a single operation **or** a bare array (no `messages` wrapper), each op named by its payload key +> (`mapSet` / `mapRemove` / `mapCreate` / `counterInc` / `counterCreate`) with an `objectId`/`path` target +> (and optional idempotency `id`). The spec helper is being aligned upstream (ably/specification#497). +> +> The REST call hits the live sandbox today; the realtime client it provisions for only *observes* the data +> once the SDK's OBJECT_SYNC + `RealtimeObject.get()` land. + --- -## 14. Worked example +## 15. Worked example Spec pseudocode (public-API style): @@ -597,7 +632,7 @@ wrapped in `LiveMapValue.of`; `at(...)` followed by `asLiveCounter()` before cou --- -## 15. Quick symbol index +## 16. Quick symbol index | ably-js / spec symbol | ably-java | |---|---| diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/helpers.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/helpers.kt new file mode 100644 index 000000000..e30acb02b --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/helpers.kt @@ -0,0 +1,148 @@ +package io.ably.lib.uts.integration.standard.liveobjects + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import io.ably.lib.http.HttpUtils +import io.ably.lib.rest.AblyRest +import io.ably.lib.types.ClientOptions + +/** + * LiveObjects **integration** test helpers — the ably-java translation of the UTS + * `objects/helpers/standard_test_pool.md` "## REST Fixture Provisioning" section (`provision_objects_via_rest`), + * used by integration specs that need pre-existing object state before the realtime client connects + * (currently `objects/integration/RTPO15`). + * + * ### Payload format: V2 (per the OpenAPI), NOT the UTS pseudocode + * + * The source of truth for the request shape is the published OpenAPI + * (`ably-docs/static/open-specs/liveobjects.yaml`, "Update LiveObjects REST API docs for **V2 format**", + * 2026-01-22), **not** the UTS pseudocode — the UTS spec helper describes a legacy/pre-V2 shape that is out + * of sync (see [provisionObjectsViaRest]). The V2 contract: + * + * - Endpoint: `POST /channels/{channel}/object` (**singular** `object`). + * - Body: a single operation object, **or** a bare JSON array of them — there is **no** `{ "messages": [...] }` + * wrapper. + * - An operation is identified by its **payload key** (`mapSet` / `mapRemove` / `mapCreate` / `counterInc` / + * `counterCreate`) plus a sibling target (`objectId` *or* `path`) — there is **no** `operation: "MAP_SET"` + * string and **no** `data` wrapper. + * - Values are `{ string }` / `{ number }` / `{ boolean }` / `{ bytes }`(base64) / `{ objectId }`. + * - `mapCreate.semantics` is the integer `0` (LWW); its `entries` wrap each value as `{ "data": }`. + * + * Compiles against `:java` only (`AblyRest` + `HttpUtils`), like the unit `helpers.kt`. + */ + +// --------------------------------------------------------------------------- +// Value builders — a V2 `PrimitiveValue` (the `value` of a mapSet / a mapCreate entry's `data`) +// --------------------------------------------------------------------------- + +fun valueString(value: String, encoding: String? = null): JsonObject = + JsonObject().apply { addProperty("string", value); encoding?.let { addProperty("encoding", it) } } +fun valueNumber(value: Number): JsonObject = JsonObject().apply { addProperty("number", value) } +fun valueBoolean(value: Boolean): JsonObject = JsonObject().apply { addProperty("boolean", value) } +fun valueBytes(base64: String, encoding: String? = null): JsonObject = + JsonObject().apply { addProperty("bytes", base64); encoding?.let { addProperty("encoding", it) } } +fun valueObjectId(objectId: String): JsonObject = JsonObject().apply { addProperty("objectId", objectId) } + +// --------------------------------------------------------------------------- +// Operation builders — a single V2 operation; target is `objectId` OR `path` +// --------------------------------------------------------------------------- + +private fun operation(objectId: String?, path: String?, id: String?, build: JsonObject.() -> Unit): JsonObject = + JsonObject().apply { + id?.let { addProperty("id", it) } // OperationBase.id — idempotency key + objectId?.let { addProperty("objectId", it) } + path?.let { addProperty("path", it) } + build() + } + +/** `{ mapSet: { key, value }, objectId|path }` */ +fun mapSetOp(key: String, value: JsonObject, objectId: String? = null, path: String? = null, id: String? = null): JsonObject = + operation(objectId, path, id) { + add("mapSet", JsonObject().apply { addProperty("key", key); add("value", value) }) + } + +/** `{ mapRemove: { key }, objectId|path }` */ +fun mapRemoveOp(key: String, objectId: String? = null, path: String? = null, id: String? = null): JsonObject = + operation(objectId, path, id) { + add("mapRemove", JsonObject().apply { addProperty("key", key) }) + } + +/** `{ mapCreate: { semantics, entries: { k: { data: value } } }, objectId?|path? }` */ +fun mapCreateOp( + entries: Map = emptyMap(), + semantics: Int = 0, // 0 == LWW + objectId: String? = null, + path: String? = null, + id: String? = null, +): JsonObject = operation(objectId, path, id) { + add( + "mapCreate", + JsonObject().apply { + addProperty("semantics", semantics) + add( + "entries", + JsonObject().apply { + entries.forEach { (k, v) -> add(k, JsonObject().apply { add("data", v) }) } + }, + ) + }, + ) +} + +/** `{ counterCreate: { count }, objectId?|path? }` */ +fun counterCreateOp(count: Number, objectId: String? = null, path: String? = null, id: String? = null): JsonObject = + operation(objectId, path, id) { + add("counterCreate", JsonObject().apply { addProperty("count", count) }) + } + +/** `{ counterInc: { number }, objectId|path }` (negative `number` = decrement) */ +fun counterIncOp(number: Number, objectId: String? = null, path: String? = null, id: String? = null): JsonObject = + operation(objectId, path, id) { + add("counterInc", JsonObject().apply { addProperty("number", number) }) + } + +// --------------------------------------------------------------------------- +// provision_objects_via_rest +// --------------------------------------------------------------------------- + +/** + * `provision_objects_via_rest(api_key, channel_name, operations)` — seeds object state on a channel via the + * REST API before any realtime client connects. + * + * Translated to the **V2** OpenAPI contract (see file header), which diverges from the UTS pseudocode in + * `standard_test_pool.md` (legacy `POST …/objects` + `{ messages: [ { operation: { action, … } } ] }`). A + * single operation is posted as one object; multiple operations are posted as a JSON array (`BatchOperation`). + * + * @return the `objectIds` reported by the API for the created/updated objects. + */ +fun provisionObjectsViaRest(apiKey: String, channelName: String, operations: List): List { + require(operations.isNotEmpty()) { "operations must not be empty" } + + val rest = AblyRest( + ClientOptions().apply { + key = apiKey + environment = "sandbox" + useBinaryProtocol = false + }, + ) + + val path = "/channels/$channelName/object" // V2: singular `object` + val body: JsonElement = + if (operations.size == 1) operations[0] else JsonArray().apply { operations.forEach { add(it) } } + + val response = rest.request( + "POST", + path, + null, + HttpUtils.requestBodyFromGson(body, rest.options.useBinaryProtocol), + null, + ) + check(response.success) { + "REST objects provisioning failed: HTTP ${response.statusCode} ${response.errorMessage}" + } + + return response.items().flatMap { item -> + item.asJsonObject.get("objectIds")?.asJsonArray?.map { it.asString } ?: emptyList() + } +} From 9a48df15852a94a4599307c63bca014c1f69f5a5 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 26 Jun 2026 17:42:15 +0530 Subject: [PATCH 4/4] Updated liveobjects integration helper and completed remaining integration/proxy tests --- uts/build.gradle.kts | 2 + .../proxy/liveobjects/ObjectsFaultsTest.kt | 316 ++++++++++++++++++ .../liveobjects/ObjectsLifecycleTest.kt | 283 ++++++++++++++++ .../standard/liveobjects/ObjectsSyncTest.kt | 192 +++++++++++ .../standard/liveobjects/helpers.kt | 7 +- 5 files changed, 799 insertions(+), 1 deletion(-) create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/liveobjects/ObjectsFaultsTest.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsLifecycleTest.kt create mode 100644 uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsSyncTest.kt diff --git a/uts/build.gradle.kts b/uts/build.gradle.kts index 5286e59eb..4eca7f9c4 100644 --- a/uts/build.gradle.kts +++ b/uts/build.gradle.kts @@ -11,6 +11,8 @@ dependencies { // helpers reach the internal wire/message classes (e.g. for build_public_object_message) by reflection. testRuntimeOnly(project(":liveobjects")) testImplementation(kotlin("test")) + // @ParameterizedTest / @ValueSource — version managed by the junit-bom on the test classpath. + testImplementation("org.junit.jupiter:junit-jupiter-params") testImplementation(libs.mockk) testImplementation(libs.coroutine.core) testImplementation(libs.coroutine.test) diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/liveobjects/ObjectsFaultsTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/liveobjects/ObjectsFaultsTest.kt new file mode 100644 index 000000000..b5989e5e8 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/integration/proxy/liveobjects/ObjectsFaultsTest.kt @@ -0,0 +1,316 @@ +package io.ably.lib.uts.integration.proxy.liveobjects + +import io.ably.lib.liveobjects.path.PathObject +import io.ably.lib.liveobjects.value.LiveMapValue +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.ChannelState +import io.ably.lib.realtime.ConnectionState +import io.ably.lib.types.AblyException +import io.ably.lib.types.ChannelMode +import io.ably.lib.types.ChannelOptions +import io.ably.lib.uts.infra.awaitChannelState +import io.ably.lib.uts.infra.awaitState +import io.ably.lib.uts.infra.integration.SandboxApp +import io.ably.lib.uts.infra.integration.proxy.ProxyManager +import io.ably.lib.uts.infra.integration.proxy.ProxySession +import io.ably.lib.uts.infra.integration.proxy.connectThroughProxy +import io.ably.lib.uts.infra.integration.proxy.wsFrameToClientRule +import io.ably.lib.uts.infra.pollUntil +import io.ably.lib.uts.infra.unit.TestRealtimeClient +import kotlinx.coroutines.future.await +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertFailsWith +import kotlin.time.Duration.Companion.seconds + +/** + * Proxy integration test against Ably Sandbox endpoint. + * + * Uses the programmable uts-proxy to inject transport-level faults while the + * SDK communicates with the real Ably backend. See + * `uts/realtime/integration/helpers/proxy.md` for proxy infrastructure details. + * + * Exercises objects sync/mutation behaviour under faults: sync interrupted by disconnect and + * re-synced on reconnect, mutations buffered during re-sync, server-initiated detach re-sync, and + * publishAndApply failing when the channel enters FAILED. + * + * Spec points: RTO5a2, RTO7, RTO8, RTO17, RTO20e. Source spec: + * `objects/integration/proxy/objects_faults.md`. Corresponding unit specs: `objects/unit/objects_pool.md`, + * `objects/unit/realtime_object.md`. + * + * Proxy tests always use JSON (the proxy can only inspect text frames), which is the + * `ClientOptionsBuilder` default. + * + * > **Translate-only:** `channel.object.get()` resolves only once the SDK's OBJECT_SYNC processing + * > + `RealtimeObject.get()` land, so these compile now and run once the LiveObjects engine is + * > implemented. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ObjectsFaultsTest { + + private lateinit var app: SandboxApp + + @BeforeAll + fun setUpAll() = runBlocking { + ProxyManager.ensureProxy() + app = SandboxApp.create() + } + + @AfterAll + fun tearDownAll() = runBlocking { + if (::app.isInitialized) app.delete() + } + + /** + * @UTS objects/proxy/RTO5a2-RTO17/sync-interrupted-reconnect-0 + */ + @Test + fun `RTO5a2, RTO17 - sync interrupted by disconnect, re-syncs on reconnect`() = runTest { + val channelName = "objects-sync-interrupt-" + UUID.randomUUID() + + // Disconnect after first OBJECT_SYNC (action 20) frame to interrupt the sync. + val session = ProxySession.create( + rules = listOf( + wsFrameToClientRule(action = mapOf("type" to "disconnect"), messageAction = 20, times = 1), + ), + ) + val client = proxyClient(session) + try { + client.connect() + awaitState(client, ConnectionState.connected, 15.seconds) + + val channel = objectChannel(client, channelName) + + // First attach triggers sync; proxy disconnects mid-sync. + channel.attach() + awaitState(client, ConnectionState.disconnected, 15.seconds) + + // Client auto-reconnects; re-attach triggers a fresh sync. + awaitState(client, ConnectionState.connected, 30.seconds) + + // get() waits for SYNCED — resolves only if the re-sync completes. + val root = withTimeout(30.seconds) { channel.`object`.get().await() } + + assertIs(root) + assertEquals("", root.path()) + } finally { + client.close() + session.close() + } + } + + /** + * @UTS objects/proxy/RTO7-RTO8/mutations-buffered-during-resync-0 + */ + @Test + fun `RTO7, RTO8 - mutations during re-sync are buffered and applied`() = runTest { + val channelName = "objects-buffer-resync-" + UUID.randomUUID() + + // Client A: direct connection (no proxy), publishes mutations. + val clientA = directClient() + // Client B: through the proxy, will be disconnected mid-test. + val session = ProxySession.create(rules = emptyList()) + val clientB = proxyClient(session) + try { + clientA.connect() + awaitState(clientA, ConnectionState.connected, 15.seconds) + val rootA = withTimeout(15.seconds) { objectChannel(clientA, channelName).`object`.get().await() } + + // Set initial data + rootA.set("key1", LiveMapValue.of("initial")).await() + + // Client B connects and syncs + clientB.connect() + awaitState(clientB, ConnectionState.connected, 15.seconds) + var rootB = withTimeout(15.seconds) { objectChannel(clientB, channelName).`object`.get().await() } + pollUntil(10.seconds) { rootB.get("key1").asString().value() == "initial" } + + // Disconnect client B + session.triggerAction(mapOf("type" to "disconnect")) + awaitState(clientB, ConnectionState.disconnected, 15.seconds) + + // While B is disconnected, A publishes a mutation + rootA.set("key1", LiveMapValue.of("updated_during_disconnect")).await() + + // Client B reconnects and re-syncs; the mutation should be visible + awaitState(clientB, ConnectionState.connected, 30.seconds) + rootB = withTimeout(15.seconds) { objectChannel(clientB, channelName).`object`.get().await() } + pollUntil(15.seconds) { rootB.get("key1").asString().value() == "updated_during_disconnect" } + + assertEquals("updated_during_disconnect", rootB.get("key1").asString().value()) + } finally { + clientA.close() + clientB.close() + session.close() + } + } + + /** + * @UTS objects/proxy/RTO17/server-detach-resync-0 + */ + @Test + fun `RTO17 - server-initiated detach triggers re-sync on re-attach`() = runTest { + val channelName = "objects-detach-resync-" + UUID.randomUUID() + + val session = ProxySession.create(rules = emptyList()) + val client = proxyClient(session) + try { + client.connect() + awaitState(client, ConnectionState.connected, 15.seconds) + + val channel = objectChannel(client, channelName) + var root = withTimeout(15.seconds) { channel.`object`.get().await() } + + // Set some data + root.set("before_detach", LiveMapValue.of("hello")).await() + assertEquals("hello", root.get("before_detach").asString().value()) + + // Inject a server-initiated DETACHED (action 13) for the channel. + session.triggerAction( + mapOf( + "type" to "inject_to_client", + "message" to mapOf("action" to 13, "channel" to channelName), + ), + ) + + // Client should auto-re-attach (RTL13a). + awaitChannelState(channel, ChannelState.attached, 30.seconds) + + // Re-sync should restore the data. + root = withTimeout(15.seconds) { channel.`object`.get().await() } + pollUntil(15.seconds) { root.get("before_detach").asString().value() == "hello" } + + assertEquals("hello", root.get("before_detach").asString().value()) + } finally { + client.close() + session.close() + } + } + + /** + * @UTS objects/proxy/RTO20e/publish-fails-on-channel-failed-0 + */ + @Test + fun `RTO20e - publishAndApply fails when channel enters FAILED during SYNCING`() = runTest { + val channelName = "objects-publish-failed-" + UUID.randomUUID() + + val session = ProxySession.create(rules = emptyList()) + val client = proxyClient(session) + try { + client.connect() + awaitState(client, ConnectionState.connected, 15.seconds) + + val channel = objectChannel(client, channelName) + val root = withTimeout(15.seconds) { channel.`object`.get().await() } + + // Inject a channel ERROR (action 9) to transition the channel to FAILED. + session.triggerAction( + mapOf( + "type" to "inject_to_client", + "message" to mapOf( + "action" to 9, + "channel" to channelName, + "error" to mapOf("statusCode" to 400, "code" to 90000, "message" to "injected error"), + ), + ), + ) + awaitChannelState(channel, ChannelState.failed, 15.seconds) + + // A mutation (publishAndApply internally) must fail since the channel is FAILED. + val error = assertFailsWith { + root.set("key", LiveMapValue.of("value")).await() + } + assertEquals(92008, error.errorInfo.code) + // The objects layer wraps the channel-level error as the AblyException cause + // (ablyException(errorInfo, cause) -> AblyException.fromErrorInfo(cause, errorInfo)), + // so the injected 90000 channel error is the cause. + val cause = assertIs(error.cause) + assertEquals(90000, cause.errorInfo.code) + } finally { + client.close() + session.close() + } + } + + /** + * @UTS objects/proxy/RTO5-RTO7/publish-during-sync-echo-after-0 + */ + @Test + fun `RTO5, RTO7 - publish during sync, echo arrives after sync completes`() = runTest { + val channelName = "objects-publish-during-sync-" + UUID.randomUUID() + + // Client A: direct, no proxy. + val clientA = directClient() + // Client B: through the proxy, with a delayed first OBJECT_SYNC to keep it SYNCING. + val session = ProxySession.create( + rules = listOf( + wsFrameToClientRule(action = mapOf("type" to "delay", "delayMs" to 3000), messageAction = 20, times = 1), + ), + ) + val clientB = proxyClient(session) + try { + clientA.connect() + awaitState(clientA, ConnectionState.connected, 15.seconds) + val rootA = withTimeout(15.seconds) { objectChannel(clientA, channelName).`object`.get().await() } + + // Set up initial data + rootA.set("existing", LiveMapValue.of("before")).await() + + // Start client B — stuck in SYNCING due to the delayed OBJECT_SYNC. + clientB.connect() + awaitState(clientB, ConnectionState.connected, 15.seconds) + val channelB = objectChannel(clientB, channelName) + channelB.attach() + + // While B is syncing, A publishes a mutation. + rootA.set("existing", LiveMapValue.of("after")).await() + + // B's get() resolves once the delayed sync completes. + val rootB = withTimeout(30.seconds) { channelB.`object`.get().await() } + + // The mutation from A should be visible (in sync data or as a buffered OBJECT). + pollUntil(15.seconds) { rootB.get("existing").asString().value() == "after" } + + assertEquals("after", rootB.get("existing").asString().value()) + } finally { + clientA.close() + clientB.close() + session.close() + } + } + + // ── helpers ────────────────────────────────────────────────────────────── + + /** A realtime client routed through the proxy (localhost hop → nonprod sandbox upstream). */ + private fun proxyClient(session: ProxySession): AblyRealtime = TestRealtimeClient { + key = app.defaultKey + connectThroughProxy(session) + autoConnect = false + } + + /** A realtime client connected straight to the nonprod sandbox (no proxy). */ + private fun directClient(): AblyRealtime = TestRealtimeClient { + key = app.defaultKey + realtimeHost = ProxyManager.sandboxRealtimeHost + restHost = ProxyManager.sandboxRealtimeHost + autoConnect = false + } + + /** A channel with the OBJECT_SUBSCRIBE + OBJECT_PUBLISH modes. */ + private fun objectChannel(client: AblyRealtime, name: String): Channel = + client.channels.get( + name, + ChannelOptions().apply { + modes = arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) + }, + ) +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsLifecycleTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsLifecycleTest.kt new file mode 100644 index 000000000..12e90b6ad --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsLifecycleTest.kt @@ -0,0 +1,283 @@ +package io.ably.lib.uts.integration.standard.liveobjects + +import io.ably.lib.liveobjects.path.PathObject +import io.ably.lib.liveobjects.path.PathObjectListener +import io.ably.lib.liveobjects.path.PathObjectSubscriptionEvent +import io.ably.lib.liveobjects.value.LiveCounter +import io.ably.lib.liveobjects.value.LiveMap +import io.ably.lib.liveobjects.value.LiveMapValue +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.ConnectionState +import io.ably.lib.types.ChannelMode +import io.ably.lib.types.ChannelOptions +import io.ably.lib.uts.infra.awaitState +import io.ably.lib.uts.infra.integration.SandboxApp +import io.ably.lib.uts.infra.integration.proxy.ProxyManager +import io.ably.lib.uts.infra.pollUntil +import io.ably.lib.uts.infra.unit.TestRealtimeClient +import kotlinx.coroutines.future.await +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.util.Collections +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.time.Duration.Companion.seconds + +/** + * Direct-sandbox integration test against the Ably Sandbox + * (`sandbox.realtime.ably-nonprod.net`, via [ProxyManager.sandboxRealtimeHost]) — no proxy, no + * fault injection. Provisions one throwaway [SandboxApp] for the suite and connects real realtime + * clients straight to the sandbox. + * + * End-to-end LiveObjects lifecycle: connect, sync, create/mutate objects via [PathObject], and + * verify propagation to a second client. + * + * Spec points: RTO23, RTPO15, RTPO17. Source spec: `objects/integration/objects_lifecycle_test.md`. + * + * Each test runs once per protocol variant (JSON / msgpack) per the spec's `PROTOCOL` dimension — + * realised here with a `useBinaryProtocol` [ParameterizedTest] parameter. + * + * > **Translate-only:** `channel.object.get()` resolves only once the SDK's OBJECT_SYNC processing + * > + `RealtimeObject.get()` land, so these compile now and run once the LiveObjects engine is + * > implemented. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ObjectsLifecycleTest { + + private lateinit var app: SandboxApp + + @BeforeAll + fun setUpAll() = runBlocking { + app = SandboxApp.create() + } + + @AfterAll + fun tearDownAll() = runBlocking { + if (::app.isInitialized) app.delete() + } + + /** + * @UTS objects/integration/RTO23-RTPO15/set-primitive-propagates-0 + */ + @ParameterizedTest(name = "useBinaryProtocol={0}") + @ValueSource(booleans = [false, true]) + fun `RTO23, RTPO15 - set primitive via PathObject, second client reads it`(useBinaryProtocol: Boolean) = runTest { + val channelName = "objects-lifecycle-" + UUID.randomUUID() + val clientA = newClient(useBinaryProtocol) + val clientB = newClient(useBinaryProtocol) + try { + clientA.connect() + awaitState(clientA, ConnectionState.connected, 15.seconds) + clientB.connect() + awaitState(clientB, ConnectionState.connected, 15.seconds) + + val channelA = objectChannel(clientA, channelName) + val channelB = objectChannel(clientB, channelName) + + val rootA = channelA.`object`.get().await() + val rootB = channelB.`object`.get().await() + + // Client A sets a value + rootA.set("greeting", LiveMapValue.of("hello")).await() + + // Client B subscribes and waits for the update + val eventsB = Collections.synchronizedList(mutableListOf()) + rootB.subscribe(PathObjectListener { event -> eventsB.add(event) }) + pollUntil(10.seconds) { rootB.get("greeting").asString().value() == "hello" } + + assertEquals("hello", rootB.get("greeting").asString().value()) + } finally { + clientA.close() + clientB.close() + } + } + + /** + * @UTS objects/integration/RTPO15/set-counter-value-type-0 + */ + @ParameterizedTest(name = "useBinaryProtocol={0}") + @ValueSource(booleans = [false, true]) + fun `RTPO15 - set with LiveCounterValueType, second client reads counter`(useBinaryProtocol: Boolean) = runTest { + val channelName = "objects-counter-create-" + UUID.randomUUID() + val clientA = newClient(useBinaryProtocol) + val clientB = newClient(useBinaryProtocol) + try { + clientA.connect() + awaitState(clientA, ConnectionState.connected, 15.seconds) + clientB.connect() + awaitState(clientB, ConnectionState.connected, 15.seconds) + + val rootA = objectChannel(clientA, channelName).`object`.get().await() + val rootB = objectChannel(clientB, channelName).`object`.get().await() + + rootA.set("my_counter", LiveMapValue.of(LiveCounter.create(42))).await() + pollUntil(10.seconds) { rootB.get("my_counter").asLiveCounter().value() == 42.0 } + + assertEquals(42.0, rootB.get("my_counter").asLiveCounter().value()) + assertNotNull(rootB.get("my_counter").instance()) + } finally { + clientA.close() + clientB.close() + } + } + + /** + * @UTS objects/integration/RTPO17/increment-propagates-0 + */ + @ParameterizedTest(name = "useBinaryProtocol={0}") + @ValueSource(booleans = [false, true]) + fun `RTPO17 - increment counter, second client sees updated value`(useBinaryProtocol: Boolean) = runTest { + val channelName = "objects-increment-" + UUID.randomUUID() + val clientA = newClient(useBinaryProtocol) + val clientB = newClient(useBinaryProtocol) + try { + clientA.connect() + awaitState(clientA, ConnectionState.connected, 15.seconds) + clientB.connect() + awaitState(clientB, ConnectionState.connected, 15.seconds) + + val rootA = objectChannel(clientA, channelName).`object`.get().await() + val rootB = objectChannel(clientB, channelName).`object`.get().await() + + // Create a counter first + rootA.set("hits", LiveMapValue.of(LiveCounter.create(0))).await() + pollUntil(10.seconds) { rootB.get("hits").asLiveCounter().value() == 0.0 } + + // Increment it + rootA.get("hits").asLiveCounter().increment(10).await() + pollUntil(10.seconds) { rootB.get("hits").asLiveCounter().value() == 10.0 } + + assertEquals(10.0, rootA.get("hits").asLiveCounter().value()) + assertEquals(10.0, rootB.get("hits").asLiveCounter().value()) + } finally { + clientA.close() + clientB.close() + } + } + + /** + * @UTS objects/integration/RTPO15/set-map-value-type-0 + */ + @ParameterizedTest(name = "useBinaryProtocol={0}") + @ValueSource(booleans = [false, true]) + fun `RTPO15 - set with LiveMapValueType, second client reads nested map`(useBinaryProtocol: Boolean) = runTest { + val channelName = "objects-map-create-" + UUID.randomUUID() + val clientA = newClient(useBinaryProtocol) + val clientB = newClient(useBinaryProtocol) + try { + clientA.connect() + awaitState(clientA, ConnectionState.connected, 15.seconds) + clientB.connect() + awaitState(clientB, ConnectionState.connected, 15.seconds) + + val rootA = objectChannel(clientA, channelName).`object`.get().await() + val rootB = objectChannel(clientB, channelName).`object`.get().await() + + rootA.set( + "settings", + LiveMapValue.of( + LiveMap.create( + mapOf( + "theme" to LiveMapValue.of("dark"), + "fontSize" to LiveMapValue.of(14), + ), + ), + ), + ).await() + pollUntil(10.seconds) { + rootB.get("settings").asLiveMap().get("theme").asString().value() == "dark" + } + + assertEquals("dark", rootB.get("settings").asLiveMap().get("theme").asString().value()) + assertEquals(14.0, rootB.get("settings").asLiveMap().get("fontSize").asNumber().value()?.toDouble()) + } finally { + clientA.close() + clientB.close() + } + } + + /** + * @UTS objects/integration/RTO23/get-returns-path-object-0 + */ + @ParameterizedTest(name = "useBinaryProtocol={0}") + @ValueSource(booleans = [false, true]) + fun `RTO23 - get() waits for sync and returns PathObject`(useBinaryProtocol: Boolean) = runTest { + val channelName = "objects-get-root-" + UUID.randomUUID() + val client = newClient(useBinaryProtocol) + try { + client.connect() + awaitState(client, ConnectionState.connected, 15.seconds) + + val root = objectChannel(client, channelName).`object`.get().await() + + assertIs(root) + assertEquals("", root.path()) + assertEquals(0L, root.size()) + } finally { + client.close() + } + } + + /** + * @UTS objects/integration/RTPO15/rest-provisioned-data-sync-0 + */ + @ParameterizedTest(name = "useBinaryProtocol={0}") + @ValueSource(booleans = [false, true]) + fun `RTPO15 - client syncs pre-existing data provisioned via REST`(useBinaryProtocol: Boolean) = runTest { + val channelName = "objects-rest-provision-" + UUID.randomUUID() + + // Provision data via REST before any realtime client connects (see helpers.kt). + // Both provisionObjectsViaRest and the realtime client below target the same nonprod + // sandbox host (ProxyManager.sandboxRealtimeHost), so the provisioned data is visible + // to the client once the SDK's OBJECT_SYNC + RealtimeObject.get() land. + provisionObjectsViaRest( + app.defaultKey, + channelName, + listOf(mapSetOp("provisioned", valueString("from_rest"), objectId = "root")), + ) + + val client = newClient(useBinaryProtocol) + try { + client.connect() + awaitState(client, ConnectionState.connected, 15.seconds) + + val root = objectChannel(client, channelName).`object`.get().await() + + assertEquals("from_rest", root.get("provisioned").asString().value()) + } finally { + client.close() + } + } + + // ── helpers ────────────────────────────────────────────────────────────── + + /** A realtime client wired straight to the nonprod sandbox (no proxy). */ + private fun newClient(useBinaryProtocol: Boolean): AblyRealtime = TestRealtimeClient { + key = app.defaultKey + realtimeHost = ProxyManager.sandboxRealtimeHost + restHost = ProxyManager.sandboxRealtimeHost + this.useBinaryProtocol = useBinaryProtocol + autoConnect = false + } + + /** A channel with the object modes (defaults to OBJECT_SUBSCRIBE + OBJECT_PUBLISH). */ + private fun objectChannel(client: AblyRealtime, name: String, vararg modes: ChannelMode): Channel = + client.channels.get( + name, + ChannelOptions().apply { + this.modes = if (modes.isEmpty()) { + arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) + } else { + arrayOf(*modes) + } + }, + ) +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsSyncTest.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsSyncTest.kt new file mode 100644 index 000000000..5acb565c7 --- /dev/null +++ b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/ObjectsSyncTest.kt @@ -0,0 +1,192 @@ +package io.ably.lib.uts.integration.standard.liveobjects + +import io.ably.lib.liveobjects.path.PathObject +import io.ably.lib.liveobjects.value.LiveMapValue +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.ChannelState +import io.ably.lib.realtime.ConnectionState +import io.ably.lib.types.ChannelMode +import io.ably.lib.types.ChannelOptions +import io.ably.lib.uts.infra.awaitChannelState +import io.ably.lib.uts.infra.awaitState +import io.ably.lib.uts.infra.integration.SandboxApp +import io.ably.lib.uts.infra.integration.proxy.ProxyManager +import io.ably.lib.uts.infra.pollUntil +import io.ably.lib.uts.infra.unit.TestRealtimeClient +import kotlinx.coroutines.future.await +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.time.Duration.Companion.seconds + +/** + * Direct-sandbox integration test against the Ably Sandbox + * (`sandbox.realtime.ably-nonprod.net`, via [ProxyManager.sandboxRealtimeHost]) — no proxy, no + * fault injection. Provisions one throwaway [SandboxApp] for the suite. + * + * Verifies the objects sync sequence against the real server: attach with HAS_OBJECTS, receive + * OBJECT_SYNC, reach SYNCED; two-client convergence; and re-attach re-syncing the pool. + * + * Spec points: RTO4, RTO5, RTO17. Source spec: `objects/integration/objects_sync_test.md`. + * + * Each test runs once per protocol variant (JSON / msgpack) per the spec's `PROTOCOL` dimension — + * realised here with a `useBinaryProtocol` [ParameterizedTest] parameter. + * + * > **Translate-only:** `channel.object.get()` resolves only once the SDK's OBJECT_SYNC processing + * > + `RealtimeObject.get()` land, so these compile now and run once the LiveObjects engine is + * > implemented. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ObjectsSyncTest { + + private lateinit var app: SandboxApp + + @BeforeAll + fun setUpAll() = runBlocking { + app = SandboxApp.create() + } + + @AfterAll + fun tearDownAll() = runBlocking { + if (::app.isInitialized) app.delete() + } + + /** + * @UTS objects/integration/RTO4-RTO5/attach-sync-get-0 + */ + @ParameterizedTest(name = "useBinaryProtocol={0}") + @ValueSource(booleans = [false, true]) + fun `RTO4, RTO5 - attach triggers sync, get() resolves after SYNCED`(useBinaryProtocol: Boolean) = runTest { + val channelName = "objects-sync-" + UUID.randomUUID() + val client = newClient(useBinaryProtocol) + try { + client.connect() + awaitState(client, ConnectionState.connected, 15.seconds) + + val root = objectChannel(client, channelName).`object`.get().await() + + assertIs(root) + assertEquals("", root.path()) + } finally { + client.close() + } + } + + /** + * @UTS objects/integration/RTO5-RTO17/two-clients-sync-0 + */ + @ParameterizedTest(name = "useBinaryProtocol={0}") + @ValueSource(booleans = [false, true]) + fun `RTO5, RTO17 - two clients sync same channel with pre-existing data`(useBinaryProtocol: Boolean) = runTest { + val channelName = "objects-two-sync-" + UUID.randomUUID() + val clientA = newClient(useBinaryProtocol) + val clientB = newClient(useBinaryProtocol) + try { + clientA.connect() + awaitState(clientA, ConnectionState.connected, 15.seconds) + clientB.connect() + awaitState(clientB, ConnectionState.connected, 15.seconds) + + // Client A creates data + val rootA = objectChannel(clientA, channelName).`object`.get().await() + rootA.set("key1", LiveMapValue.of("value1")).await() + + // Client B attaches and syncs — should see the data + val rootB = objectChannel(clientB, channelName).`object`.get().await() + pollUntil(10.seconds) { rootB.get("key1").asString().value() == "value1" } + + assertEquals("value1", rootB.get("key1").asString().value()) + } finally { + clientA.close() + clientB.close() + } + } + + /** + * @UTS objects/integration/RTO17/reattach-resyncs-0 + */ + @ParameterizedTest(name = "useBinaryProtocol={0}") + @ValueSource(booleans = [false, true]) + fun `RTO17 - re-attach re-syncs object pool`(useBinaryProtocol: Boolean) = runTest { + val channelName = "objects-reattach-" + UUID.randomUUID() + val client = newClient(useBinaryProtocol) + try { + client.connect() + awaitState(client, ConnectionState.connected, 15.seconds) + + val channel = objectChannel(client, channelName) + var root = channel.`object`.get().await() + + // Set some data + root.set("before_detach", LiveMapValue.of("hello")).await() + assertEquals("hello", root.get("before_detach").asString().value()) + + // Detach and re-attach + channel.detach() + awaitChannelState(channel, ChannelState.detached) + channel.attach() + awaitChannelState(channel, ChannelState.attached) + + // Re-sync should restore data + root = channel.`object`.get().await() + pollUntil(10.seconds) { root.get("before_detach").asString().value() == "hello" } + + assertEquals("hello", root.get("before_detach").asString().value()) + } finally { + client.close() + } + } + + /** + * @UTS objects/integration/RTO4/attach-subscribe-only-0 + */ + @ParameterizedTest(name = "useBinaryProtocol={0}") + @ValueSource(booleans = [false, true]) + fun `RTO4 - attach without OBJECT_SUBSCRIBE still resolves get() with empty pool`(useBinaryProtocol: Boolean) = runTest { + val channelName = "objects-subscribe-only-" + UUID.randomUUID() + val client = newClient(useBinaryProtocol) + try { + client.connect() + awaitState(client, ConnectionState.connected, 15.seconds) + + val root = objectChannel(client, channelName, ChannelMode.object_subscribe).`object`.get().await() + + assertIs(root) + assertEquals(0L, root.size()) + } finally { + client.close() + } + } + + // ── helpers ────────────────────────────────────────────────────────────── + + /** A realtime client wired straight to the nonprod sandbox (no proxy). */ + private fun newClient(useBinaryProtocol: Boolean): AblyRealtime = TestRealtimeClient { + key = app.defaultKey + realtimeHost = ProxyManager.sandboxRealtimeHost + restHost = ProxyManager.sandboxRealtimeHost + this.useBinaryProtocol = useBinaryProtocol + autoConnect = false + } + + /** A channel with the object modes (defaults to OBJECT_SUBSCRIBE + OBJECT_PUBLISH). */ + private fun objectChannel(client: AblyRealtime, name: String, vararg modes: ChannelMode): Channel = + client.channels.get( + name, + ChannelOptions().apply { + this.modes = if (modes.isEmpty()) { + arrayOf(ChannelMode.object_subscribe, ChannelMode.object_publish) + } else { + arrayOf(*modes) + } + }, + ) +} diff --git a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/helpers.kt b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/helpers.kt index e30acb02b..9d4001a2f 100644 --- a/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/helpers.kt +++ b/uts/src/test/kotlin/io/ably/lib/uts/integration/standard/liveobjects/helpers.kt @@ -6,6 +6,7 @@ import com.google.gson.JsonObject import io.ably.lib.http.HttpUtils import io.ably.lib.rest.AblyRest import io.ably.lib.types.ClientOptions +import io.ably.lib.uts.infra.integration.proxy.ProxyManager /** * LiveObjects **integration** test helpers — the ably-java translation of the UTS @@ -122,7 +123,11 @@ fun provisionObjectsViaRest(apiKey: String, channelName: String, operations: Lis val rest = AblyRest( ClientOptions().apply { key = apiKey - environment = "sandbox" + // Target the same nonprod sandbox host that SandboxApp/ProxyManager and the realtime + // clients use (sandbox.realtime.ably-nonprod.net) — NOT environment="sandbox", which + // resolves to the legacy prod-sandbox host sandbox-rest.ably.io (Hosts.java also forbids + // setting both environment and restHost). Matches standard_test_pool.md (ably/specification#497). + restHost = ProxyManager.sandboxRealtimeHost useBinaryProtocol = false }, )