From 811ce1b6fd44552c8e00d1d07f4a2c0b8584bc5f Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 31 Jul 2026 18:11:16 +0530 Subject: [PATCH] test: enable LiveObjects UTS integration suites (standard, proxy, rest tiers) Applies the UTS integration test suites translated in #2226, now runnable against the completed LiveObjects implementation. The compile-only .disabled traits are removed; all suites pass against the Ably sandbox exactly as originally written (17/17 objects-tier + 27/27 rest-tier): - standard/objects: sync, lifecycle, and GC suites + helpers - proxy/objects: fault-injection suite (self-provisioning uts-proxy) - standard/rest: History/Presence/Publish suites - Package.swift: AblyLiveObjects dependency for the UTS target - deviations.md records and uts-to-swift skill routing updates --- .claude/skills/uts-to-swift/SKILL.md | 48 +- .../references/objects-mapping.md | 803 +++++++++++++++++- Package.swift | 2 + Test/UTS/deviations.md | 61 ++ .../proxy/objects/ObjectsFaultsTests.swift | 335 ++++++++ .../standard/objects/ObjectsGcTests.swift | 121 +++ .../objects/ObjectsLifecycleTests.swift | 251 ++++++ .../standard/objects/ObjectsSyncTests.swift | 184 ++++ .../helpers/ObjectsIntegrationHelpers.swift | 71 ++ .../helpers/ObjectsRestProvisioning.swift | 128 +++ .../standard/rest/HistoryTests.swift | 292 +++++++ .../standard/rest/PresenceTests.swift | 665 +++++++++++++++ .../standard/rest/PublishTests.swift | 292 +++++++ 13 files changed, 3240 insertions(+), 13 deletions(-) create mode 100644 Test/UTS/integration/proxy/objects/ObjectsFaultsTests.swift create mode 100644 Test/UTS/integration/standard/objects/ObjectsGcTests.swift create mode 100644 Test/UTS/integration/standard/objects/ObjectsLifecycleTests.swift create mode 100644 Test/UTS/integration/standard/objects/ObjectsSyncTests.swift create mode 100644 Test/UTS/integration/standard/objects/helpers/ObjectsIntegrationHelpers.swift create mode 100644 Test/UTS/integration/standard/objects/helpers/ObjectsRestProvisioning.swift create mode 100644 Test/UTS/integration/standard/rest/HistoryTests.swift create mode 100644 Test/UTS/integration/standard/rest/PresenceTests.swift create mode 100644 Test/UTS/integration/standard/rest/PublishTests.swift diff --git a/.claude/skills/uts-to-swift/SKILL.md b/.claude/skills/uts-to-swift/SKILL.md index 8c6c31bb3..43e77ed21 100644 --- a/.claude/skills/uts-to-swift/SKILL.md +++ b/.claude/skills/uts-to-swift/SKILL.md @@ -61,15 +61,14 @@ this output — treat it as the single source of truth and don't recompute paths `translationNotes` is the path to a per-module ably-js → ably-cocoa type/interface map when the module declares one (its `notes` field in `uts-package-mapping.json`, e.g. `objects` → `references/objects-mapping.md`), else `null`. When it's non-null, it is **required reading before -Phase 2** — see Step 1. (The `objects` notes file is currently a **placeholder** — if it still only -carries the "intentionally empty" stub, tell the user the module's mapping hasn't been authored yet and -treat the module as not-yet-translatable rather than guessing a mapping.) +Phase 2** — see Step 1. (If a module's notes file only carries an "intentionally empty" placeholder +stub, tell the user the module's mapping hasn't been authored yet and treat the module as +not-yet-translatable rather than guessing a mapping.) ## Step B — Confirm or create the target mapping -The target dirs come from `uts-package-mapping.json` (alongside this skill); spec and ably-cocoa module -names don't always match (ably-java, for example, maps `objects` → `liveobjects`), which is why it's -explicit. +The target dirs come from `uts-package-mapping.json` (alongside this skill); a spec module's name and +the SDK's internal naming for that area don't necessarily match, which is why the mapping is explicit. - **If `mapped` is `true`**: show the resolved `targetDir` for each present tier and ask the user to confirm. If they say the mapping is wrong, ask for the correct ably-cocoa module base name and re-run with `--create` @@ -848,7 +847,11 @@ This is the guarantee that no setup step, operation, or assertion was dropped type — is a valid account.) - [ ] `awaitShortfall > 0` (`summary.testsWithAwaitShortfall`) is the **softer secondary signal** — fewer infra wait calls than spec `AWAIT`s. A custom continuation-bridged helper (like `awaitTime`) - legitimately replaces a wait call, so account for those rather than treating this as a gate. + legitimately replaces a wait call, and on a **natively-async SDK API** (a module whose SDK + surface is `async` methods — its translation notes will say) a + spec `AWAIT op` correctly becomes a plain `try await` expression the counter doesn't see — + expect a benign shortfall on nearly every such test. Account for those rather than treating + this as a gate. ### Setup fidelity — preconditions match the spec @@ -932,6 +935,11 @@ options.useBinaryProtocol = useBinaryProtocol options.autoConnect = false ``` +For a **plugin-backed module**, the client options must also install the module's plugin — the +module's entry-point property traps without it. The module's translation notes name the exact +wiring, and the module packages it as a client-options builder in its module `helpers/` directory +(see **Module helpers** below) — use that builder; don't hand-wire the plugin per test. + **Class docstring** — use a direct-sandbox variant (no proxy/fault-injection wording): ```swift @@ -969,6 +977,32 @@ Use generous timeouts (10–30s) — real network is involved. Everything else i described at the top of this section; a direct-sandbox test just skips the proxy-only subsections (`ProxySession`, rule factories, the event log). +**Natively-async SDK APIs** (modules whose SDK surface is typed-throws `async` methods — their +translation notes will say): a spec `AWAIT op` +translates to a plain `try await op` — no continuation bridging, no infra wait call. Only core-SDK +callback APIs (attach/detach/publish/history) still need the continuation helpers above. Two +consequences: + +- `AWAIT x WITH timeout: N seconds` on such an op has **no per-call Swift equivalent** (`await` + takes no timeout). Keep the plain `try await` and note the spec's timeout in a comment — a hang + surfaces as the test-runner timeout. (Only `AWAIT_STATE` timeouts map to a parameter: + `awaitState(..., timeout: N)`.) +- The Step 7 audit counts only infra wait calls, so these tests report a benign `awaitShortfall` — + see the Step 7 checklist for the valid account. + +**`try?` in `pollUntil` conditions — SE-0230 trap.** Poll closures are non-throwing, so typed +throwing reads get wrapped in `try?` — and Swift **flattens** the nested optional: +`try? node.asPrimitive().value()` (a `throws → Primitive?` call) yields `Primitive?`, not +`Primitive??`, making both "threw" and "resolved to nil" read as `nil` (and making a further `?.` +chain on the unwrapped value a compile error). Package such reads as small non-throwing helpers +(`guard let value = try? … else { return nil }`) rather than inlining the `try?`. + +**Module helpers** — wiring/read helpers shared by *several* of a module's suites (client-options +builders, channel builders, typed `value()` readers) go in a `helpers/` directory **next to the +suites**: `integration/standard//helpers/`. Do not put module-specific helpers under +`infra/` — that stays module-agnostic. Suite-specific helpers (a continuation bridge used by one +file) stay in the per-file extension as usual. + **File template — direct sandbox** (`integration/standard//`): ```swift diff --git a/.claude/skills/uts-to-swift/references/objects-mapping.md b/.claude/skills/uts-to-swift/references/objects-mapping.md index 715a4a91e..89f1474f3 100644 --- a/.claude/skills/uts-to-swift/references/objects-mapping.md +++ b/.claude/skills/uts-to-swift/references/objects-mapping.md @@ -1,7 +1,798 @@ -# `objects` UTS → ably-cocoa: ably-js ⇄ ably-cocoa type/interface map +# `objects` UTS → ably-cocoa `AblyLiveObjects`: ably-js ⇄ Swift type/interface map -> **Placeholder — intentionally empty.** The `objects` UTS specs are written against the -> ably-js LiveObjects API; the mapping to the Swift SDK's surface is SDK-specific and has -> not been authored yet. Fill this in (mirroring the structure of ably-java's -> `uts-to-kotlin/references/objects-mapping.md`) before translating any `objects` module -> spec for ably-cocoa. +Read this **before translating any spec from the `objects` module** (target module +`AblyLiveObjects`, in `LiveObjects/Sources/AblyLiveObjects`). The `objects` UTS specs are written +in a language-agnostic pseudocode that mirrors the **ably-js** LiveObjects API — a +dynamically-typed surface with a single polymorphic `PathObject` / `Instance`, `Promise`-returning +mutators, and raw JS values. ably-cocoa is a **statically-typed SDK** and implements the *Typed-SDK +variant* of the spec (`RTTS1`–`RTTS10` in `objects-features.md`) — with two deliberate, +Swift-idiomatic consolidations recorded on AIT-1023 and in the API's doc comments: + +1. the spec's six primitive path-object / instance sub-types collapse into a single + `PrimitivePathObject` / `PrimitiveInstance` resolving to a pattern-matchable `Primitive` enum + (divergence from `RTTS6c`/`RTTS10c`), and +2. `Instance` is an **enum** (`.liveMap` / `.liveCounter` / `.primitive`), not a base type with + throwing `as*` casts (`RTTS9`), so discrimination is compile-time-exhaustive. + +So almost every spec line needs a mechanical rewrite, not a literal transcription. This doc is that +rewrite table. The canonical bridge is the spec's own Interface Definition +(`## Interface Definition {#idl}`) and its `=== Typed-SDK variant (RTTS1-RTTS10) ===` block; where +this doc and the IDL disagree, check the Swift source (`Path Based API/Public/*.swift`) — it is the +ground truth for this SDK, including the two consolidations above. + +> ⚠️ **Runtime status.** The path-based public API is currently a +> **skeleton**: every `Default*` implementation traps via `notImplemented()` (`fatalError`). Until +> the implementation lands, `objects` specs are **translate-only** — generate + compile (Step 5) +> but do NOT run (Step 6); an evaluation run doesn't fail, it *crashes the test process*. For the +> same reason, every generated public-API suite must carry a +> `.disabled("The path-based LiveObjects public API is not yet implemented …")` trait alongside +> `.serialized` — otherwise a routine `swift test --filter UTS` run (the documented workflow for +> the whole target) fatal-errors. Remove the traits when the implementation lands. Re-check this +> note (grep the SDK for `notImplemented()`) before choosing translate-and-evaluate. + +## Table of contents + +1. [The three layers (don't conflate them)](#1-the-three-layers) +2. [Entry point & channel access](#2-entry-point) +3. [Async: Promise/await → Swift `try await` (typed throws)](#3-async) +4. [Dynamic `PathObject` → typed `PathObject` views](#4-pathobject) +5. [Dynamic `Instance` → the `Instance` enum](#5-instance) +6. [Creation value types & the `LiveMapValue` union](#6-value-types) +7. [Mutations (set / remove / increment / decrement)](#7-mutations) +8. [Subscriptions, listeners & events](#8-subscriptions) +9. [Sync-state events (`object.on('synced')`)](#9-sync-state) +10. [`ValueType` & type discrimination](#10-valuetype) +11. [Message / operation types (`PublicAPI::ObjectMessage` →)](#11-messages) +12. [Errors & error codes](#12-errors) +13. [Internal-graph types (unit specs) — important caveats](#13-internal-graph) +14. [Integration-test helpers — REST fixture provisioning](#14-integration-helpers) +15. [Worked example](#15-worked-example) +16. [Quick symbol index](#16-symbol-index) + +--- + +## 1. The three layers + +The single biggest source of confusion: the spec uses the names `LiveMap` / `LiveCounter` for **two +different things**, and a third *internal* layer underneath. Keep them straight: + +| Layer | Spec name | ably-js | ably-cocoa | Where | +|---|---|---|---|---| +| **Creation value type** — immutable blueprint you pass *into* `set` | `LiveMap` / `LiveCounter` (the `RTLMV*` / `RTLCV*` classes) | `LiveMap.create()` / `LiveCounter.create()` | `struct LiveMap` / `struct LiveCounter` | `Path Based API/Public/ValueTypes.swift` | +| **Public read/write view** — what you navigate & subscribe on | `PathObject`, `Instance` | `PathObject`, `Instance` | `PathObject` protocol + typed views (§4); `Instance` enum (§5) | `Path Based API/Public/PathObject.swift` / `Instance.swift` | +| **Internal graph object** — the live CRDT node | `InternalLiveMap` / `InternalLiveCounter` (`RTLM*` / `RTLC*`), `ObjectsPool` | internal | `InternalDefaultLiveMap` / `InternalDefaultLiveCounter` / `ObjectsPool` — `internal`, not visible to importers | `Internal/` — see §13 | + +So when a spec says `counter = LiveCounter.create(5)` and passes it to `set`, that's the **value +type** (`LiveCounter.create(initialCount: 5)`). When a spec says "the resolved value is an +`InternalLiveCounter` with `.data == 5`", that's the **internal graph node** (§13). When a spec +navigates `root.get("counter").value()`, that's the **public view** (`PathObject`). + +--- + +## 2. Entry point & channel access + +| Spec / ably-js | ably-cocoa (Swift) | +|---|---| +| `channel.object` (objects entry point) | `channel.object` — a **property** on `ARTRealtimeChannel` of type `any RealtimeObject` (`RTL27`). | +| `root = AWAIT channel.object.get()` | `let root = try await channel.object.get()` — `async throws(ARTErrorInfo)`, returns `any LiveMapPathObject` (always a map view, per `RTTS6d`/`RTO23f`). | +| `channel.object.get()` (ably-js generic) | **No generic.** The root is always `any LiveMapPathObject`; narrow downstream with the `as*` views (§4). Drop the type parameter entirely. | +| Channel needs object modes | `let options = ARTRealtimeChannelOptions(); options.modes = [.objectPublish, .objectSubscribe]; let channel = realtime.channels.get(name, options: options)` | + +Accessing `channel.object` without the `LiveObjects` plugin in `ARTClientOptions.plugins` is a +programmer error (traps). The exact wiring is +`options.plugins = [.liveObjects: AblyLiveObjects.Plugin.self]` plus `import AblyLiveObjects` — on +the integration tier it is packaged as `objectsClientOptions(key:useBinaryProtocol:)` in +`Test/UTS/integration/standard/objects/helpers/ObjectsIntegrationHelpers.swift`; **use that +builder, don't hand-wire the plugin per test**. + +--- + +## 3. Async: Promise / await → Swift `try await` (typed throws) + +Every spec `AWAIT`/Promise-returning call is a Swift `async throws(ARTErrorInfo)` method — no +future type to unwrap, and the thrown error is **already** an `ARTErrorInfo` (typed throws): + +| Spec / ably-js | ably-cocoa | +|---|---| +| `AWAIT channel.object.get()` | `try await channel.object.get()` → `any LiveMapPathObject` | +| `AWAIT pathObj.set(k, v)` / `.remove(k)` | `try await mapView.set(key: k, value: v)` / `.remove(key: k)` → `Void` | +| `AWAIT counterObj.increment(n)` / `.decrement(n)` | `try await counterView.increment(amount: n)` / `.decrement(amount: n)` → `Void` | +| `AWAIT instance.set(...)` etc. | `try await` on the matching `LiveMapInstance` / `LiveCounterInstance` method | + +Subscriptions are **not** async — `subscribe(...)` returns synchronously (but `throws`, §8). +Path-resolving reads (`exists()`, `type()`, `value()`, `entries()`, …) are synchronous throwing +**methods** — the `try` is for the `RTO25` access-precondition check, not for I/O. + +**Deferred awaits (`x_future = op()` … `AWAIT x_future`).** `realtime_object.md` repeatedly starts +an operation, does something else (e.g. delivers a mock message), *then* awaits — including +`AWAIT inc_future FAILS WITH error`. Swift has no bare future: wrap the operation in a `Task` at +the "start" line and await its `value` at the `AWAIT` line. `Task`'s error type is untyped, so +re-narrow to `ARTErrorInfo` when asserting a failure: + +```text +# spec +get_future = channel.object.get() +mock_ws.send_to_client(...) # unblocks the get +root = AWAIT get_future +``` +```swift +let getTask = Task { try await channel.object.get() } +mockWs.sendToClient(...) +let root = try await getTask.value +// failure form: AWAIT inc_future FAILS WITH error (code N) +do { + _ = try await incTask.value + Issue.record("expected the deferred operation to fail") +} catch { + #expect(try #require(error as? ARTErrorInfo).code == N) // Task erases the typed throw +} +``` + +--- + +## 4. Dynamic `PathObject` → typed `PathObject` views + +In the spec/ably-js a `PathObject` is polymorphic: `get`, `at`, `value`, `set`, `increment`, +`entries`… all hang off the one object. In ably-cocoa the base `PathObject` protocol exposes +**only** the type-agnostic members; everything type-specific lives on a view protocol you reach via +an `as*` refinement. There are exactly **three** views (not the eight the spec's typed variant +sketches): map, counter, and one consolidated primitive view. + +**Base `PathObject`** — always available: + +| Spec / ably-js | ably-cocoa | +|---|---| +| `pathObj.path()` | `pathObj.path` — a **property** (`String`; the root's is `""`) | +| `pathObj.instance()` | `try pathObj.instance()` → `Instance?` (nil when resolution fails or the value is a primitive — note this is narrower than ably-js/`RTPO8f`; check the Swift doc comment when a spec wraps a primitive) | +| `pathObj.compactJson()` | `try pathObj.compactJson()` → `JSONValue?` | +| `pathObj.compact()` | **Not implemented in ably-cocoa** (`RTTS3f`: typed SDKs need not implement `compact`). Use `compactJson()`; if a spec genuinely needs the non-JSON `compact()` shape, that's a deviation — flag it. | +| `pathObj.subscribe(listener[, opts])` | `try pathObj.subscribe(options:listener:)` → `any Subscription` (§8) | +| *(typed-SDK addition)* exists check | `try pathObj.exists()` → `Bool` (`RTTS4a`) | +| `pathObj.getType()` | `try pathObj.type()` → `ValueType?` — nil when nothing resolves (§10) | +| — view refinements — | `asLiveMap()`, `asLiveCounter()`, `asPrimitive()` — pure type refinements, **never throw**, don't resolve the path (`RTTS5`) | + +**View casts never throw** (`RTTS5d`) — they only re-wrap. A wrong cast surfaces later: read ops on +the wrong-typed view return `nil`/empty; write ops throw (§12). So +`try root.get(key: "k").asLiveCounter().value()` returns `nil` if `k` isn't a counter, rather than +throwing. + +**Map-only members** — require `asLiveMap()` → `any LiveMapPathObject`: + +| Spec / ably-js (on a `PathObject`) | ably-cocoa | +|---|---| +| `pathObj.get(key)` | `mapView.get(key: key)` → `any PathObject` (non-resolving navigation) | +| `pathObj.at("a.b.c")` | `mapView.at(path: "a.b.c")` → `any PathObject` (non-resolving) | +| `pathObj.entries()` | `try mapView.entries()` → `[(key: String, value: any PathObject)]` | +| `pathObj.keys()` / `.values()` | `try mapView.keys()` → `[String]` / `try mapView.values()` → `[any PathObject]` | +| `pathObj.size()` | `try mapView.size()` → `Int?` (nil off-map) | +| `pathObj.set(key, value)` | `try await mapView.set(key: key, value: )` (§6, §7) | +| `pathObj.remove(key)` | `try await mapView.remove(key: key)` | + +> The **root** is already an `any LiveMapPathObject` (from `channel.object.get()`), so +> `root.get(key:)` / `root.set(key:value:)` need no cast — only deeper, freshly-navigated +> `PathObject`s do. + +**Iterating & membership.** `entries()` returns an array of labelled tuples; `keys()` / `values()` +return arrays. The spec's tuple-destructuring loops and `IN` membership map directly: + +```text +# spec +FOR [key, pathObj] IN root.entries(): … +ASSERT "name" IN root.keys() +keys = list(root.keys()) +``` +```swift +for (key, pathObj) in try root.entries() { … } +#expect(try root.keys().contains("name")) +let keys = try root.keys() // already an Array — no materialisation step +``` + +These live on `LiveMapPathObject`, so a *navigated* node needs `asLiveMap()` first +(`try root.get(key: "score").asLiveMap().entries()`); `root` itself doesn't. + +**Counter-only members** — require `asLiveCounter()` → `any LiveCounterPathObject`: + +| Spec / ably-js | ably-cocoa | +|---|---| +| `pathObj.value()` *(when it's a counter)* | `try counterView.value()` → `Double?` (counter value, else nil) | +| `pathObj.increment([n])` | `try await counterView.increment()` / `.increment(amount: n)` | +| `pathObj.decrement([n])` | `try await counterView.decrement()` / `.decrement(amount: n)` | + +**Primitive value reads — the consolidation to know.** The spec's typed-SDK variant defines +six primitive sub-types (`NumberPathObject`, `StringPathObject`, …); ably-cocoa **collapses them +into one** `PrimitivePathObject` whose `value()` returns a `Primitive?` enum you pattern-match or +read via convenience getters (documented divergence from `RTTS6c`; the six-way split appears +nowhere in this SDK): + +| Spec resolves to | ably-cocoa | +|---|---| +| number | `try node.asPrimitive().value()?.numberValue` → `Double?` | +| string | `try node.asPrimitive().value()?.stringValue` → `String?` | +| boolean | `try node.asPrimitive().value()?.boolValue` → `Bool?` | +| binary | `try node.asPrimitive().value()?.dataValue` → `Data?` | +| JSON object | `try node.asPrimitive().value()?.jsonObjectValue` → `[String: JSONValue]?` | +| JSON array | `try node.asPrimitive().value()?.jsonArrayValue` → `[JSONValue]?` | + +So a spec's `pathObj.asNumber().value()` becomes `pathObj.asPrimitive().value()?.numberValue`, and +a spec's `pathObj.asString()` *cast on its own* has no Swift counterpart — fold it into the +`asPrimitive()` read. The getters are stricter the same way the spec's typed accessors are: each +returns `nil` unless the primitive is exactly that case. + +> **Counter vs number.** The dynamic `PathObject#value` (`RTPO7`) returns "the resolved counter +> value *or* any primitive". Translate "ASSERT pathObj.value() == 5" against a **counter** as +> `#expect(try root.get(key: "c").asLiveCounter().value() == 5)`, not via `asPrimitive()`. +> +> **Numbers are just `Double`.** Counter `value()` is `Double?`, `Primitive.number` carries +> `Double`, and `size()` is `Int?` — Swift literals convert, so `== 110` and `size() == 7` work +> as written, no numeric-suffix normalisation needed. A spec `size() == null` (called on a +> non-map) is `#expect(try node.asLiveMap().size() == nil)` — the cast doesn't throw and `size()` +> returns nil off-map. +> +> **Dynamic `value()` reads in `poll_until` conditions (integration tier).** `pollUntil` closures +> are non-throwing, so the typed reads above get wrapped in `try?` — and SE-0230 **flattens** the +> nested optional (`try? node.asPrimitive().value()` yields `Primitive?`, not `Primitive??`; a +> further `?.` chain on the `guard let`-unwrapped value is a compile error). Don't inline the +> `try?`: use the packaged non-throwing readers in +> `Test/UTS/integration/standard/objects/helpers/ObjectsIntegrationHelpers.swift` — +> `counterValue(at:)` / `stringValue(at:)` / `numberValue(at:)`. They return `nil` when the path +> doesn't resolve or resolves to a different type, which is also how the spec's `value() == null` +> (the tombstoned/absent case, `RTLM5d2h`) is asserted. A thrown `RTO25` precondition error also +> reads as `nil` there — fine on the integration tier, which only reads on ATTACHED channels. + +**Path strings & dot-escaping (`RTPO4`/`RTPO4b`/`RTPO6`).** `path` is a dot-delimited `String` +property; the root's is `""`. A literal dot *inside* a segment is escaped as `\.`, and `at(path:)` +parses `\.` back — so `path` round-trips. Mind Swift's own backslash escaping (`"a\\.b.c"` is the +string `a\.b.c`): + +```text +# spec # ably-cocoa (Swift) +ASSERT root.path() == "" #expect(root.path == "") +ASSERT root.get("a").get("b").path() #expect(root.get(key: "a").asLiveMap().get(key: "b").path == "a.b") + == "a.b" +po = root.at("a\.b.c") let po = root.at(path: "a\\.b.c") // segments ["a.b", "c"] +ASSERT po.path() == "a\.b.c" #expect(po.path == "a\\.b.c") +``` + +--- + +## 5. Dynamic `Instance` → the `Instance` enum + +**Structural divergence from ably-js and from the spec's typed variant:** `Instance` is a Swift +**enum** with three +cases carrying the typed payload — there are no `as*` casts and no throwing-cast mismatch path +(`RTTS9d` is unrepresentable by construction). Discriminate by `switch` / `guard case`: + +```swift +let instance = try #require(try root.get(key: "game").instance()) +guard case let .liveMap(map) = instance else { Issue.record("expected a map instance"); return } +``` + +> **`Instance` has no convenience getters.** Unlike `LiveMapValue` (which offers `liveCounterValue` +> etc.), the `Instance` enum exposes only its cases — extracting a payload (e.g. for a spec's +> `pathObj.instance().id`) always needs the `guard case` above. For the recurring counter-id read +> the integration tier packages it as `counterInstanceId(at:)` in +> `Test/UTS/integration/standard/objects/helpers/ObjectsIntegrationHelpers.swift` (uses +> `try #require` + `guard case`, stopping the test on a mismatch). + +**On the enum itself** (`Instance`): + +| Spec / ably-js | ably-cocoa | +|---|---| +| `instance.getType()` | `instance.type` — non-optional `ValueType` (`RTTS8`), O(1) property | +| `instance.compactJson()` | `try instance.compactJson()` → `JSONValue` (**non-optional**, `RTINS11c`) | +| `instance.compact()` | **Not implemented** (`RTTS7d`, same as `PathObject`). Use `compactJson()`; flag a deviation if a spec needs `compact()`. | +| `instance.asLiveMap()` / `.asLiveCounter()` / throwing casts | **No casts.** `switch instance { case let .liveMap(map): … }` — a spec case asserting "cast on wrong type throws" is not expressible (the mismatch is a compile-time impossibility); note it as a deviation, don't force it. | + +**`LiveMapInstance`** (payload of `.liveMap`): + +| Spec / ably-js | ably-cocoa | +|---|---| +| `instance.id` | `map.id` → `String` (non-optional, `RTINS3`) | +| `instance.get(key)` | `try map.get(key: key)` → `Instance?` | +| `instance.entries()` / `.keys()` / `.values()` | `try map.entries()` → `[(key: String, value: Instance)]` / `try map.keys()` → `[String]` / `try map.values()` → `[Instance]` | +| `instance.size()` | `try map.size` → `Int` (**throwing property**, non-optional — the instance is already resolved; `throws` only for the `RTO25` precondition) | +| `instance.set(key, value)` / `.remove(key)` | `try await map.set(key:value:)` / `.remove(key:)` | +| `instance.subscribe(listener)` | `try map.subscribe(listener:)` → `any Subscription` | +| `instance.compactJson()` | `try map.compactJson()` → `JSONValue` | + +**`LiveCounterInstance`** (payload of `.liveCounter`): + +| Spec / ably-js | ably-cocoa | +|---|---| +| `instance.id` | `counter.id` → `String` | +| `instance.value()` | `try counter.value` → `Double` (**throwing property**, non-optional, `RTTS10b`) | +| `instance.increment([n])` / `.decrement([n])` | `try await counter.increment()` / `.increment(amount: n)` / `.decrement()` / `.decrement(amount: n)` | +| `instance.subscribe(listener)` | `try counter.subscribe(listener:)` → `any Subscription` | + +**`PrimitiveInstance`** (payload of `.primitive`) is **read-only** — `try prim.value` → `Primitive` +(non-optional throwing property), `prim.type` (the *specific* primitive `ValueType`, e.g. +`.string`), `try prim.compactJson()`. No `id`, no `get`/`set`, no `subscribe` — one consolidated +type instead of the spec's six (`RTTS10c` divergence, same rationale as §4). + +--- + +## 6. Creation value types & the `LiveMapValue` union + +ably-cocoa can't accept "any JS value" into `set`, so writes take the `LiveMapValue` enum +(`.primitive(Primitive)` / `.liveMap(LiveMap)` / `.liveCounter(LiveCounter)`) — but **literals need +no wrapper at all**: `LiveMapValue` conforms to +`ExpressibleBy{String,Integer,Float,Boolean,Array,Dictionary}Literal`. + +| Spec / ably-js | ably-cocoa | +|---|---| +| `LiveCounter.create()` | `LiveCounter.create()` (initial count 0) | +| `LiveCounter.create(5)` | `LiveCounter.create(initialCount: 5)` | +| `LiveMap.create()` | `LiveMap.create()` | +| `LiveMap.create({ a: 1, b: "x" })` | `LiveMap.create(entries: ["a": 1, "b": "x"])` — entries are `[String: LiveMapValue]`, literals convert | +| a raw string/number/bool/JSON into `set` | pass the literal directly: `set(key: "name", value: "alice")`, `set(key: "n", value: 42)`, `set(key: "cfg", value: ["depth": 2])` | +| a raw **binary** value into `set` | no literal form — `set(key: "blob", value: .primitive(.data(someData)))` | +| a non-literal Swift value (e.g. a `String` variable) | `.primitive(.string(s))` / `.primitive(.number(d))` etc. (or construct the `Primitive` and wrap) | +| creation value into `set` | `.liveCounter(.create(initialCount: 5))` / `.liveMap(.create(entries: …))` | + +Inspect a constructed `LiveMapValue` with its convenience getters (`stringValue`, `numberValue`, +`boolValue`, `dataValue`, `jsonObjectValue`, `jsonArrayValue`, `liveMapValue: LiveMap?`, +`liveCounterValue: LiveCounter?`) when a spec asserts on a value's contents. + +> **Type-safety turns several "invalid input" spec cases into compile errors, not runtime +> assertions.** Where a spec feeds a deliberately wrong type and expects an `ErrorInfo`, the Swift +> signatures reject it at compile time, so the test isn't expressible — note it as a deviation +> rather than forcing it: +> - Passing a **graph object / public view** (`PathObject`, `Instance`, a live object) as a map +> value (`RTLMV4c1`, runtime `40013` in the dynamic API) — blocked by the `LiveMapValue` enum. +> - **Wrong-typed `create` args**, e.g. `LiveCounter.create("not_a_number")` (spec expects +> `40003`) — blocked by `create(initialCount: Double)`; `LiveMap.create(entries:)` likewise +> takes `[String: LiveMapValue]`, so non-`Dict` / non-`String`-key / unsupported-value entry +> cases (`RTLMV4a`/`b`/`c`) can't be constructed either. +> +> Validation cases on *values the type system still allows* (e.g. NaN / out-of-range `Double`) +> remain real runtime assertions — only the cases the signature outright forbids become deviations. + +--- + +## 7. Mutations (set / remove / increment / decrement) + +Putting §4 + §6 together — the canonical write translations: + +```text +# spec +AWAIT root.set("count", LiveCounter.create(0)) +AWAIT root.get("count").increment(5) +AWAIT root.set("name", "alice") +AWAIT root.remove("name") +``` +```swift +// ably-cocoa (root is `any LiveMapPathObject`) +try await root.set(key: "count", value: .liveCounter(.create())) +try await root.get(key: "count").asLiveCounter().increment(amount: 5) +try await root.set(key: "name", value: "alice") +try await root.remove(key: "name") +``` + +- `set` / `remove` live on `LiveMapPathObject` (or `LiveMapInstance`); navigate + `asLiveMap()` + first unless you're on the root or an already-typed map view. +- `increment` / `decrement` live on `LiveCounterPathObject` (or `LiveCounterInstance`); + `asLiveCounter()` first. +- Default-amount forms exist: `increment()` ≡ `increment(amount: 1)`, `decrement()` ≡ + `decrement(amount: 1)`. + +### Wrong-type write failures still go *through* the cast + +A common spec shape is a write on the wrong kind of object, expecting a runtime error — e.g. +`AWAIT root.increment(5) FAILS WITH error` (increment on a map) or `counter.set("k", v) FAILS WITH +error`. In the dynamic API every method exists on every `PathObject`, so the call is expressible +and throws at runtime. In ably-cocoa the typed view **doesn't have that method at all** +(`LiveMapPathObject` has no `increment`; `LiveCounterPathObject` has no `set`), so calling it +directly is a *compile* error — not the runtime failure the spec is testing. + +To translate these, cast to the view whose write method you need (the `PathObject` cast never +throws, `RTTS5d`), then assert the **operation** throws — that's where the `92007` surfaces: + +```text +# spec: increment on a map fails +AWAIT root.increment(5) FAILS WITH error # code 92007 +``` +```swift +do { + try await root.asLiveCounter().increment(amount: 5) + Issue.record("expected increment on a map to throw") +} catch { + #expect(error.code == 92007) // typed throws: `error` is already ARTErrorInfo +} +``` + +So "can't call increment on a map" is **not** "not expressible" — it's +`asLiveCounter().increment(...)` plus an assertion on the throw. (Contrast §6: invalid *value* / +*argument-type* cases genuinely aren't expressible, because the enum/`create` signatures reject +them at compile time. Contrast also §5: a wrong `Instance` **cast** is unrepresentable — but a +wrong-type *write* through a path view stays translatable via this pattern.) + +--- + +## 8. Subscriptions, listeners & events + +ably-js passes a closure and gets back a `Subscription` with `.unsubscribe()`. ably-cocoa is the +same shape — closures, no listener interfaces — plus an `AsyncStream` variant: + +| Spec / ably-js | ably-cocoa | +|---|---| +| `sub = pathObj.subscribe((event) => { … })` | `let sub = try pathObj.subscribe { event in … }` | +| `pathObj.subscribe(listener, { depth: 2 })` | `try pathObj.subscribe(options: .init(depth: 2)) { event in … }` (nil options = default) | +| `sub = instance.subscribe((event) => { … })` | `let sub = try mapOrCounterInstance.subscribe { event in … }` (`InstanceSubscriptionEvent`) | +| `sub.unsubscribe()` | `sub.unsubscribe()` (idempotent, `SUB2b`) | +| `event.object` | `event.object` — `any PathObject` (path sub) / `Instance` (instance sub) | +| `event.message` | `event.message` → `ObjectMessage?` (§11) | +| — Swift-only alternative — | `for await event in try pathObj.events() { … }` / `instance payloads' events()` — an `AsyncStream` that unsubscribes on stream termination. Prefer the closure form when translating (it matches the spec's shape 1:1); the stream form is fine where a spec loops over events. | + +Callback typealiases: `PathObjectSubscriptionCallback = @Sendable (PathObjectSubscriptionEvent) -> +Void`, `InstanceSubscriptionCallback = @Sendable (InstanceSubscriptionEvent) -> Void`. Both events +carry only `object` + `message` — there is **no per-key diff** on the public event (see the +`LiveObjectUpdate` note below). Because the callbacks are `@Sendable`, collect results with the UTS +harness's `Captured` (see the main skill's Captured section), not a bare `var`. + +`subscribe` **throws** (`RTO25` precondition; also `40003` for a non-positive `depth`, +`RTPO19c1`) — so `try` it, and translate a `FAILS WITH` on subscribe as a do/catch (§12). + +> **`LiveObjectUpdate` is not the public event.** `live_object_subscribe.md` cites the internal +> `RTLO4b` `LiveObjectUpdate` (fields `update` / `noop` / `objectMessage` / `tombstone`), but it +> subscribes through the *public* `instance.subscribe(...)`, whose Swift event is +> `InstanceSubscriptionEvent` — only `object` + `message`, **no diff/`noop`/`tombstone` +> accessors**. So "listener fired N times" and "returns a `Subscription`" translate directly, but +> any assertion on the `LiveObjectUpdate` *diff* fields is internal (§13) — adapt or skip with a +> deviation. + +--- + +## 9. Sync-state events (`object.on('synced')`) + +The ably-js string-event API becomes an enum + closure on `RealtimeObject` (`RTO18`). Note the UTS +pseudocode writes the events as **bare constants**, not strings — +`channel.object.on(SYNCED, () => events.append("SYNCED"))` (`realtime_object.md`): + +| Spec / ably-js | ably-cocoa | +|---|---| +| `channel.object.on(SYNCED, cb)` / ably-js `on('synced', cb)` | `let sub = channel.object.on(event: .synced) { … }` → `any StatusSubscription` | +| `channel.object.on(SYNCING, cb)` / `on('syncing', cb)` | `channel.object.on(event: .syncing) { … }` | +| `channel.object.off(cb)` | `sub.off()` — deregistration is **per-subscription** (`RTO18f`); there is no `off(listener)` | +| remove all (`offAll()`) | **No `offAll()` in ably-cocoa.** Call `off()` on each retained `StatusSubscription`; if a spec's assertion depends on a true remove-all, flag a deviation. | + +`ObjectsEvent` cases: `.syncing`, `.synced`. The `on` callback takes no payload +(`@Sendable () -> Void`). Note `on(event:callback:)` does **not** throw, unlike `subscribe` (§8). + +--- + +## 10. `ValueType` & type discrimination + +ably-js uses string-literal type tags; ably-cocoa has `enum ValueType` (`RTTS2`): + +| Spec value category | `ValueType` case | +|---|---| +| string / number / boolean / binary | `.string` / `.number` / `.boolean` / `.binary` | +| JSON object / JSON array | `.jsonObject` / `.jsonArray` | +| live map / live counter | `.liveMap` / `.liveCounter` | +| present but unrecognised | `.unknown` | + +`try pathObj.type()` returns `ValueType?` — nil when nothing resolves (distinct from `.unknown`); +`instance.type` is non-optional. Use `type()` for "what is it" assertions, then the matching `as*` +view (path) or `case` (instance) to read the value. Translate string tags mechanically: +`"LiveMap"`→`.liveMap`, `"number"`→`.number`, etc. + +--- + +## 11. Message / operation types + +The spec's `PublicAPI::ObjectMessage` / `PublicAPI::ObjectOperation` (the `PAOM*` / `PAOOP*` types, +delivered to subscription listeners) map to ably-cocoa **structs with plain `var` properties** +(`Path Based API/Public/PublicObjectMessage.swift`). The `PublicAPI::` prefix is dropped — they are +exposed as `ObjectMessage` / `ObjectOperation` (their internal wire namesakes live under the +`ProtocolTypes` namespace and are invisible to importers). + +**The Swift structs have public +memberwise initializers** — a spec that constructs an expected message for comparison can build one +directly and use `==` (everything here is `Equatable`). What ably-cocoa does *not* expose is the +`PAOM3`/`PAOOP3` construction-from-wire (`fromObjectMessage` / `fromObjectOperation`) — that +conversion is internal, so `public_object_message.md`'s from-wire cases need internal access (§13). + +`ObjectMessage` properties: `id: String?`, `clientId: String?`, `connectionId: String?`, +`timestamp: Date?`, `channel: String`, `operation: ObjectOperation`, `serial: String?`, +`serialTimestamp: Date?`, `siteCode: String?`, `extras: [String: JSONValue]?`. +**Timestamps are `Date`, not epoch millis** — a spec's numeric timestamp `N` compares as +`Date(timeIntervalSince1970: N / 1000)` (or assert on +`timestamp?.timeIntervalSince1970` × 1000). + +`ObjectOperation`: `action: ObjectOperationAction`, `objectId: String`, and optional payloads — +`mapCreate`, `mapSet`, `mapRemove`, `counterCreate`, `counterInc`, `objectDelete`, `mapClear` — +exactly one non-nil, matching the action. + +**The spec accesses these as dotted property chains and compares `action` to a *string literal*; +ably-cocoa uses the same property chains but an *enum case*:** + +```text +# spec +ASSERT msg.operation.action == "MAP_SET" +ASSERT msg.operation.mapSet.key == "name" +ASSERT msg.operation.mapSet.value.string == "blue" +ASSERT msg.operation.counterInc.number == 42 +ASSERT msg.operation.mapCreate == null +``` +```swift +let op = msg.operation +#expect(op.action == .mapSet) // string "MAP_SET" -> enum case +#expect(op.mapSet?.key == "name") +#expect(op.mapSet?.value.string == "blue") // ObjectData.string +#expect(op.counterInc?.number == 42) // Double; literal converts +#expect(op.mapCreate == nil) +``` + +String action tags map SCREAMING_SNAKE → lowerCamel: `"MAP_SET"`→`.mapSet`, +`"COUNTER_INC"`→`.counterInc`, `"OBJECT_DELETE"`→`.objectDelete`, `"MAP_CLEAR"`→`.mapClear`, etc. +Same rule for semantics (`"lww"`→`.lww`) and value types (§10). + +| Spec type | ably-cocoa | Notable members | +|---|---|---| +| `ObjectOperationAction` | `enum ObjectOperationAction` | `.mapCreate, .mapSet, .mapRemove, .counterCreate, .counterInc, .objectDelete, .mapClear` — **no `.unknown` case**; a spec asserting an `UNKNOWN` action is a deviation | +| `MapSet` | `struct MapSet` | `key: String`, `value: ObjectData` | +| `MapRemove` | `struct MapRemove` | `key: String` | +| `MapCreate` | `struct MapCreate` | `semantics: ObjectsMapSemantics`, `entries: [String: ObjectsMapEntry]` (non-optional) | +| `CounterCreate` | `struct CounterCreate` | `count: Double` | +| `CounterInc` | `struct CounterInc` | `number: Double` | +| `ObjectDelete` / `MapClear` | empty structs | no members (assert non-nil-ness) | +| `ObjectData` (leaf value) | `struct ObjectData` | `objectId: String?`, `encoding: String?`, `boolean: Bool?`, `bytes: Data?`, `number: Double?`, `string: String?`, `json: String?` — **`json` is a JSON-encoded `String`**, not a parsed value; decode before structural assertions | +| `ObjectsMapEntry` | `struct ObjectsMapEntry` | `tombstone: Bool?`, `timeserial: String?`, `serialTimestamp: Date?`, `data: ObjectData?` | +| map semantics | `enum ObjectsMapSemantics` | `.lww` only — **no `.unknown`** (deviation if a spec needs it) | + +> Note `PublicAPI::ObjectOperation` carries only `mapCreate`/`counterCreate` (the `*WithObjectId` +> outbound variants are resolved back to their `MapCreate`/`CounterCreate` forms, `PAOOP1`). Don't +> expect a `mapCreateWithObjectId` on the public type — those exist only on the internal +> `ProtocolTypes` wire types. + +--- + +## 12. Errors & error codes + +Spec assertions like `FAILS WITH error code 92007` map to `ARTErrorInfo`. Everything in this API +uses **typed throws** (`throws(ARTErrorInfo)`), so in a `catch` block `error` *is* the +`ARTErrorInfo` — no casting, no future unwrapping: + +```swift +do { + _ = try await channel.object.get() + Issue.record("expected get() to throw") +} catch { + #expect(error.code == 40024) +} +``` + +| Spec failure | ably-cocoa | +|---|---| +| async op rejects with `ErrorInfo` code N | `try await` throws `ARTErrorInfo`; do/catch and assert `error.code == N` | +| wrong write method for the type (e.g. `increment` on a map, `set` on a counter) | the typed view lacks the method — cast first (`asLiveCounter()` / `asLiveMap()`, never throws, `RTTS5d`), then the **operation** throws `92007`. See §7 | +| `Instance` cast on wrong type (`RTTS9d`) | **not expressible** — `Instance` is an enum; the mismatch case doesn't exist. Deviation. | +| `PathObject` `as*` cast on wrong type | **never throws** (`RTTS5d`) — failure shows up on the subsequent read (nil) or write (throws) | +| invalid value into `set` (graph object / view; `RTLMV4c1`, `40013`) | not expressible in the typed `set` — deviation (§6) | +| non-positive subscription `depth` (`RTPO19c1`) | `subscribe(options: .init(depth: 0))` — the `subscribe` call throws `40003` | +| write where path doesn't resolve | `92005` | +| write where value isn't the required type | `92007` | +| `get()` / op when channel lacks the object mode (`RTO23a`/`RTO2a2`) | `40024` | +| access methods when channel is DETACHED or FAILED (`RTO25b`) | `90001` | +| `get()` when channel is FAILED (`RTO23e`/`RTL33c`) | `90001`. **But `get()` on a DETACHED channel does *not* throw** — ensure-active-channel re-attaches and `get()` resolves (`RTO23e`/`RTL33b`); only the access methods gate on DETACHED | +| channel enters DETACHED/SUSPENDED/FAILED while awaiting SYNCED (`RTO20e`/`RTO23c`) | `92008` | +| write while `echoMessages` is false (`RTO26c`) | `40000` | + +Assert the code as a plain `Int` — `#expect(error.code == 90001)` — matching the spec's +`error.code == 90001`; error codes are int literals, not enums (unlike the action / semantics / +value-type tags). The `90000` a spec injects via a mocked `ERROR`/`DETACHED` `ProtocolMessage` is +the channel-level error, not an objects code — it's what drives the channel into the state that +makes the objects call fail. + +**Nested cause (`error.cause.code`).** A spec's nested `error.cause.code` (e.g. `RTO20e`: +top-level `92008` plus cause `90000`): `ARTErrorInfo` has no public typed `cause` accessor — +inspect the underlying `NSError` chain (`error.userInfo[NSUnderlyingErrorKey]`) and verify what the +implementation actually populates **at translation time** (the implementation doesn't exist yet — +see the runtime-status warning at the top). If the cause isn't reachable, assert the top-level code +and flag the cause assertion as a deviation. + +--- + +## 13. Internal-graph types (unit specs) — important caveats + +Several **unit** specs assert on the **internal CRDT graph**, not the public API: +`InternalLiveCounter.data`, `InternalLiveMap.siteTimeserials`, `ObjectsPool.syncState`, +`LiveObjectUpdate`, `applyOperation(msg, source)`, object-id generation (`RTO14`), the +`*CreateWithObjectId` wire variants, etc. Specs that are wholly or mostly internal: + +- `objects_pool.md`, `parent_references.md`, `object_id.md` — pool sync state, the reverse + parent-reference graph, object-id generation: entirely internal. +- the internal-state assertions in `internal_live_counter*.md` / `internal_live_map*.md` (`.data`, + `.siteTimeserials`, `.createOperationIsMerged`, `.isTombstone`, `applyOperation`, `replaceData`) + — internal; their public-facing read/write counterparts are the `path_object*.md` / + `instance.md` specs. +- `value_types.md` — the *public* `LiveMap.create` / `LiveCounter.create` surface maps via §6, but + the evaluation half (`COUNTER_CREATE` / `MAP_CREATE` `ObjectMessage` generation, + nonce/`initialValue`/`objectId` derivation, the `*WithObjectId` wire forms) is + internal/wire-level. +- `realtime_object.md` — **mixed**: `get()` (`RTO23`, incl. the `40024`/`90001`/`92008` + precondition cases) is public and maps via §2/§12, but `publish` / `publishAndApply` + (`RTO15`/`RTO20`, marked `internal` in the IDL) and the OBJECT/ACK wire assertions are internal. +- `public_object_message.md` — the public getters map via §11, but the `PAOM3`/`PAOOP3` + construction-from-wire is internal (`ProtocolTypes.InboundObjectMessage` → public + `ObjectMessage`); translating it needs the internal access described below. + +In ably-cocoa the internal layer lives in the **same module** (`AblyLiveObjects`, +`Internal/` + `Protocol/` directories: `InternalDefaultLiveMap`, `InternalDefaultLiveCounter`, +`InternalDefaultRealtimeObjects`, `ObjectsPool`, and the `ProtocolTypes.*` wire types) with +`internal` access — no reflection tricks needed, but reachable only via +`@testable import AblyLiveObjects` (that's how the plugin's own test suite, +`LiveObjects/Tests/AblyLiveObjectsTests`, accesses them). + +**One-time wiring** — item 1 is +**done**; item 2 is still outstanding and blocks the **unit tier only**: + +1. ✅ **The UTS target depends on `AblyLiveObjects`** (done, 2026-07-17, with the integration-tier + translation). `Package.swift`'s `UTS` test target lists `.target(name: "AblyLiveObjects")`. + Both feared interactions resolved cleanly: the Swift 6 language mode compiles the fully + `Sendable` LiveObjects API without friction, and **no `@available` annotations are needed** in + UTS test code — SPM raises the effective deployment target of test targets above the package + floor (verified: `swift build --build-tests` clean with unannotated suites). +2. **The spec helpers have no Swift implementation.** Every objects unit spec opens with + `setup_synced_channel` and builds messages with `build_*` helpers, all fully specified in + `uts/objects/helpers/standard_test_pool.md` — implement from that document. + Author the Swift implementation at `Test/UTS/infra/unit/objects/` (e.g. a + `SyncedChannel` scoped-resource helper on top of `UTSTestCase`'s mock transport, for both + `setup_synced_channel` and `setup_synced_channel_no_ack`; builders for the full + `standard_test_pool.md` set — `buildObjectSyncMessage` / `buildObjectMessage` / + `buildAckMessage` / `buildCounterInc` / `buildMapSet` / `buildMapRemove` / `buildMapClear` / + `buildObjectDelete` / `buildCounterCreate` / `buildMapCreate` / `buildObjectState` / + `buildObjectMessageWithState` / `buildPublicObjectMessage` (the last performs the §11 + from-wire construction, so it lives behind `@testable import`); the inline ObjectData / + map-entry / state fragment helpers (`dataString` / `dataNumber` / `dataBoolean` / + `dataObjectId` / `dataBytes` / `dataJson`, `mapEntry`, `mapState`, `counterState`, + `mapCreateOp`, `counterCreateOp`); the `STANDARD_POOL_OBJECTS` fixture; and the canonical + serial constants `POOL_SERIAL` / `ackSerial(msgSerial:i:)` / `remoteSerial(i:)` / + `belowAckSerial(i:)`) — the spec's snake_case names, camelCased. **Call helpers; never + hand-roll the mock setup, message JSON, or `"t:N"` serial literals** (serials compare as + strings — ad-hoc values silently sort wrong). + +Spec name → ably-cocoa impl (for orientation, not public use): `InternalLiveMap` → +`InternalDefaultLiveMap`, `InternalLiveCounter` → `InternalDefaultLiveCounter`, the public-view +impls are `DefaultLiveMapPathObject` / `DefaultLiveCounterInstance` / … (`Path Based API/Default/`, +currently all `notImplemented()`), wire forms are `ProtocolTypes.ObjectMessage` / +`WireObjectMessage` / `WireObjectOperation` etc. + +> **Runtime status recap (top of doc):** the path-based public surface **traps** +> (`notImplemented()`). Internal-layer specs may be runnable earlier +> than public-API specs (the internal CRDT machinery predates the path-based API and has a passing +> test suite in `AblyLiveObjectsTests`), but check what the spec actually calls before choosing +> evaluate mode. + +--- + +## 14. Integration-test helpers — REST fixture provisioning + +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` (`standard_test_pool.md`). + +**Implemented** at `Test/UTS/integration/standard/objects/helpers/ObjectsRestProvisioning.swift` +(module helpers live in a `helpers/` directory next to the module's suites, not under the +module-agnostic `infra/`; shared client/channel wiring and `value()` read helpers sit alongside in +`ObjectsIntegrationHelpers.swift`): +`provisionObjectsViaRest(apiKey:channelName:operations:) -> [String]` plus the op/value builders +(`mapSetOp`, `mapRemoveOp`, `mapCreateOp`, `counterCreateOp`, `counterIncOp`; `valueString`, +`valueNumber`, `valueBoolean`, `valueBytes`, `valueObjectId`). Two facts the implementation +encodes (hard-won — don't re-derive them): + +- **V2 REST format** (OpenAPI is the source of truth): `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's legacy + `POST …/objects` + `{ messages: [...] }` shape was aligned to V2 in ably/specification#497. +- **Sandbox host**: use `SandboxApp.sandboxHost` (`sandbox.realtime.ably-nonprod.net`, + `Test/UTS/infra/integration/SandboxApp.swift`) — the same nonprod host every UTS integration + client uses; do not use the legacy `sandbox-rest.ably.io`. + +Implementation details settled by the port (don't re-derive them): + +- `operations` must be non-empty (`precondition`). +- **Response parsing**: the body is a single result object *or* a JSON array of them, each carrying + an `objectIds` string array; the helper flattens those into its `[String]` return. The UTS spec + helper documents no response contract — flagged upstream. +- The cocoa port posts through the UTS infra's plain `URLSession` helpers + (`jsonRequest`/`httpRequest`, `infra/Utils.swift`) rather than an `ARTRest` client, so the spec's + "REST client must be closed after use" note doesn't apply here. + +The realtime client then observes the provisioned data through OBJECT_SYNC + +`channel.object.get()` — which requires the path-based implementation, so these specs are gated on +the runtime status like everything else. + +**The GC spec** (`objects_gc_test.md`, RTO10/RTLM19 tombstone semantics) needs no extra infra, but +it is the only integration spec that reads `instance().id` (hence `counterInstanceId(at:)` in +`ObjectsIntegrationHelpers.swift`, §5). + +**Sandbox-app scoping.** The `IntegrationTestCase` pattern provisions per test via +`withSandboxApp`, so each parameterised case (×2 protocol variants) creates and deletes its own +app. Deliberate — Swift Testing has no suite-level async fixtures — but keep it in mind if sandbox +provisioning ever becomes a bottleneck. + +**Proxy tier.** The objects module also has a proxy spec +(`integration/proxy/objects_faults.md`) — fault injection against `channel.object.get()` and +friends. Nothing objects-specific is needed for the proxy *infrastructure*: use the main skill's +proxy tier (session/rules/actions, `AuthReauthTests.swift` as the reference) and this doc purely +for the API mapping. + +--- + +## 15. Worked example + +Spec pseudocode (public-API style): + +```text +test "increments a nested counter and observes it" + root = AWAIT channel.object.get() + AWAIT root.set("game", LiveMap.create({ score: LiveCounter.create(0) })) + scoreSub = root.at("game.score").subscribe((event) => { received = event }) + AWAIT root.at("game.score").increment(10) + ASSERT root.at("game.score").value() == 10 + ASSERT received.object.value() == 10 + scoreSub.unsubscribe() +``` + +ably-cocoa / Swift translation: + +```swift +let root = try await channel.object.get() // `any LiveMapPathObject` + +try await root.set( + key: "game", + value: .liveMap(.create(entries: ["score": .liveCounter(.create())])) +) + +let received = Captured() // @Sendable callback -> harness collector +let scoreSub = try root.at(path: "game.score").subscribe { event in + received.append(event) +} + +try await root.at(path: "game.score").asLiveCounter().increment(amount: 10) + +#expect(try root.at(path: "game.score").asLiveCounter().value() == 10) +#expect(try #require(received.all.last).object.asLiveCounter().value() == 10) +scoreSub.unsubscribe() +``` + +Note the mechanical rewrites: `AWAIT` → `try await` (no future); nested creation via the +`.liveMap(.create(entries:))` / `.liveCounter(.create())` enum cases (primitive literals would need +no wrapper at all); `at(...)` followed by `asLiveCounter()` before counter ops; the `@Sendable` +listener capturing through `Captured`; `event.object` re-cast with `asLiveCounter()`. + +--- + +## 16. Quick symbol index + +| ably-js / spec symbol | ably-cocoa | +|---|---| +| `channel.object` | `channel.object: any RealtimeObject` (property) | +| `channel.object.get()` | `try await channel.object.get()` → `any LiveMapPathObject` | +| `PathObject` (polymorphic) | `PathObject` protocol + `asLiveMap()` / `asLiveCounter()` / `asPrimitive()` (3 views, not 8) | +| `Instance` (polymorphic) | `enum Instance { .liveMap / .liveCounter / .primitive }` — `switch`, no casts | +| `pathObj.path()` | `pathObj.path` (property) | +| `pathObj.get(k)` / `.at(p)` | `pathObj.asLiveMap().get(key: k)` / `.at(path: p)` | +| `pathObj.value()` | counter: `asLiveCounter().value()` → `Double?`; primitive: `asPrimitive().value()?.numberValue` etc. | +| `pathObj.set(k, v)` / `.remove(k)` | `try await pathObj.asLiveMap().set(key: k, value: v)` / `.remove(key: k)` | +| `pathObj.increment(n)` / `.decrement(n)` | `try await pathObj.asLiveCounter().increment(amount: n)` / `.decrement(amount: n)` | +| `op FAILS WITH ` (wrong method for type) | cast to the needed view, do/catch the op, `#expect(error.code == )` (§7, §12) | +| `FOR [k, v] IN x.entries()` | `for (k, v) in try x.asLiveMap().entries()` | +| `"k" IN x.keys()` / `list(x.keys())` | `try x.asLiveMap().keys().contains("k")` / `try x.asLiveMap().keys()` | +| `size() == 7` / `== null` | `#expect(try …size() == 7)` (`Int?`) / `#expect(try node.asLiveMap().size() == nil)` | +| `op.action == "MAP_SET"` | `#expect(op.action == .mapSet)` (string tag → enum case, lowerCamel) | +| `op.mapSet.value.string` | `op.mapSet?.value.string` (`ObjectData` properties) | +| `LiveMap.create(entries)` | `LiveMap.create(entries: [String: LiveMapValue])` (value type) | +| `LiveCounter.create(n)` | `LiveCounter.create(initialCount: n)` (value type) | +| raw value into `set` | literals pass directly (`ExpressibleBy*Literal`); non-literals via `.primitive(.string(s))` etc.; binary always `.primitive(.data(d))` | +| `subscribe(cb)` → `Subscription` | `try subscribe { event in … }` → `any Subscription` (also `events()` AsyncStream) | +| `{ depth: n }` | `PathObjectSubscriptionOptions(depth: n)` | +| `event.object` / `event.message` | `event.object` / `event.message: ObjectMessage?` | +| `object.on('synced', cb)` | `channel.object.on(event: .synced) { … }` → `any StatusSubscription`; `off()` per subscription (no `off(listener)`/`offAll`) | +| type tag `'LiveMap'` etc. | `ValueType.liveMap` etc.; `type()` optional on paths, `type` non-optional on instances | +| `PublicAPI::ObjectMessage` | `struct ObjectMessage` (properties + public init; timestamps are `Date`) | +| `PublicAPI::ObjectOperation` | `struct ObjectOperation` (one payload non-nil; no `.unknown` action) | +| `InternalLiveMap` / `InternalLiveCounter` / `ObjectsPool` | `internal` in `AblyLiveObjects` — `@testable import` + UTS-target dependency wiring, §13 | +| `setup_synced_channel` / `build_*` / `provision_objects_via_rest` | **no Swift impl yet** — author per §13/§14 before first use | diff --git a/Package.swift b/Package.swift index ae528b3e3..efe4436fb 100644 --- a/Package.swift +++ b/Package.swift @@ -115,6 +115,8 @@ let package = Package( dependencies: [ .byName(name: "Ably"), .target(name: "_AblyPluginSupportPrivate"), + // The `objects` UTS module tests the LiveObjects plugin's public API. + .target(name: "AblyLiveObjects"), ], path: "Test/UTS", exclude: [ diff --git a/Test/UTS/deviations.md b/Test/UTS/deviations.md index f4f6ed97e..ffc123bc0 100644 --- a/Test/UTS/deviations.md +++ b/Test/UTS/deviations.md @@ -32,4 +32,65 @@ _None currently._ ## Mock Infrastructure Limitations _None currently._ + +--- + +## Entries applied from PR ably/ably-cocoa#2226 + +The following deviation was authored in PR #2226 (the original objects UTS integration-test PR). It is +a **Failing Tests** entry (SDK non-compliance, spec-correct test skipped) and is appended here rather +than merged into the section above so no existing content is overwritten. + +### RSL1l1 — no publish-with-params API + +1. **Spec point**: RSL1l1 (UTS `rest/integration/RSL1l1/publish-params-force-nack-0`) +2. **Spec requirement**: Additional publish params can be supplied with a publish and are + transmitted to the server as request query params (the UTS test exercises this with the + `_forceNack: "true"` test param, expecting the publish to fail with error code 40099). +3. **Actual SDK behaviour**: ably-cocoa exposes no publish overload accepting request params. + `params:` exists only on the message-edit methods (`updateMessage:operation:params:callback:`, + `deleteMessage:…`, `appendMessage:…` — RSL15f), on both `ARTChannelProtocol` and the internal + `ARTChannel`, so the spec's `channel.publish(message:, params:)` cannot be expressed at all. +4. **Root cause**: `Source/include/Ably/ARTChannelProtocol.h` / `Source/ARTRestChannel.m` — the + publish family (`publish:data:…`, `publish:` messages array) has no `params:` variant; RSL1l1 is + unimplemented. +5. **Test impact**: `UTS.PublishTests/test_RSL1l1_publish_params_with_forceNack` — the spec-correct + test cannot compile, so the method carries the spec pseudocode as comments, is gated behind + `RUN_DEVIATIONS`, and fails via `Issue.record` pointing here when enabled. Reproduce: + `RUN_DEVIATIONS=1 swift test --filter UTS.PublishTests/test_RSL1l1_publish_params_with_forceNack`. + +--- + +## PR #2226 application record + +Applied on branch `feature/liveobjects-implementation` against the completed LiveObjects +implementation (the PR was originally authored when the public API was a trapping skeleton). + +- **Applied (integration tier)**: `Test/UTS/integration/standard/objects/{ObjectsGcTests,ObjectsLifecycleTests,ObjectsSyncTests}.swift` + + `helpers/{ObjectsIntegrationHelpers,ObjectsRestProvisioning}.swift`; the proxy-tier + `Test/UTS/integration/proxy/objects/ObjectsFaultsTests.swift`; and the rest-tier + `Test/UTS/integration/standard/rest/{HistoryTests,PresenceTests,PublishTests}.swift`. These are the + point of this PR: the objects integration tests are now driveable against the finished + implementation. +- **Applied (skill/reference)**: `.claude/skills/uts-to-swift/SKILL.md` (integration/proxy tier notes) + and `.claude/skills/uts-to-swift/references/objects-mapping.md` (objects UTS translation notes). +- **Applied (Package.swift)**: added the `AblyLiveObjects` dependency to the `UTS` test target so the + objects integration tests can import the plugin. +- **Skipped**: no unit-tier objects tests were in this PR (the 15 objects unit specs were already + consolidated into `LiveObjects/Tests/AblyLiveObjectsTests/UTS/`), so there were no duplicates to + drop. +- **Adapted**: `Test/UTS/deviations.md` entries were appended (not overwritten); `Package.swift` + reconciled by hand due to a trailing-comma context drift. +- **Enabled**: the `.disabled("The path-based LiveObjects public API is not yet implemented…")` + trait was removed from all four objects suites — the implementation has landed, which is the + condition the trait's own text set for its removal. +- **No test adaptations were needed**: the suites run exactly as authored in the PR. Two + implementation-side gaps they surfaced were fixed in the SDK instead (stakeholder-approved): + the RTL33b implicit attach in `RealtimeObject.get()` (formerly DEV-46; new plugin-API accessor) + and a `deinit`-on-internal-queue teardown crash in the objects engine. A `siteCode` seeding hole + (RTO20 local echo silently skipped for channels created after CONNECTED) was also found via these + tests' logs and fixed. +- **Results at time of application** (macOS, nonprod sandbox): standard objects suites 12/12 passed; + proxy `ObjectsFaultsTests` 5/5 passed; rest-tier History/Presence/Publish 27/27 passed with + `test_RSL1l1_publish_params_with_forceNack` skipped per the RSL1l1 deviation entry above. diff --git a/Test/UTS/integration/proxy/objects/ObjectsFaultsTests.swift b/Test/UTS/integration/proxy/objects/ObjectsFaultsTests.swift new file mode 100644 index 000000000..d3eb743e3 --- /dev/null +++ b/Test/UTS/integration/proxy/objects/ObjectsFaultsTests.swift @@ -0,0 +1,335 @@ +// Proxy tests spawn a local uts-proxy process — macOS-only. +#if os(macOS) + +import Testing +import Foundation +import Ably +import AblyLiveObjects + +/// Objects fault handling (RTO5a2, RTO7, RTO8, RTO17, RTO20e, RTO20e1) +/// Derived from ably/specification `uts/objects/integration/proxy/objects_faults.md` +/// +/// 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/docs/proxy.md` for proxy infrastructure details. +/// +/// Corresponding unit tests: +/// - `objects/unit/objects_pool.md` — RTO5a2 (new sync discards old), RTO7/RTO8 (buffering during +/// SYNCING) +/// - `objects/unit/realtime_object.md` — RTO17 (sync state events), RTO20e (publishAndApply waits +/// for SYNCED), RTO20e1 (in-flight operation fails with 92008 when the channel leaves the +/// attached state during the sync wait) +/// +/// Needs outbound network (the uts-proxy binary on first run, then the Ably sandbox): +/// +/// ```bash +/// swift test --filter UTS.ObjectsFaultsTests +/// ``` +@Suite(.serialized) +final class ObjectsFaultsTests: ProxyTestCase { + + // UTS: objects/proxy/RTO5a2-RTO17/sync-interrupted-reconnect-0 + @Test + func test_RTO5a2_RTO17_sync_interrupted_by_disconnect_resyncs_on_reconnect() async throws { + // Setup + // (The spec's BEFORE/AFTER ALL sandbox app provisioning, the session teardown, and the + // common client-close cleanup are owned by the withProxySession/withRealtimeClient scopes.) + let channelName = "objects-sync-interrupt-\(UUID().uuidString)" + + // Disconnect after first OBJECT_SYNC frame + // (spec rule comment: "RTO5a2: Disconnect after first OBJECT_SYNC to interrupt sync". + // OBJECT_SYNC must be matched by the numeric string "20" — uts-proxy resolves action + // names only up to AUTH (17); wsFrameToClientRule stringifies the number.) + try await withProxySession(rules: [ + wsFrameToClientRule(action: ["type": "disconnect"], messageAction: 20, times: 1), + ]) { app, session in + let options = objectsProxyClientOptions(for: app, through: session) + try await withRealtimeClient(options) { client in + let channel = objectsChannel(client, channelName) + + // Test Steps + client.connect() + guard await awaitState(client, .connected, timeout: 15) else { return } + + // First attach triggers sync; proxy disconnects mid-sync + channel.attach() + guard await awaitState(client, .disconnected, timeout: 15) else { return } + + // Client auto-reconnects; re-attach triggers fresh sync + guard await awaitState(client, .connected, timeout: 30) else { return } + + // get() waits for SYNCED — will only resolve if re-sync completes + // (spec: WITH timeout: 30 seconds — `await` takes no per-call timeout; a hang + // surfaces as the test-runner timeout) + let root = try await channel.object.get() + + // Assertions + // ASSERT root IS PathObject + // (no runtime assertion: satisfied by get()'s return type, `any LiveMapPathObject`) + #expect(root.path == "") + } + } + } + + // UTS: objects/proxy/RTO7-RTO8/mutations-buffered-during-resync-0 + @Test + func test_RTO7_RTO8_mutations_during_resync_are_buffered_and_applied() async throws { + // Setup + let channelName = "objects-buffer-resync-\(UUID().uuidString)" + + // (The spec creates client A before the proxy session; withProxySession owns the app + + // session provisioning so the session scope opens first — functionally equivalent, since + // only client B's traffic passes through the proxy.) + try await withProxySession(rules: []) { app, session in + // Client A: direct connection (no proxy), publishes mutations + // (the spec doesn't fix client A's protocol; JSON to match the proxy tier) + let optionsA = objectsClientOptions(key: app.defaultKey, useBinaryProtocol: false) + try await withRealtimeClient(optionsA) { clientA in + clientA.connect() + guard await awaitState(clientA, .connected, timeout: 15) else { return } + + let channelA = objectsChannel(clientA, channelName) + let rootA = try await channelA.object.get() + + // Set initial data + try await rootA.set(key: "key1", value: "initial") + + // Client B: through proxy, will be disconnected + let optionsB = objectsProxyClientOptions(for: app, through: session) + try await withRealtimeClient(optionsB) { clientB in + let channelB = objectsChannel(clientB, channelName) + + // Test Steps + // Client B connects and syncs + clientB.connect() + guard await awaitState(clientB, .connected, timeout: 15) else { return } + + let rootB = try await channelB.object.get() + guard await pollUntil("rootB.key1 == \"initial\"", timeout: 10, { + stringValue(at: rootB.get(key: "key1")) == "initial" + }) else { return } + + // Disconnect client B + try await session.triggerAction(["type": "disconnect"]) + guard await awaitState(clientB, .disconnected, timeout: 15) else { return } + + // While B is disconnected, A publishes a mutation + try await rootA.set(key: "key1", value: "updated_during_disconnect") + + // Client B reconnects and re-syncs; the mutation should be visible + guard await awaitState(clientB, .connected, timeout: 30) else { return } + + let resyncedRootB = try await channelB.object.get() + guard await pollUntil("rootB.key1 == \"updated_during_disconnect\"", timeout: 15, { + stringValue(at: resyncedRootB.get(key: "key1")) == "updated_during_disconnect" + }) else { return } + + // Assertions + #expect(stringValue(at: resyncedRootB.get(key: "key1")) == "updated_during_disconnect") + + // Teardown (spec: client_a.close() / client_b.close() / session.close()) — + // handled by the withRealtimeClient / withProxySession scopes. + } + } + } + } + + // UTS: objects/proxy/RTO17/server-detach-resync-0 + @Test + func test_RTO17_server_initiated_detach_triggers_resync_on_reattach() async throws { + // Setup + let channelName = "objects-detach-resync-\(UUID().uuidString)" + + try await withProxySession(rules: []) { app, session in + let options = objectsProxyClientOptions(for: app, through: session) + try await withRealtimeClient(options) { client in + let channel = objectsChannel(client, channelName) + + // Test Steps + client.connect() + guard await awaitState(client, .connected, timeout: 15) else { return } + + let root = try await channel.object.get() + + // Set some data + try await root.set(key: "before_detach", value: "hello") + #expect(stringValue(at: root.get(key: "before_detach")) == "hello") + + // Inject server-initiated DETACHED + let detachedMessage: [String: any Sendable] = ["action": 13, "channel": channelName] + try await session.triggerAction(["type": "inject_to_client", "message": detachedMessage]) + + // Client should auto-re-attach (RTL13a) + guard await awaitChannelState(channel, .attached, timeout: 30) else { return } + + // Re-sync should restore data + let resyncedRoot = try await channel.object.get() + guard await pollUntil("root.before_detach == \"hello\" after re-attach", timeout: 15, { + stringValue(at: resyncedRoot.get(key: "before_detach")) == "hello" + }) else { return } + + // Assertions + #expect(stringValue(at: resyncedRoot.get(key: "before_detach")) == "hello") + } + } + } + + // UTS: objects/proxy/RTO20e/publish-fails-on-channel-failed-0 + @Test + func test_RTO20e_publishAndApply_fails_when_channel_enters_FAILED_during_SYNCING() async throws { + // Setup + let channelName = "objects-publish-failed-\(UUID().uuidString)" + + try await withProxySession(rules: []) { app, session in + let options = objectsProxyClientOptions(for: app, through: session) + try await withRealtimeClient(options) { client in + let channel = objectsChannel(client, channelName) + + // Test Steps + client.connect() + guard await awaitState(client, .connected, timeout: 15) else { return } + + let root = try await channel.object.get() + + // Force the objects back into SYNCING: inject an ATTACHED (action 11) carrying the + // HAS_OBJECTS flag (bit 7, i.e. flags: 128). RTO4c starts a new sync sequence on every + // ATTACHED protocol message; the server never sent this ATTACHED, so no OBJECT_SYNC + // follows and the objects remain SYNCING. The channel itself stays ATTACHED. + let attachedMessage: [String: any Sendable] = [ + "action": 11, + "channel": channelName, + "flags": 128, + ] + try await session.triggerAction(["type": "inject_to_client", "message": attachedMessage]) + + // Mutate WHILE SYNCING: the channel is ATTACHED so the write preconditions (RTO26) + // pass and the publish + ACK complete against the real server; publishAndApply then + // waits for a SYNCED that will never arrive (RTO20e). Do not await yet. + let pending = Task { try await root.set(key: "key", value: "value") } + + // Ensure the operation is in the RTO20e sync-wait, not still publishing: wait until + // the proxy log shows the server's ACK (action 1) for the OBJECT publish, then allow + // a brief real-time yield for the client to move the ACKed operation into the wait. + // (There is no observable client state between "ACK processed" and "parked in the + // sync-wait" to poll on, so a small fixed yield is required — the deriving SDK may + // substitute an equivalent scheduler yield.) + guard await pollUntil("server ACK (action 1) recorded in the proxy log", timeout: 10, { + let log = (try? await session.getLog()) ?? [] + return log.contains { event in + event.type == "ws_frame" + && event.direction == "server_to_client" + && event.message?["action"] as? Int == 1 + } + }) else { return } + // WAIT 500ms // real (wall-clock) time + try await Task.sleep(nanoseconds: 500_000_000) + + // The channel enters FAILED whilst the operation waits for SYNCED (RTO20e1) + let errorMessage: [String: any Sendable] = [ + "action": 9, + "channel": channelName, + "error": ["statusCode": 400, "code": 90000, "message": "injected error"] as [String: any Sendable], + ] + try await session.triggerAction(["type": "inject_to_client", "message": errorMessage]) + + guard await awaitChannelState(channel, .failed, timeout: 15) else { return } + + // AWAIT pending FAILS WITH error + // (spec: WITH timeout: 15 seconds — `await` takes no per-call timeout; a hang + // surfaces as the test-runner timeout) + do { + try await pending.value + Issue.record("expected the pending set operation to fail with 92008 (RTO20e1)") + } catch { + // Assertions + let errorInfo = try #require(error as? ARTErrorInfo) // Task erases the typed throw + #expect(errorInfo.code == 92008) + #expect(errorInfo.statusCode == 400) + // RTO20e1: cause is set to RealtimeChannel.errorReason — the injected channel ERROR + let cause = try #require(errorInfo.cause) + #expect(cause.code == 90000) + } + } + } + } + + // UTS: objects/proxy/RTO5-RTO7/publish-during-sync-echo-after-0 + @Test + func test_RTO5_RTO7_publish_during_sync_echo_arrives_after_sync_completes() async throws { + // Setup + let channelName = "objects-publish-during-sync-\(UUID().uuidString)" + + // (As in the RTO7-RTO8 test, the spec creates client A before the proxy session; the + // withProxySession scope opens first here, which is functionally equivalent — the delay + // rule only sees client B's traffic.) + // (spec rule comment: "Delay first OBJECT_SYNC to keep B in SYNCING state". + // OBJECT_SYNC is matched by the numeric string "20".) + try await withProxySession(rules: [ + wsFrameToClientRule(action: ["type": "delay", "delayMs": 3000], messageAction: 20, times: 1), + ]) { app, session in + // Client A: direct, no proxy + // (the spec doesn't fix client A's protocol; JSON to match the proxy tier) + let optionsA = objectsClientOptions(key: app.defaultKey, useBinaryProtocol: false) + try await withRealtimeClient(optionsA) { clientA in + clientA.connect() + guard await awaitState(clientA, .connected, timeout: 15) else { return } + + let channelA = objectsChannel(clientA, channelName) + let rootA = try await channelA.object.get() + + // Set up initial data + try await rootA.set(key: "existing", value: "before") + + // Client B: through proxy with delayed OBJECT_SYNC + let optionsB = objectsProxyClientOptions(for: app, through: session) + try await withRealtimeClient(optionsB) { clientB in + let channelB = objectsChannel(clientB, channelName) + + // Test Steps + // Start client B — will be stuck in SYNCING due to delayed OBJECT_SYNC + clientB.connect() + guard await awaitState(clientB, .connected, timeout: 15) else { return } + channelB.attach() + + // While B is syncing, A publishes a mutation + try await rootA.set(key: "existing", value: "after") + + // B's get() will resolve once delayed sync completes + // (spec: WITH timeout: 30 seconds — `await` takes no per-call timeout) + let rootB = try await channelB.object.get() + + // The mutation from A should be visible (either in sync data or buffered OBJECT) + guard await pollUntil("rootB.existing == \"after\"", timeout: 15, { + stringValue(at: rootB.get(key: "existing")) == "after" + }) else { return } + + // Assertions + #expect(stringValue(at: rootB.get(key: "existing")) == "after") + + // Teardown (spec: client_a.close() / client_b.close() / session.close()) — + // handled by the withRealtimeClient / withProxySession scopes. + } + } + } + } +} + +extension ObjectsFaultsTests { + /// Proxy client options (token auth, JSON, localhost proxy — the base class's + /// `proxyClientOptions`) with the LiveObjects plugin installed (accessing `channel.object` + /// without it is a programmer error) and the spec's `autoConnect: false`. + /// + /// The spec's proxied clients use `key: api_key` directly, but the proxy serves plain ws + /// (`tls: false`) and basic auth is TLS-only (RSA1), so `proxyClientOptions` substitutes a + /// locally-signed TokenRequest — the standard proxy-tier adaptation. + func objectsProxyClientOptions(for app: SandboxApp, through session: ProxySession) -> ARTClientOptions { + let options = proxyClientOptions(for: app, through: session) + options.plugins = [.liveObjects: AblyLiveObjects.Plugin.self] + options.autoConnect = false + return options + } +} + +#endif diff --git a/Test/UTS/integration/standard/objects/ObjectsGcTests.swift b/Test/UTS/integration/standard/objects/ObjectsGcTests.swift new file mode 100644 index 000000000..aec2aa522 --- /dev/null +++ b/Test/UTS/integration/standard/objects/ObjectsGcTests.swift @@ -0,0 +1,121 @@ +import Testing +import Foundation +import Ably +import AblyLiveObjects + +/// Objects GC — tombstone semantics (RTO10, RTLM19, RTLM5d2h, RTLM7) +/// Derived from ably/specification `uts/objects/integration/objects_gc_test.md` +/// +/// Direct-sandbox integration test against the Ably Sandbox (`sandbox.realtime.ably-nonprod.net`, +/// via SandboxApp.sandboxHost) — no proxy, no fault injection. Provisions a throwaway SandboxApp +/// and connects real clients straight to the sandbox. +/// +/// Behavioral verification of tombstone semantics end-to-end against the real server: removing a +/// map entry tombstones it (RTLM7), tombstoned entries read back as undefined/null (RTLM5d2h), and +/// the same key is recreatable — the server assigns a fresh objectId to the replacement object, +/// which is safe because tombstoned state is retained for the GC grace period (RTO10). +/// +/// The timer-based GC sweep itself (RTO10a–RTO10c, RTLM19a) is verified at the **unit tier** +/// (`objects/unit/realtime_object.md`) where the clock is controllable; it is intentionally not +/// exercised here (the sweep cadence plus the default 24h grace period is not observable within +/// test timeouts, and integration tests run on wall-clock time — no `ADVANCE_TIME` in this file). +/// +/// The spec's `value() == null` assertions denote its undefined/null absent value (RTLM5d2h) — +/// asserted here as the typed-view read returning `nil`. +@Suite(.serialized) +final class ObjectsGcTests: IntegrationTestCase { + + // UTS: objects/integration/RTO10/tombstoned-object-gc-recreate-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RTO10_tombstoned_object_is_recreatable_with_new_objectId(useBinaryProtocol: Bool) async throws { + // The spec's BEFORE/AFTER ALL sandbox app provisioning is owned by the withSandboxApp scope. + try await withSandboxApp { app in + // Setup + let channelName = "objects-gc-object-\(UUID().uuidString)" + + let options = objectsClientOptions(key: app.defaultKey, useBinaryProtocol: useBinaryProtocol) + try await withRealtimeClient(options) { client in + client.connect() + guard await awaitState(client, .connected, timeout: 15) else { return } + + let channel = objectsChannel(client, channelName) + // The spec's `WITH timeout: 15 seconds` on get() has no per-call Swift equivalent — + // get() suspends until the sync completes; a hang surfaces as the test-runner timeout. + let root = try await channel.object.get() + + // Test Steps + // Create a counter + try await root.set(key: "counter", value: .liveCounter(.create(initialCount: 42))) + guard await pollUntil("root.counter == 42", timeout: 10, { + counterValue(at: root.get(key: "counter")) == 42 + }) else { return } + + let counterId = try counterInstanceId(at: root.get(key: "counter")) + + // Remove it (tombstones the entry and the object, RTLM7) + try await root.remove(key: "counter") + + // RTLM5d2h: tombstoned entries read back as undefined/null + guard await pollUntil("root.counter reads back as nil", timeout: 10, { + counterValue(at: root.get(key: "counter")) == nil + }) else { return } + + // Create a new counter at the same key + try await root.set(key: "counter", value: .liveCounter(.create(initialCount: 99))) + guard await pollUntil("root.counter == 99", timeout: 10, { + counterValue(at: root.get(key: "counter")) == 99 + }) else { return } + + // Assertions + #expect(counterValue(at: root.get(key: "counter")) == 99) + #expect(try counterInstanceId(at: root.get(key: "counter")) != counterId) + + // Teardown (spec: client.close()) — handled by the withRealtimeClient scope. + } + } + } + + // UTS: objects/integration/RTLM19/tombstoned-entry-gc-reset-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RTLM19_tombstoned_map_entry_is_re_settable(useBinaryProtocol: Bool) async throws { + // The spec's BEFORE/AFTER ALL sandbox app provisioning is owned by the withSandboxApp scope. + try await withSandboxApp { app in + // Setup + let channelName = "objects-gc-entry-\(UUID().uuidString)" + + let options = objectsClientOptions(key: app.defaultKey, useBinaryProtocol: useBinaryProtocol) + try await withRealtimeClient(options) { client in + client.connect() + guard await awaitState(client, .connected, timeout: 15) else { return } + + let channel = objectsChannel(client, channelName) + let root = try await channel.object.get() + + // Test Steps + // Set then remove a key + try await root.set(key: "ephemeral", value: "temporary") + guard await pollUntil("root.ephemeral == \"temporary\"", timeout: 10, { + stringValue(at: root.get(key: "ephemeral")) == "temporary" + }) else { return } + + try await root.remove(key: "ephemeral") + + // RTLM5d2h: tombstoned entries read back as undefined/null + guard await pollUntil("root.ephemeral reads back as nil", timeout: 10, { + stringValue(at: root.get(key: "ephemeral")) == nil + }) else { return } + + // Set the same key again + try await root.set(key: "ephemeral", value: "revived") + guard await pollUntil("root.ephemeral == \"revived\"", timeout: 10, { + stringValue(at: root.get(key: "ephemeral")) == "revived" + }) else { return } + + // Assertions + #expect(stringValue(at: root.get(key: "ephemeral")) == "revived") + + // Teardown (spec: client.close()) — handled by the withRealtimeClient scope. + } + } + } +} diff --git a/Test/UTS/integration/standard/objects/ObjectsLifecycleTests.swift b/Test/UTS/integration/standard/objects/ObjectsLifecycleTests.swift new file mode 100644 index 000000000..c3f800dbb --- /dev/null +++ b/Test/UTS/integration/standard/objects/ObjectsLifecycleTests.swift @@ -0,0 +1,251 @@ +import Testing +import Foundation +import Ably +import AblyLiveObjects + +/// Objects lifecycle (RTO23, RTPO15, RTPO17) +/// Derived from ably/specification `uts/objects/integration/objects_lifecycle_test.md` +/// +/// Direct-sandbox integration test against the Ably Sandbox (`sandbox.realtime.ably-nonprod.net`, +/// via SandboxApp.sandboxHost) — no proxy, no fault injection. Provisions a throwaway SandboxApp +/// and connects real clients straight to the sandbox. +/// +/// End-to-end lifecycle: connect, sync, create objects via PathObject, mutate, and verify +/// propagation to a second client. Complements unit tests by verifying real server sync, mutation +/// delivery, and object creation. +@Suite(.serialized) +final class ObjectsLifecycleTests: IntegrationTestCase { + + // UTS: objects/integration/RTO23-RTPO15/set-primitive-propagates-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RTO23_RTPO15_set_primitive_via_PathObject_second_client_reads_it(useBinaryProtocol: Bool) async throws { + // The spec's BEFORE/AFTER ALL sandbox app provisioning is owned by the withSandboxApp scope. + try await withSandboxApp { app in + // Setup + let channelName = "objects-lifecycle-\(UUID().uuidString)" + + let optionsA = objectsClientOptions(key: app.defaultKey, useBinaryProtocol: useBinaryProtocol) + let optionsB = objectsClientOptions(key: app.defaultKey, useBinaryProtocol: useBinaryProtocol) + + try await withRealtimeClient(optionsA) { clientA in + try await withRealtimeClient(optionsB) { clientB in + clientA.connect() + guard await awaitState(clientA, .connected, timeout: 15) else { return } + + clientB.connect() + guard await awaitState(clientB, .connected, timeout: 15) else { return } + + let channelA = objectsChannel(clientA, channelName) + let channelB = objectsChannel(clientB, channelName) + + let rootA = try await channelA.object.get() + let rootB = try await channelB.object.get() + + // Test Steps + // Client A sets a value + try await rootA.set(key: "greeting", value: "hello") + + // Client B subscribes and waits for the update + let eventsB = Captured() + try rootB.subscribe { event in eventsB.append(event) } + guard await pollUntil("rootB.greeting == \"hello\"", timeout: 10, { + stringValue(at: rootB.get(key: "greeting")) == "hello" + }) else { return } + + // Assertions + #expect(stringValue(at: rootB.get(key: "greeting")) == "hello") + + // Teardown (spec: client_a.close() / client_b.close()) — handled by the + // withRealtimeClient scopes. + } + } + } + } + + // UTS: objects/integration/RTPO15/set-counter-value-type-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RTPO15_set_with_LiveCounter_second_client_reads_counter(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let channelName = "objects-counter-create-\(UUID().uuidString)" + + let optionsA = objectsClientOptions(key: app.defaultKey, useBinaryProtocol: useBinaryProtocol) + let optionsB = objectsClientOptions(key: app.defaultKey, useBinaryProtocol: useBinaryProtocol) + + try await withRealtimeClient(optionsA) { clientA in + try await withRealtimeClient(optionsB) { clientB in + clientA.connect() + guard await awaitState(clientA, .connected, timeout: 15) else { return } + + clientB.connect() + guard await awaitState(clientB, .connected, timeout: 15) else { return } + + let channelA = objectsChannel(clientA, channelName) + let channelB = objectsChannel(clientB, channelName) + + let rootA = try await channelA.object.get() + let rootB = try await channelB.object.get() + + // Test Steps + try await rootA.set(key: "my_counter", value: .liveCounter(.create(initialCount: 42))) + guard await pollUntil("rootB.my_counter == 42", timeout: 10, { + counterValue(at: rootB.get(key: "my_counter")) == 42 + }) else { return } + + // Assertions + #expect(counterValue(at: rootB.get(key: "my_counter")) == 42) + #expect(try rootB.get(key: "my_counter").instance() != nil) + + // Teardown — handled by the withRealtimeClient scopes. + } + } + } + } + + // UTS: objects/integration/RTPO17/increment-propagates-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RTPO17_increment_counter_second_client_sees_updated_value(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let channelName = "objects-increment-\(UUID().uuidString)" + + let optionsA = objectsClientOptions(key: app.defaultKey, useBinaryProtocol: useBinaryProtocol) + let optionsB = objectsClientOptions(key: app.defaultKey, useBinaryProtocol: useBinaryProtocol) + + try await withRealtimeClient(optionsA) { clientA in + try await withRealtimeClient(optionsB) { clientB in + clientA.connect() + guard await awaitState(clientA, .connected, timeout: 15) else { return } + + clientB.connect() + guard await awaitState(clientB, .connected, timeout: 15) else { return } + + let channelA = objectsChannel(clientA, channelName) + let channelB = objectsChannel(clientB, channelName) + + let rootA = try await channelA.object.get() + let rootB = try await channelB.object.get() + + // Test Steps + // Create a counter first + try await rootA.set(key: "hits", value: .liveCounter(.create(initialCount: 0))) + guard await pollUntil("rootB.hits == 0", timeout: 10, { + counterValue(at: rootB.get(key: "hits")) == 0 + }) else { return } + + // Increment it + try await rootA.get(key: "hits").asLiveCounter().increment(amount: 10) + guard await pollUntil("rootB.hits == 10", timeout: 10, { + counterValue(at: rootB.get(key: "hits")) == 10 + }) else { return } + + // Assertions + #expect(counterValue(at: rootA.get(key: "hits")) == 10) + #expect(counterValue(at: rootB.get(key: "hits")) == 10) + + // Teardown — handled by the withRealtimeClient scopes. + } + } + } + } + + // UTS: objects/integration/RTPO15/set-map-value-type-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RTPO15_set_with_LiveMap_second_client_reads_nested_map(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let channelName = "objects-map-create-\(UUID().uuidString)" + + let optionsA = objectsClientOptions(key: app.defaultKey, useBinaryProtocol: useBinaryProtocol) + let optionsB = objectsClientOptions(key: app.defaultKey, useBinaryProtocol: useBinaryProtocol) + + try await withRealtimeClient(optionsA) { clientA in + try await withRealtimeClient(optionsB) { clientB in + clientA.connect() + guard await awaitState(clientA, .connected, timeout: 15) else { return } + + clientB.connect() + guard await awaitState(clientB, .connected, timeout: 15) else { return } + + let channelA = objectsChannel(clientA, channelName) + let channelB = objectsChannel(clientB, channelName) + + let rootA = try await channelA.object.get() + let rootB = try await channelB.object.get() + + // Test Steps + try await rootA.set(key: "settings", value: .liveMap(.create(entries: [ + "theme": "dark", + "fontSize": 14, + ]))) + guard await pollUntil("rootB.settings.theme == \"dark\"", timeout: 10, { + stringValue(at: rootB.get(key: "settings").asLiveMap().get(key: "theme")) == "dark" + }) else { return } + + // Assertions + #expect(stringValue(at: rootB.get(key: "settings").asLiveMap().get(key: "theme")) == "dark") + #expect(numberValue(at: rootB.get(key: "settings").asLiveMap().get(key: "fontSize")) == 14) + + // Teardown — handled by the withRealtimeClient scopes. + } + } + } + } + + // UTS: objects/integration/RTO23/get-returns-path-object-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RTO23_get_waits_for_sync_and_returns_PathObject(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let channelName = "objects-get-root-\(UUID().uuidString)" + + let options = objectsClientOptions(key: app.defaultKey, useBinaryProtocol: useBinaryProtocol) + try await withRealtimeClient(options) { client in + client.connect() + guard await awaitState(client, .connected, timeout: 15) else { return } + + let channel = objectsChannel(client, channelName) + + // Test Steps + let root = try await channel.object.get() + + // Assertions + // ASSERT root IS PathObject + // (no runtime assertion: satisfied by get()'s return type, `any LiveMapPathObject`) + #expect(root.path == "") + #expect(try root.size() == 0) + + // Teardown (spec: client.close()) — handled by the withRealtimeClient scope. + } + } + } + + // UTS: objects/integration/RTPO15/rest-provisioned-data-sync-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RTPO15_client_syncs_pre_existing_data_provisioned_via_REST(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let channelName = "objects-rest-provision-\(UUID().uuidString)" + + // Provision data via REST before any realtime client connects + try await provisionObjectsViaRest(apiKey: app.defaultKey, channelName: channelName, operations: [ + mapSetOp(key: "provisioned", value: valueString("from_rest"), objectId: "root"), + ]) + + // Test Steps + let options = objectsClientOptions(key: app.defaultKey, useBinaryProtocol: useBinaryProtocol) + try await withRealtimeClient(options) { client in + client.connect() + guard await awaitState(client, .connected, timeout: 15) else { return } + + let channel = objectsChannel(client, channelName) + let root = try await channel.object.get() + + // Assertions + #expect(stringValue(at: root.get(key: "provisioned")) == "from_rest") + + // Teardown (spec: client.close()) — handled by the withRealtimeClient scope. + } + } + } +} diff --git a/Test/UTS/integration/standard/objects/ObjectsSyncTests.swift b/Test/UTS/integration/standard/objects/ObjectsSyncTests.swift new file mode 100644 index 000000000..1b3dd6d59 --- /dev/null +++ b/Test/UTS/integration/standard/objects/ObjectsSyncTests.swift @@ -0,0 +1,184 @@ +import Testing +import Foundation +import Ably +import AblyLiveObjects + +/// Objects sync (RTO4, RTO5, RTO17) +/// Derived from ably/specification `uts/objects/integration/objects_sync_test.md` +/// +/// Direct-sandbox integration test against the Ably Sandbox (`sandbox.realtime.ably-nonprod.net`, +/// via SandboxApp.sandboxHost) — no proxy, no fault injection. Provisions a throwaway SandboxApp +/// and connects real clients straight to the sandbox. +/// +/// Verifies the sync sequence against the real server: attach with HAS_OBJECTS, receive +/// OBJECT_SYNC, reach SYNCED state. Also tests re-attach behaviour where the client detaches and +/// re-attaches to verify the pool is re-synced. +@Suite(.serialized) +final class ObjectsSyncTests: IntegrationTestCase { + + // UTS: objects/integration/RTO4-RTO5/attach-sync-get-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RTO4_RTO5_attach_triggers_sync_get_resolves_after_SYNCED(useBinaryProtocol: Bool) async throws { + // The spec's BEFORE/AFTER ALL sandbox app provisioning is owned by the withSandboxApp scope. + try await withSandboxApp { app in + // Setup + let channelName = "objects-sync-\(UUID().uuidString)" + + let options = objectsClientOptions(key: app.defaultKey, useBinaryProtocol: useBinaryProtocol) + try await withRealtimeClient(options) { client in + client.connect() + guard await awaitState(client, .connected, timeout: 15) else { return } + + let channel = objectsChannel(client, channelName) + + // Test Steps + let root = try await channel.object.get() + + // Assertions + // ASSERT root IS PathObject + // (no runtime assertion: satisfied by get()'s return type, `any LiveMapPathObject`) + #expect(root.path == "") + + // Teardown (spec: client.close()) — handled by the withRealtimeClient scope. + } + } + } + + // UTS: objects/integration/RTO5-RTO17/two-clients-sync-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RTO5_RTO17_two_clients_sync_same_channel_with_pre_existing_data(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let channelName = "objects-two-sync-\(UUID().uuidString)" + + let optionsA = objectsClientOptions(key: app.defaultKey, useBinaryProtocol: useBinaryProtocol) + let optionsB = objectsClientOptions(key: app.defaultKey, useBinaryProtocol: useBinaryProtocol) + + try await withRealtimeClient(optionsA) { clientA in + try await withRealtimeClient(optionsB) { clientB in + clientA.connect() + guard await awaitState(clientA, .connected, timeout: 15) else { return } + + clientB.connect() + guard await awaitState(clientB, .connected, timeout: 15) else { return } + + let channelA = objectsChannel(clientA, channelName) + let channelB = objectsChannel(clientB, channelName) + + // Test Steps + // Client A creates data + let rootA = try await channelA.object.get() + try await rootA.set(key: "key1", value: "value1") + + // Client B attaches and syncs — should see the data + let rootB = try await channelB.object.get() + guard await pollUntil("rootB.key1 == \"value1\"", timeout: 10, { + stringValue(at: rootB.get(key: "key1")) == "value1" + }) else { return } + + // Assertions + #expect(stringValue(at: rootB.get(key: "key1")) == "value1") + + // Teardown (spec: client_a.close() / client_b.close()) — handled by the + // withRealtimeClient scopes. + } + } + } + } + + // UTS: objects/integration/RTO17/reattach-resyncs-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RTO17_reattach_resyncs_object_pool(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let channelName = "objects-reattach-\(UUID().uuidString)" + + let options = objectsClientOptions(key: app.defaultKey, useBinaryProtocol: useBinaryProtocol) + try await withRealtimeClient(options) { client in + client.connect() + guard await awaitState(client, .connected, timeout: 15) else { return } + + let channel = objectsChannel(client, channelName) + var root = try await channel.object.get() + + // Test Steps + // Set some data + try await root.set(key: "before_detach", value: "hello") + #expect(stringValue(at: root.get(key: "before_detach")) == "hello") + + // Detach and re-attach + await awaitDetach(channel) + await awaitAttach(channel) + + // Re-sync should restore data + root = try await channel.object.get() + let reSyncedRoot = root + guard await pollUntil("root.before_detach == \"hello\" after re-attach", timeout: 10, { + stringValue(at: reSyncedRoot.get(key: "before_detach")) == "hello" + }) else { return } + + // Assertions + #expect(stringValue(at: root.get(key: "before_detach")) == "hello") + + // Teardown (spec: client.close()) — handled by the withRealtimeClient scope. + } + } + } + + // UTS: objects/integration/RTO4/attach-subscribe-only-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RTO4_attach_without_OBJECT_PUBLISH_still_resolves_get_with_empty_pool(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let channelName = "objects-subscribe-only-\(UUID().uuidString)" + + let options = objectsClientOptions(key: app.defaultKey, useBinaryProtocol: useBinaryProtocol) + try await withRealtimeClient(options) { client in + client.connect() + guard await awaitState(client, .connected, timeout: 15) else { return } + + let channel = objectsChannel(client, channelName, modes: [.objectSubscribe]) + + // Test Steps + let root = try await channel.object.get() + + // Assertions + // ASSERT root IS PathObject + // (no runtime assertion: satisfied by get()'s return type, `any LiveMapPathObject`) + #expect(try root.size() == 0) + + // Teardown (spec: client.close()) — handled by the withRealtimeClient scope. + } + } + } +} + +extension ObjectsSyncTests { + /// Awaits the channel detach acknowledgement (the spec's `AWAIT channel.detach()`), recording + /// an issue on error. + private func awaitDetach(_ channel: ARTRealtimeChannel, + sourceLocation: SourceLocation = #_sourceLocation) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + channel.detach { error in + if let error { + Issue.record("detach() failed: \(error)", sourceLocation: sourceLocation) + } + continuation.resume() + } + } + } + + /// Awaits the channel attach acknowledgement (the spec's `AWAIT channel.attach()`), recording + /// an issue on error. + private func awaitAttach(_ channel: ARTRealtimeChannel, + sourceLocation: SourceLocation = #_sourceLocation) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + channel.attach { error in + if let error { + Issue.record("attach() failed: \(error)", sourceLocation: sourceLocation) + } + continuation.resume() + } + } + } +} diff --git a/Test/UTS/integration/standard/objects/helpers/ObjectsIntegrationHelpers.swift b/Test/UTS/integration/standard/objects/helpers/ObjectsIntegrationHelpers.swift new file mode 100644 index 000000000..3d537e8bb --- /dev/null +++ b/Test/UTS/integration/standard/objects/helpers/ObjectsIntegrationHelpers.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing +import Ably +import AblyLiveObjects + +/// Shared wiring and read helpers for the `objects` direct-sandbox integration suites +/// (`integration/standard/objects/`) — the cocoa counterpart of the client/channel builders in +/// ably-java's `integration/standard/liveobjects/ObjectsLifecycleTest.kt` et al. + +/// Client options for a realtime client wired straight to the nonprod sandbox (no proxy), with the +/// LiveObjects plugin installed (accessing `channel.object` without it is a programmer error). +func objectsClientOptions(key: String, useBinaryProtocol: Bool) -> ARTClientOptions { + let options = ARTClientOptions(key: key) + options.realtimeHost = SandboxApp.sandboxHost + options.restHost = SandboxApp.sandboxHost + options.useBinaryProtocol = useBinaryProtocol + options.autoConnect = false + options.plugins = [.liveObjects: AblyLiveObjects.Plugin.self] + return options +} + +/// A channel with the object modes (defaults to OBJECT_SUBSCRIBE + OBJECT_PUBLISH). +func objectsChannel(_ client: ARTRealtime, + _ name: String, + modes: ARTChannelMode = [.objectSubscribe, .objectPublish]) -> ARTRealtimeChannel { + let options = ARTRealtimeChannelOptions() + options.modes = modes + return client.channels.get(name, options: options) +} + +// MARK: - Dynamic `value()` reads (RTPO7) + +// The spec's dynamic `pathObj.value()` returns "the resolved counter value or any primitive"; +// ably-cocoa's typed views split that read per expected type (`objects-mapping.md` §4). These +// helpers perform the read against the expected type, returning `nil` when the path doesn't resolve +// or resolves to a different type — which is also how the spec's `value() == null` (the tombstoned / +// absent case, RTLM5d2h) is asserted. A thrown RTO25 precondition error also reads as `nil` here; +// these suites only read on ATTACHED channels, where that error cannot arise. + +/// The spec's `pathObj.value()` against an expected **counter**. +func counterValue(at node: any PathObject) -> Double? { + guard let value = try? node.asLiveCounter().value() else { return nil } + return value +} + +/// The spec's `pathObj.value()` against an expected **string** primitive. +func stringValue(at node: any PathObject) -> String? { + guard let primitive = try? node.asPrimitive().value() else { return nil } + return primitive.stringValue +} + +/// The spec's `pathObj.value()` against an expected **number** primitive. +func numberValue(at node: any PathObject) -> Double? { + guard let primitive = try? node.asPrimitive().value() else { return nil } + return primitive.numberValue +} + +/// The spec's `pathObj.instance().id` against an expected **counter** — unwraps the `Instance` enum +/// (`objects-mapping.md` §5), stopping the test if nothing resolves or the instance isn't a counter. +func counterInstanceId(at node: any PathObject, + sourceLocation: SourceLocation = #_sourceLocation) throws -> String { + let instance = try #require(try node.instance(), + "expected an instance at path '\(node.path)'", + sourceLocation: sourceLocation) + guard case let .liveCounter(counter) = instance else { + Issue.record("expected a liveCounter instance at path '\(node.path)', got \(instance.type)", + sourceLocation: sourceLocation) + throw HTTPError("expected a liveCounter instance at path '\(node.path)'") + } + return counter.id +} diff --git a/Test/UTS/integration/standard/objects/helpers/ObjectsRestProvisioning.swift b/Test/UTS/integration/standard/objects/helpers/ObjectsRestProvisioning.swift new file mode 100644 index 000000000..80844caa2 --- /dev/null +++ b/Test/UTS/integration/standard/objects/helpers/ObjectsRestProvisioning.swift @@ -0,0 +1,128 @@ +import Foundation + +/// REST fixture provisioning for the `objects` integration specs — the Swift implementation of the +/// spec's `provision_objects_via_rest` helper (`uts/objects/helpers/standard_test_pool.md`, +/// "REST Fixture Provisioning"). Mirrors ably-java's +/// `uts/.../integration/standard/liveobjects/Helpers.kt`, camelCased. +/// +/// The objects REST API uses the **V2 format** (the LiveObjects OpenAPI specification is the source +/// of truth): `POST /channels/{channel}/object` (**singular**), body is a single operation object +/// **or** a bare JSON array of operations — there is **no** `{ "messages": [...] }` envelope. Each +/// operation names its type via a payload key (`mapSet` / `mapRemove` / `mapCreate` / `counterInc` +/// / `counterCreate`) and targets an object by `objectId` **or** `path`; an optional `id` on any +/// operation is an idempotency key. (The spec's legacy `POST …/objects` + `messages` shape was +/// aligned to V2 in ably/specification#497.) +/// +/// Unlike ably-java (which drives an `AblyRest` client that must be closed after use), this port +/// posts through the integration infra's plain `URLSession` helpers — nothing to close. + +private let objectsProvisioningSession = makeURLSession(requestTimeout: 30) + +/// Publishes `operations` to the channel over REST, before any realtime client connects. +/// A single operation is sent as a bare object; several are sent as a JSON array (batch). +/// Returns the server-assigned `objectIds` from the response (empty when the response carries none). +@discardableResult +func provisionObjectsViaRest(apiKey: String, + channelName: String, + operations: [[String: Any]]) async throws -> [String] { + precondition(!operations.isEmpty, "operations must not be empty") + + let encodedChannelName = channelName.addingPercentEncoding( + withAllowedCharacters: CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/")) + ) ?? channelName + let url = URL(string: "https://\(SandboxApp.sandboxHost)/channels/\(encodedChannelName)/object")! + + // operations: a single operation object, or an array of operation objects (batch) + let body: Any = operations.count == 1 ? operations[0] : operations + var request = try jsonRequest("POST", url, body: body) + let basic = Data(apiKey.utf8).base64EncodedString() + request.setValue("Basic \(basic)", forHTTPHeaderField: "Authorization") + + let (data, status) = try await httpRequest(request, session: objectsProvisioningSession) + guard (200..<300).contains(status) else { + throw HTTPError("POST /channels/\(channelName)/object returned \(status): \(String(decoding: data, as: UTF8.self))") + } + + // The response is a single result object or an array of them, each carrying `objectIds` + // (ably-java flattens `response.items()` the same way). + let responseBody = try JSONSerialization.jsonObject(with: data) + let items: [[String: Any]] + if let object = responseBody as? [String: Any] { + items = [object] + } else if let array = responseBody as? [[String: Any]] { + items = array + } else { + items = [] + } + return items.flatMap { item in item["objectIds"] as? [String] ?? [] } +} + +// MARK: - Operation builders (V2 operation shapes) + +/// `{ mapSet: { key, value }, objectId|path, id? }` — set `key` to `value` on the target map. +func mapSetOp(key: String, value: [String: Any], objectId: String? = nil, path: String? = nil, id: String? = nil) -> [String: Any] { + withTarget(["mapSet": ["key": key, "value": value]], objectId: objectId, path: path, id: id) +} + +/// `{ mapRemove: { key }, objectId|path, id? }` — remove `key` from the target map. +func mapRemoveOp(key: String, objectId: String? = nil, path: String? = nil, id: String? = nil) -> [String: Any] { + withTarget(["mapRemove": ["key": key]], objectId: objectId, path: path, id: id) +} + +/// `{ mapCreate: { semantics, entries }, objectId|path?, id? }` — create a map (semantics 0 = LWW). +/// Entry values are wrapped as `{ data: }` per the V2 schema. A create with no target makes +/// a standalone object. +func mapCreateOp(entries: [String: [String: Any]], semantics: Int = 0, objectId: String? = nil, path: String? = nil, id: String? = nil) -> [String: Any] { + let wrappedEntries = entries.mapValues { value in ["data": value] } + return withTarget(["mapCreate": ["semantics": semantics, "entries": wrappedEntries]], objectId: objectId, path: path, id: id) +} + +/// `{ counterCreate: { count }, objectId|path?, id? }` — create a counter. +func counterCreateOp(count: Double, objectId: String? = nil, path: String? = nil, id: String? = nil) -> [String: Any] { + withTarget(["counterCreate": ["count": count]], objectId: objectId, path: path, id: id) +} + +/// `{ counterInc: { number }, objectId|path, id? }` — increment the target counter +/// (a negative number decrements). +func counterIncOp(number: Double, objectId: String? = nil, path: String? = nil, id: String? = nil) -> [String: Any] { + withTarget(["counterInc": ["number": number]], objectId: objectId, path: path, id: id) +} + +private func withTarget(_ operation: [String: Any], objectId: String?, path: String?, id: String?) -> [String: Any] { + var operation = operation + if let objectId { operation["objectId"] = objectId } + if let path { operation["path"] = path } + if let id { operation["id"] = id } + return operation +} + +// MARK: - Primitive value builders (V2 `PrimitiveValue` shapes) + +/// `{ string: value, encoding? }` +func valueString(_ value: String, encoding: String? = nil) -> [String: Any] { + var result: [String: Any] = ["string": value] + if let encoding { result["encoding"] = encoding } + return result +} + +/// `{ number: value }` +func valueNumber(_ value: Double) -> [String: Any] { + ["number": value] +} + +/// `{ boolean: value }` +func valueBoolean(_ value: Bool) -> [String: Any] { + ["boolean": value] +} + +/// `{ bytes: base64, encoding? }` +func valueBytes(_ base64: String, encoding: String? = nil) -> [String: Any] { + var result: [String: Any] = ["bytes": base64] + if let encoding { result["encoding"] = encoding } + return result +} + +/// `{ objectId: objectId }` — a reference to an existing object. +func valueObjectId(_ objectId: String) -> [String: Any] { + ["objectId": objectId] +} diff --git a/Test/UTS/integration/standard/rest/HistoryTests.swift b/Test/UTS/integration/standard/rest/HistoryTests.swift new file mode 100644 index 000000000..3ddff46f1 --- /dev/null +++ b/Test/UTS/integration/standard/rest/HistoryTests.swift @@ -0,0 +1,292 @@ +import Testing +import Foundation +import Ably + +/// REST channel history (RSL2a, RSL2b, RSL2b1, RSL2b2, RSL2b3) +/// Derived from ably/specification `uts/rest/integration/history.md` +/// +/// Direct-sandbox integration test against the Ably Sandbox (`sandbox.realtime.ably-nonprod.net`, +/// via SandboxApp.sandboxHost) — no proxy, no fault injection. Provisions a throwaway SandboxApp +/// and points real REST clients straight at the sandbox. Needs outbound network: +/// +/// ```bash +/// swift test --filter UTS.HistoryTests +/// ``` +@Suite(.serialized) +final class HistoryTests: IntegrationTestCase { + + // UTS: rest/integration/RSL2a/history-returns-messages-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSL2a_history_returns_published_messages(useBinaryProtocol: Bool) async throws { + // The spec's BEFORE/AFTER ALL sandbox app provisioning is owned by the withSandboxApp scope. + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + let channelName = "history-test-RSL2a-\(UUID().uuidString)" + let channel = client.channels.get(channelName) + + // Test Steps + // Publish some messages + await self.awaitPublish(channel, name: "event1", data: "data1") + await self.awaitPublish(channel, name: "event2", data: "data2") + await self.awaitPublish(channel, name: "event3", data: ["key": "value"]) + + // Poll until messages appear in history + guard await pollUntil("channel history contains all 3 messages", timeout: 10, interval: 0.5, { + await self.historyItems(of: channel).count == 3 + }) else { return } + let history = await historyItems(of: channel) + + // Assertions + try #require(history.count == 3) + + // Default order is backwards (newest first) + #expect(history[0].name == "event3") + #expect(history[0].data as? NSDictionary == ["key": "value"]) + + #expect(history[1].name == "event2") + #expect(history[1].data as? String == "data2") + + #expect(history[2].name == "event1") + #expect(history[2].data as? String == "data1") + + // All messages should have timestamps + #expect(history.allSatisfy { $0.timestamp != nil }) + } + } + + // UTS: rest/integration/RSL2b1/history-direction-forwards-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSL2b1_history_direction_forwards(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + let channelName = "history-direction-\(UUID().uuidString)" + let channel = client.channels.get(channelName) + + // Test Steps + // Publish messages - ordering is determined by server timestamp + await self.awaitPublish(channel, name: "first", data: "1") + await self.awaitPublish(channel, name: "second", data: "2") + await self.awaitPublish(channel, name: "third", data: "3") + + // Poll until all messages appear + guard await pollUntil("channel history contains all 3 messages", timeout: 10, interval: 0.5, { + await self.historyItems(of: channel).count == 3 + }) else { return } + + let forwardsQuery = ARTDataQuery() + forwardsQuery.direction = .forwards + let history = await historyItems(of: channel, query: forwardsQuery) + + // Assertions + try #require(history.count == 3) + #expect(history[0].name == "first") + #expect(history[1].name == "second") + #expect(history[2].name == "third") + } + } + + // UTS: rest/integration/RSL2b2/history-limit-parameter-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSL2b2_history_limit_parameter(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + let channelName = "history-limit-\(UUID().uuidString)" + let channel = client.channels.get(channelName) + + // Test Steps + // Publish multiple messages + for i in 1...10 { + await self.awaitPublish(channel, name: "event-\(i)", data: "\(i)") + } + + // Poll until all messages are persisted + guard await pollUntil("channel history contains all 10 messages", timeout: 10, interval: 0.5, { + await self.historyItems(of: channel).count == 10 + }) else { return } + + let limitQuery = ARTDataQuery() + limitQuery.limit = 5 + let history = await historyItems(of: channel, query: limitQuery) + + // Assertions + try #require(history.count == 5) + + // Should get the 5 most recent (backwards direction by default) + #expect(history[0].name == "event-10") + #expect(history[4].name == "event-6") + } + } + + // UTS: rest/integration/RSL2b3/history-time-range-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSL2b3_history_time_range_parameters(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + let channelName = "history-timerange-\(UUID().uuidString)" + let channel = client.channels.get(channelName) + + // Test Steps + // Publish early messages + await self.awaitPublish(channel, name: "early1", data: "e1") + await self.awaitPublish(channel, name: "early2", data: "e2") + + // Small delay to help ensure server assigns distinct timestamps between batches + // (spec: WAIT 2ms — a spec-mandated real wait, not a wait on observable state) + try await Task.sleep(nanoseconds: 2_000_000) + + // Publish late messages + await self.awaitPublish(channel, name: "late1", data: "l1") + await self.awaitPublish(channel, name: "late2", data: "l2") + + // Poll until all messages appear and retrieve with server timestamps + guard await pollUntil("channel history contains all 4 messages", timeout: 10, interval: 0.5, { + await self.historyItems(of: channel).count == 4 + }) else { return } + let allMessages = await historyItems(of: channel) + + // Use server-assigned timestamps to define the time boundary. + // Client-side now() must not be used here — client and server clocks may + // differ, and publishes may complete within the same client-clock millisecond. + let earlyTimestamps = allMessages + .filter { $0.name?.hasPrefix("early") == true } + .compactMap { $0.timestamp } + .map { Int64(($0.timeIntervalSince1970 * 1000).rounded()) } + let lateTimestamps = allMessages + .filter { $0.name?.hasPrefix("late") == true } + .compactMap { $0.timestamp } + .map { Int64(($0.timeIntervalSince1970 * 1000).rounded()) } + + let maxEarlyTs = try #require(earlyTimestamps.max()) + let minLateTs = try #require(lateTimestamps.min()) + let timeBoundary = (maxEarlyTs + minLateTs) / 2 // floor((max_early_ts + min_late_ts) / 2) + + // Query only early messages (up to the boundary) + let earlyQuery = ARTDataQuery() + earlyQuery.start = self.dateFromMilliseconds(maxEarlyTs - 1000) + earlyQuery.end = self.dateFromMilliseconds(timeBoundary) + let earlyHistory = await historyItems(of: channel, query: earlyQuery) + + // Query only late messages (from the boundary onwards) + let lateQuery = ARTDataQuery() + lateQuery.start = self.dateFromMilliseconds(timeBoundary + 1) + lateQuery.end = self.dateFromMilliseconds(minLateTs + 1000) + let lateHistory = await historyItems(of: channel, query: lateQuery) + + // Assertions + #expect(earlyHistory.count >= 1) + #expect(lateHistory.count >= 1) + + // Early messages should contain "early" names + #expect(earlyHistory.contains { $0.name?.hasPrefix("early") == true }) + + // Late messages should contain "late" names + #expect(lateHistory.contains { $0.name?.hasPrefix("late") == true }) + } + } + + // UTS: rest/integration/RSL2/history-empty-channel-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSL2_history_on_channel_with_no_messages(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + // Use a fresh channel with no messages + let channelName = "history-empty-\(UUID().uuidString)" + let channel = client.channels.get(channelName) + + // Test Steps + let history = await historyPage(of: channel) + + // Assertions + // ASSERT history.items IS List + // (no separate assertion: satisfied by the type system — ARTPaginatedResult.items is + // [ARTMessage]) + #expect(history.items.count == 0) + #expect(history.hasNext == false) + #expect(history.isLast == true) + } + } +} + +extension HistoryTests { + /// Awaits the publish acknowledgement (the spec's `AWAIT channel.publish(name:data:)`), + /// recording an issue on error. + private func awaitPublish(_ channel: ARTRestChannel, + name: String, + data: Any, + sourceLocation: SourceLocation = #_sourceLocation) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + channel.publish(name, data: data, callback: { error in + if let error { + Issue.record("publish(\(name)) failed: \(error)", sourceLocation: sourceLocation) + } + continuation.resume() + }) + } + } + + /// Fetches the channel's history — with the given query (the spec's + /// `channel.history(direction:/limit:/start:/end:)`) or the default query when nil — and + /// returns its items, recording an issue on error. + private func historyItems(of channel: ARTRestChannel, + query: ARTDataQuery? = nil, + sourceLocation: SourceLocation = #_sourceLocation) async -> [ARTMessage] { + await withCheckedContinuation { (continuation: CheckedContinuation<[ARTMessage], Never>) in + do { + try channel.history(query, callback: { result, error in + if let error { + Issue.record("history() failed: \(error)", sourceLocation: sourceLocation) + } + continuation.resume(returning: result?.items ?? []) + }) + } catch { + Issue.record("history(query) rejected the query: \(error)", sourceLocation: sourceLocation) + continuation.resume(returning: []) + } + } + } + + /// Fetches the channel's history (default query) and returns the page's items plus its + /// pagination flags (the spec's `history.hasNext()` / `history.isLast()`), recording an issue + /// on error. Extracted to value types — `ARTPaginatedResult` is not Sendable, so it cannot + /// cross the continuation. + private func historyPage(of channel: ARTRestChannel, + sourceLocation: SourceLocation = #_sourceLocation) async + -> (items: [ARTMessage], hasNext: Bool, isLast: Bool) { + await withCheckedContinuation { (continuation: CheckedContinuation<(items: [ARTMessage], hasNext: Bool, isLast: Bool), Never>) in + channel.history { result, error in + if let error { + Issue.record("history() failed: \(error)", sourceLocation: sourceLocation) + } + continuation.resume(returning: (items: result?.items ?? [], + hasNext: result?.hasNext ?? false, + isLast: result?.isLast ?? true)) + } + } + } + + /// Builds a `Date` from a Unix-epoch millisecond timestamp (the spec's ms arithmetic on + /// server-assigned timestamps). + private func dateFromMilliseconds(_ milliseconds: Int64) -> Date { + Date(timeIntervalSince1970: TimeInterval(milliseconds) / 1000) + } +} diff --git a/Test/UTS/integration/standard/rest/PresenceTests.swift b/Test/UTS/integration/standard/rest/PresenceTests.swift new file mode 100644 index 000000000..eec42dec8 --- /dev/null +++ b/Test/UTS/integration/standard/rest/PresenceTests.swift @@ -0,0 +1,665 @@ +import Testing +import Foundation +import Ably + +/// REST presence (RSP1, RSP3, RSP3a, RSP4, RSP4b, RSP5) +/// Derived from ably/specification `uts/rest/integration/presence.md` +/// +/// Direct-sandbox integration test against the Ably Sandbox (`sandbox.realtime.ably-nonprod.net`, +/// via SandboxApp.sandboxHost) — no proxy, no fault injection. Provisions a throwaway SandboxApp +/// (whose `test-app-setup.json` pre-populates presence fixtures on `persisted:presence_fixtures`) +/// and points real clients straight at the sandbox. Needs outbound network: +/// +/// ```bash +/// swift test --filter UTS.PresenceTests +/// ``` +@Suite(.serialized) +final class PresenceTests: IntegrationTestCase { + + // UTS: rest/integration/RSP1/access-presence-from-channel-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSP1_access_presence_from_channel(useBinaryProtocol: Bool) async throws { + // The spec's BEFORE/AFTER ALL sandbox app provisioning is owned by the withSandboxApp scope. + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + + let channel = client.channels.get("persisted:presence_fixtures") + // ASSERT presence IS NOT null + // (satisfied by the type system: ARTRestChannel.presence is a non-optional property) + let presence: ARTRestPresence = channel.presence + // ASSERT presence IS RestPresence + #expect((presence as Any) is ARTRestPresence) + } + } + + // UTS: rest/integration/RSP3/get-presence-members-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSP3_get_presence_members_from_fixture_channel(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + + let channel = client.channels.get("persisted:presence_fixtures") + // ASSERT result IS PaginatedResult + // (satisfied by the type system: the helper returns ARTPaginatedResult) + let result = try #require(await self.presenceGet(channel.presence)) + + #expect(result.items.count >= 5) // At least the non-encrypted fixtures + + // Verify expected clients are present + let clientIds = result.items.compactMap(\.clientId) + #expect(clientIds.contains("client_bool")) + #expect(clientIds.contains("client_string")) + #expect(clientIds.contains("client_json")) + } + } + + // UTS: rest/integration/RSP3/presence-message-fields-1 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSP3_get_returns_PresenceMessage_with_correct_fields(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + + let channel = client.channels.get("persisted:presence_fixtures") + let result = try #require(await self.presenceGet(channel.presence)) + + // Find client_string member + // ASSERT member IS NOT null (the #require covers it) + let member = try #require(result.items.first { $0.clientId == "client_string" }) + + // ASSERT member IS PresenceMessage + // (satisfied by the type system: items is [ARTPresenceMessage]) + #expect(member.action == .present) + #expect(member.clientId == "client_string") + #expect(member.data as? String == "This is a string clientData payload") + // ASSERT member.connectionId IS NOT null + // (satisfied by the type system: ARTBaseMessage.connectionId is non-optional in cocoa) + } + } + + // UTS: rest/integration/RSP3a1/get-with-limit-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSP3a1_get_with_limit_parameter(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + + let channel = client.channels.get("persisted:presence_fixtures") + + // Request with small limit + let query = ARTPresenceQuery() + query.limit = 2 + let result = try #require(await self.presenceGet(channel.presence, query: query)) + + #expect(result.items.count <= 2) + // If more members exist, pagination should be available + if result.hasNext { + #expect(result.items.count == 2) + } + } + } + + // UTS: rest/integration/RSP3a2/get-with-clientid-filter-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSP3a2_get_with_clientId_filter(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + + let channel = client.channels.get("persisted:presence_fixtures") + let query = ARTPresenceQuery() + query.clientId = "client_json" + let result = try #require(await self.presenceGet(channel.presence, query: query)) + + #expect(result.items.count == 1) + #expect(result.items.first?.clientId == "client_json") + // The fixture has no encoding field, so data is returned as a raw string + let data = try #require(result.items.first?.data as? String) + #expect(data == "{ \"test\": \"This is a JSONObject clientData payload\"}") + } + } + + // UTS: rest/integration/RSP3/get-empty-channel-2 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSP3_get_on_channel_with_no_presence(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + + // Use a unique channel name that has no presence members + let channelName = "presence-empty-\(UUID().uuidString)" + let channel = client.channels.get(channelName) + + let result = try #require(await self.presenceGet(channel.presence)) + + // ASSERT result.items IS List + // (satisfied by the type system: items is [ARTPresenceMessage]) + #expect(result.items.count == 0) + #expect(result.hasNext == false) + } + } + + // UTS: rest/integration/RSP4/history-returns-events-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSP4_history_returns_presence_events(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + + let channelName = "presence-history-\(UUID().uuidString)" + + // Use realtime client to generate presence history + let realtimeOptions = ARTClientOptions(key: app.defaultKey) + realtimeOptions.realtimeHost = SandboxApp.sandboxHost + realtimeOptions.restHost = SandboxApp.sandboxHost + realtimeOptions.useBinaryProtocol = useBinaryProtocol + realtimeOptions.clientId = "test-client" + realtimeOptions.autoConnect = false + + try await withRealtimeClient(realtimeOptions) { realtime in + realtime.connect() + guard await awaitState(realtime, .connected, timeout: 10) else { return } + let realtimeChannel = realtime.channels.get(channelName) + await self.awaitPresenceOp("presence.enter") { realtimeChannel.presence.enter("entered", callback: $0) } + await self.awaitPresenceOp("presence.update") { realtimeChannel.presence.update("updated", callback: $0) } + await self.awaitPresenceOp("presence.leave") { realtimeChannel.presence.leave("left", callback: $0) } + } // AWAIT realtime.close() — performed by the withRealtimeClient scope (close + await CLOSED) + + // Poll REST history until events appear + let restChannel = client.channels.get(channelName) + + guard await pollUntil("presence history has at least 3 events", timeout: 10, interval: 0.5, { + await (self.presenceHistory(restChannel.presence)?.items.count ?? 0) >= 3 + }) else { return } + let history = try #require(await self.presenceHistory(restChannel.presence)) + + #expect(history.items.count >= 3) + + // Check for expected actions (order depends on direction) + let actions = history.items.map(\.action) + #expect(actions.contains(.enter)) + #expect(actions.contains(.update)) + #expect(actions.contains(.leave)) + } + } + + // UTS: rest/integration/RSP4b1/history-time-range-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSP4b1_history_with_start_end_time_range(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + options.clientId = "test-client" + let client = ARTRest(options: options) + + let channelName = "presence-history-time-\(UUID().uuidString)" + + // Record time before any presence events + let timeBefore = Date() + + // Generate presence events via realtime + let realtimeOptions = ARTClientOptions(key: app.defaultKey) + realtimeOptions.realtimeHost = SandboxApp.sandboxHost + realtimeOptions.restHost = SandboxApp.sandboxHost + realtimeOptions.useBinaryProtocol = useBinaryProtocol + realtimeOptions.clientId = "time-test-client" + realtimeOptions.autoConnect = false + + try await withRealtimeClient(realtimeOptions) { realtime in + realtime.connect() + guard await awaitState(realtime, .connected, timeout: 10) else { return } + let realtimeChannel = realtime.channels.get(channelName) + await self.awaitPresenceOp("presence.enter") { realtimeChannel.presence.enter("test", callback: $0) } + await self.awaitPresenceOp("presence.leave") { realtimeChannel.presence.leave(nil, callback: $0) } + } // AWAIT realtime.close() — performed by the withRealtimeClient scope (close + await CLOSED) + + let timeAfter = Date() + + // Poll until events appear + let restChannel = client.channels.get(channelName) + guard await pollUntil("presence history has at least 2 events", timeout: 10, interval: 0.5, { + await (self.presenceHistory(restChannel.presence)?.items.count ?? 0) >= 2 + }) else { return } + + // Query with time range + let query = ARTDataQuery() + query.start = timeBefore + query.end = timeAfter + let history = try #require(await self.presenceHistory(restChannel.presence, query: query)) + + #expect(history.items.count >= 2) + } + } + + // UTS: rest/integration/RSP4b2/history-direction-forwards-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSP4b2_history_direction_forwards(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + + let channelName = "presence-direction-\(UUID().uuidString)" + + // Generate ordered presence events + let realtimeOptions = ARTClientOptions(key: app.defaultKey) + realtimeOptions.realtimeHost = SandboxApp.sandboxHost + realtimeOptions.restHost = SandboxApp.sandboxHost + realtimeOptions.useBinaryProtocol = useBinaryProtocol + realtimeOptions.clientId = "direction-client" + realtimeOptions.autoConnect = false + + try await withRealtimeClient(realtimeOptions) { realtime in + realtime.connect() + guard await awaitState(realtime, .connected, timeout: 10) else { return } + let realtimeChannel = realtime.channels.get(channelName) + await self.awaitPresenceOp("presence.enter") { realtimeChannel.presence.enter("first", callback: $0) } + await self.awaitPresenceOp("presence.update") { realtimeChannel.presence.update("second", callback: $0) } + await self.awaitPresenceOp("presence.update") { realtimeChannel.presence.update("third", callback: $0) } + } // AWAIT realtime.close() — performed by the withRealtimeClient scope (close + await CLOSED) + + // Poll until events appear + let restChannel = client.channels.get(channelName) + guard await pollUntil("presence history has at least 3 events", timeout: 10, interval: 0.5, { + await (self.presenceHistory(restChannel.presence)?.items.count ?? 0) >= 3 + }) else { return } + + // Get history forwards (oldest first) + let forwardsQuery = ARTDataQuery() + forwardsQuery.direction = .forwards + let historyForwards = try #require(await self.presenceHistory(restChannel.presence, query: forwardsQuery)) + + #expect(historyForwards.items.count >= 3) + #expect(historyForwards.items.first?.data as? String == "first") + + // Get history backwards (newest first) - default + let backwardsQuery = ARTDataQuery() + backwardsQuery.direction = .backwards + let historyBackwards = try #require(await self.presenceHistory(restChannel.presence, query: backwardsQuery)) + + #expect(historyBackwards.items.first?.data as? String == "third") + } + } + + // UTS: rest/integration/RSP4b3/history-limit-pagination-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSP4b3_history_with_limit_and_pagination(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + + let channelName = "presence-limit-\(UUID().uuidString)" + + // Generate multiple presence events + let realtimeOptions = ARTClientOptions(key: app.defaultKey) + realtimeOptions.realtimeHost = SandboxApp.sandboxHost + realtimeOptions.restHost = SandboxApp.sandboxHost + realtimeOptions.useBinaryProtocol = useBinaryProtocol + realtimeOptions.clientId = "limit-client" + realtimeOptions.autoConnect = false + + try await withRealtimeClient(realtimeOptions) { realtime in + realtime.connect() + guard await awaitState(realtime, .connected, timeout: 10) else { return } + let realtimeChannel = realtime.channels.get(channelName) + for i in 1...5 { + await self.awaitPresenceOp("presence.update") { realtimeChannel.presence.update("update-\(i)", callback: $0) } + } + } // AWAIT realtime.close() — performed by the withRealtimeClient scope (close + await CLOSED) + + // Poll until all events appear + let restChannel = client.channels.get(channelName) + guard await pollUntil("presence history has at least 5 events", timeout: 10, interval: 0.5, { + await (self.presenceHistory(restChannel.presence)?.items.count ?? 0) >= 5 + }) else { return } + + // Request with small limit + let limitQuery = ARTDataQuery() + limitQuery.limit = 2 + let page1 = try #require(await self.presenceHistory(restChannel.presence, query: limitQuery)) + + #expect(page1.items.count == 2) + #expect(page1.hasNext == true) + + // Get next page + // ASSERT page2 IS NOT null (the #require covers it) + let page2 = try #require(await self.nextPage(of: page1)) + + #expect(page2.items.count >= 1) + } + } + + // UTS: rest/integration/RSP5/decode-string-data-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSP5_string_data_decoded_correctly(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + + let channel = client.channels.get("persisted:presence_fixtures") + let query = ARTPresenceQuery() + query.clientId = "client_string" + let result = try #require(await self.presenceGet(channel.presence, query: query)) + + #expect(result.items.count == 1) + // ASSERT result.items[0].data IS String (the #require's cast covers it) + let data = try #require(result.items.first?.data as? String) + #expect(data == "This is a string clientData payload") + } + } + + // UTS: rest/integration/RSP5/decode-json-data-1 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSP5_JSON_data_decoded_to_object(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + + let channel = client.channels.get("persisted:presence_fixtures") + let query = ARTPresenceQuery() + query.clientId = "client_decoded" + let result = try #require(await self.presenceGet(channel.presence, query: query)) + + #expect(result.items.count == 1) + // ASSERT result.items[0].data IS Object/Map (the #require's cast covers it) + let data = try #require(result.items.first?.data as? [String: Any]) + #expect((data["example"] as? [String: Any])?["json"] as? String == "Object") + } + } + + // UTS: rest/integration/RSP5/decode-encrypted-data-2 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSP5_encrypted_data_decoded_with_cipher(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + + let cipherKey = try #require(Data(base64Encoded: "WUP6u0K7MXI5Zeo0VppPwg==")) + + // CipherParams(key:, algorithm: "aes", mode: "cbc", keyLength: 128) — cocoa's + // ARTCipherParams derives keyLength from the key (16 bytes = 128) and defaults the + // mode to CBC, so algorithm + key express the spec's full cipher configuration. + let channelOptions = ARTChannelOptions(cipher: ARTCipherParams(algorithm: "aes", key: cipherKey as NSData)) + let channel = client.channels.get("persisted:presence_fixtures", options: channelOptions) + + let query = ARTPresenceQuery() + query.clientId = "client_encoded" + let result = try #require(await self.presenceGet(channel.presence, query: query)) + + // The encrypted fixture should be decrypted + #expect(result.items.count == 1) + #expect(result.items.first?.data != nil) + // Actual decrypted value depends on fixture content + } + } + + // UTS: rest/integration/RSP5/decode-history-messages-3 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSP5_history_messages_also_decoded(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + + let channelName = "presence-decode-history-\(UUID().uuidString)" + + // Generate presence event with JSON data + let realtimeOptions = ARTClientOptions(key: app.defaultKey) + realtimeOptions.realtimeHost = SandboxApp.sandboxHost + realtimeOptions.restHost = SandboxApp.sandboxHost + realtimeOptions.useBinaryProtocol = useBinaryProtocol + realtimeOptions.clientId = "decode-client" + realtimeOptions.autoConnect = false + + let jsonData: [String: Any] = ["key": "value", "number": 123] + try await withRealtimeClient(realtimeOptions) { realtime in + realtime.connect() + guard await awaitState(realtime, .connected, timeout: 10) else { return } + let realtimeChannel = realtime.channels.get(channelName) + await self.awaitPresenceOp("presence.enter") { realtimeChannel.presence.enter(jsonData, callback: $0) } + } // AWAIT realtime.close() — performed by the withRealtimeClient scope (close + await CLOSED) + + // Poll and retrieve history + let restChannel = client.channels.get(channelName) + guard await pollUntil("presence history has at least 1 event", timeout: 10, interval: 0.5, { + await (self.presenceHistory(restChannel.presence)?.items.count ?? 0) >= 1 + }) else { return } + let history = try #require(await self.presenceHistory(restChannel.presence)) + + // ASSERT history.items[0].data IS Object/Map (the #require's cast covers it) + let data = try #require(history.items.first?.data as? [String: Any]) + #expect(data["key"] as? String == "value") + #expect(data["number"] as? Int == 123) + } + } + + // UTS: rest/integration/RSP3/full-pagination-3 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSP3_full_pagination_through_presence_members(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + + // The fixture channel has multiple members + let channel = client.channels.get("persisted:presence_fixtures") + + // Request with small limit to force pagination + let query = ARTPresenceQuery() + query.limit = 2 + let page1 = try #require(await self.presenceGet(channel.presence, query: query)) + + var allMembers: [ARTPresenceMessage] = [] + allMembers.append(contentsOf: page1.items) + + var currentPage = page1 + while currentPage.hasNext { + guard let next = await nextPage(of: currentPage) else { break } + currentPage = next + allMembers.append(contentsOf: currentPage.items) + } + + // Should have retrieved all fixture members + #expect(allMembers.count >= 5) + + // Verify no duplicates + let clientIds = allMembers.compactMap(\.clientId) + #expect(Set(clientIds).count == clientIds.count) + } + } + + // UTS: rest/integration/RSP3/invalid-credentials-rejected-4 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSP3_invalid_credentials_rejected(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { _ in + // Setup + let options = ARTClientOptions(key: "invalid.key:secret") + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + + // AWAIT client.channels.get("test").presence.get() FAILS WITH error + let error = try #require(await self.presenceGetError(client.channels.get("test").presence)) + #expect(error.statusCode == 401) + #expect(error.code >= 40100 && error.code < 40200) + } + } + + // UTS: rest/integration/RSP3/subscribe-capability-sufficient-5 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSP3_subscribe_capability_sufficient(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + // Use key with limited capabilities (keys[3] has subscribe only) + try #require(app.keys.count > 3, "test-app-setup.json should provision keys[3] (subscribe-only)") + let restrictedKey = app.keys[3] + + let options = ARTClientOptions(key: restrictedKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + + // This should work - subscribe capability is sufficient for presence.get + // ASSERT result IS NOT null (the #require covers it) + _ = try #require(await self.presenceGet(client.channels.get("persisted:presence_fixtures").presence)) + } + } +} + +extension PresenceTests { + /// Awaits `presence.get()` (the spec's `result = AWAIT channel.presence.get()`), returning the + /// paginated result — or nil, after recording an issue, on failure (tests unwrap with + /// `try #require`). + private func presenceGet(_ presence: ARTRestPresence, + sourceLocation: SourceLocation = #_sourceLocation) async -> ARTPaginatedResult? { + await withCheckedContinuation { (continuation: CheckedContinuation?, Never>) in + presence.get { result, error in + if let error { + Issue.record("presence.get() failed: \(error)", sourceLocation: sourceLocation) + } + continuation.resume(returning: result) + } + } + } + + /// Awaits `presence.get(query)` (the spec's `AWAIT channel.presence.get(limit:/clientId:)`), + /// returning the paginated result — or nil, after recording an issue, on failure. + private func presenceGet(_ presence: ARTRestPresence, + query: ARTPresenceQuery, + sourceLocation: SourceLocation = #_sourceLocation) async -> ARTPaginatedResult? { + await withCheckedContinuation { (continuation: CheckedContinuation?, Never>) in + do { + try presence.get(query) { result, error in + if let error { + Issue.record("presence.get(query) failed: \(error)", sourceLocation: sourceLocation) + } + continuation.resume(returning: result) + } + } catch { + Issue.record("presence.get(query) threw: \(error)", sourceLocation: sourceLocation) + continuation.resume(returning: nil) + } + } + } + + /// Awaits `presence.get()` expecting it to fail (the spec's `AWAIT presence.get() FAILS WITH + /// error`), returning the error — or nil, after recording an issue, if it unexpectedly + /// succeeded (tests unwrap with `try #require`). + private func presenceGetError(_ presence: ARTRestPresence, + sourceLocation: SourceLocation = #_sourceLocation) async -> ARTErrorInfo? { + await withCheckedContinuation { (continuation: CheckedContinuation) in + presence.get { _, error in + if error == nil { + Issue.record("presence.get() unexpectedly succeeded", sourceLocation: sourceLocation) + } + continuation.resume(returning: error) + } + } + } + + /// Awaits `presence.history()` (the spec's `result = AWAIT rest_channel.presence.history()`), + /// returning the paginated result — or nil, after recording an issue, on failure. + private func presenceHistory(_ presence: ARTRestPresence, + sourceLocation: SourceLocation = #_sourceLocation) async -> ARTPaginatedResult? { + await withCheckedContinuation { (continuation: CheckedContinuation?, Never>) in + presence.history { result, error in + if let error { + Issue.record("presence.history() failed: \(error)", sourceLocation: sourceLocation) + } + continuation.resume(returning: result) + } + } + } + + /// Awaits `presence.history(query)` (the spec's `AWAIT presence.history(start:/direction:/limit:)`), + /// returning the paginated result — or nil, after recording an issue, on failure. + private func presenceHistory(_ presence: ARTRestPresence, + query: ARTDataQuery, + sourceLocation: SourceLocation = #_sourceLocation) async -> ARTPaginatedResult? { + await withCheckedContinuation { (continuation: CheckedContinuation?, Never>) in + do { + try presence.history(query) { result, error in + if let error { + Issue.record("presence.history(query) failed: \(error)", sourceLocation: sourceLocation) + } + continuation.resume(returning: result) + } + } catch { + Issue.record("presence.history(query) threw: \(error)", sourceLocation: sourceLocation) + continuation.resume(returning: nil) + } + } + } + + /// Awaits `page.next()` (the spec's `page2 = AWAIT page1.next()`), returning the next page — + /// or nil, after recording an issue, on failure. + private func nextPage(of page: ARTPaginatedResult, + sourceLocation: SourceLocation = #_sourceLocation) async -> ARTPaginatedResult? { + await withCheckedContinuation { (continuation: CheckedContinuation?, Never>) in + page.next { result, error in + if let error { + Issue.record("next() failed: \(error)", sourceLocation: sourceLocation) + } + continuation.resume(returning: result) + } + } + } + + /// Awaits a realtime presence operation's acknowledgement (the spec's + /// `AWAIT realtime_channel.presence.enter/update/leave(...)`), recording an issue on error. + private func awaitPresenceOp(_ label: String, + sourceLocation: SourceLocation = #_sourceLocation, + _ operation: (@escaping ARTCallback) -> Void) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + operation { error in + if let error { + Issue.record("\(label) failed: \(error)", sourceLocation: sourceLocation) + } + continuation.resume() + } + } + } +} diff --git a/Test/UTS/integration/standard/rest/PublishTests.swift b/Test/UTS/integration/standard/rest/PublishTests.swift new file mode 100644 index 000000000..b0dfd63ff --- /dev/null +++ b/Test/UTS/integration/standard/rest/PublishTests.swift @@ -0,0 +1,292 @@ +import Testing +import Foundation +import Ably + +/// REST channel publish (RSL1d, RSL1n, RSL1k5, RSL1l1, RSL1m4) +/// Derived from ably/specification `uts/rest/integration/publish.md` +/// +/// Direct-sandbox integration test against the Ably Sandbox (`sandbox.realtime.ably-nonprod.net`, +/// via SandboxApp.sandboxHost) — no proxy, no fault injection. Provisions a throwaway SandboxApp +/// and points real REST clients straight at the sandbox. Needs outbound network: +/// +/// ```bash +/// swift test --filter UTS.PublishTests +/// ``` +@Suite(.serialized) +final class PublishTests: IntegrationTestCase { + + // UTS: rest/integration/RSL1d/publish-failure-error-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSL1d_error_indication_on_publish_failure(useBinaryProtocol: Bool) async throws { + // The spec's BEFORE/AFTER ALL sandbox app provisioning is owned by the withSandboxApp scope. + try await withSandboxApp { app in + // Setup + let channelName = "forbidden-channel-\(UUID().uuidString)" // Not in restricted key's capability + + // restricted_key = app_config.keys[2] # per-channel capabilities + try #require(app.keys.count > 2, "test-app-setup.json should provision keys[2] (per-channel capabilities)") + // Key without publish capability for this channel + let restrictedKey = app.keys[2] + + let restrictedOptions = ARTClientOptions(key: restrictedKey) + restrictedOptions.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + restrictedOptions.useBinaryProtocol = useBinaryProtocol + let restrictedClient = ARTRest(options: restrictedOptions) + let restrictedChannel = restrictedClient.channels.get(channelName) + + // Test Steps + // AWAIT restricted_channel.publish(name: "event", data: "data") FAILS WITH error + let error = try #require(await self.publishError(restrictedChannel, name: "event", data: "data")) + #expect(error.code == 40160) // Not permitted + #expect(error.statusCode == 401) + } + } + + // UTS: rest/integration/RSL1n/publish-result-serials-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSL1n_publish_result_contains_serials(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + let channelName = "test-serials-\(UUID().uuidString)" + let channel = client.channels.get(channelName) + + // Test Steps + // Single message + let result1 = try #require(await self.publishResult(channel, name: "event1", data: "data1")) + + // ASSERT result1.serials IS List + // (no separate assertion: satisfied by the type system — ARTPublishResult.serials is + // [ARTPublishResultSerial]) + #expect(result1.serials.count == 1) + // ASSERT result1.serials[0] IS String — cocoa wraps each serial in an + // ARTPublishResultSerial; the string is its `value` (nil only when the message was + // discarded by a conflation rule), so require it to be present. + let serial1 = try #require(result1.serials[0].value) + #expect(serial1.count > 0) + + // Multiple messages + let result2 = try #require(await self.publishResult(channel, messages: [ + ARTMessage(name: "event2", data: "data2"), + ARTMessage(name: "event3", data: "data3"), + ARTMessage(name: "event4", data: "data4"), + ])) + + #expect(result2.serials.count == 3) + // ASSERT ALL serial IN result2.serials: serial IS String AND serial.length > 0 + let serialValues = result2.serials.compactMap(\.value) + #expect(serialValues.count == 3) + #expect(serialValues.allSatisfy { $0.count > 0 }) + // ASSERT result2.serials ARE all unique + #expect(Set(serialValues).count == serialValues.count) + } + } + + // UTS: rest/integration/RSL1k5/idempotent-client-ids-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSL1k5_idempotent_publish_with_client_supplied_ids(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + let options = ARTClientOptions(key: app.defaultKey) + options.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + options.useBinaryProtocol = useBinaryProtocol + let client = ARTRest(options: options) + let channelName = "idempotent-explicit-\(UUID().uuidString)" + let channel = client.channels.get(channelName) + + // Test Steps + let fixedId = "client-supplied-id-\(UUID().uuidString)" + + // Publish same message ID multiple times + for i in 1...3 { + let message = ARTMessage(name: "event", data: "data-\(i)") + message.id = fixedId + await self.awaitPublish(channel, message: message) + } + + // Poll history until message appears (avoid fixed wait) + guard await pollUntil("channel history has at least one message", timeout: 10, interval: 0.5, { + await self.historyItems(of: channel).count > 0 + }) else { return } + let history = await historyItems(of: channel) + + // Verify only one message in history + #expect(history.count == 1) + #expect(history[0].id == fixedId) + // The data should be from the first publish (subsequent ones are no-ops) + #expect(history[0].data as? String == "data-1") + } + } + + // UTS: rest/integration/RSL1l1/publish-params-force-nack-0 + // DEVIATION: ably-cocoa exposes no publish-with-params API, so RSL1l1 cannot be exercised — + // see deviations.md (Failing Tests). `params:` exists only on the message-edit methods + // (updateMessage/deleteMessage/appendMessage), not on any publish overload. + @Test(.enabled(if: ProcessInfo.processInfo.environment["RUN_DEVIATIONS"] != nil), + arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSL1l1_publish_params_with_forceNack(useBinaryProtocol: Bool) async throws { + // Setup (spec) — not expressible in ably-cocoa; kept for fidelity: + // client = Rest(options: ClientOptions(key: full_access_key, endpoint: "nonprod:sandbox")) + // channel_name = "force-nack-test-" + random_id() + // channel = client.channels.get(channel_name) + + // Test Steps (spec): + // AWAIT channel.publish( + // message: Message(name: "event", data: "data"), + // params: { "_forceNack": "true" } + // ) FAILS WITH error + // ASSERT error.code == 40099 # Specific code for forced nack + // (no assertion: ARTRestChannel has no publish overload accepting request params — see + // deviations.md) + Issue.record("RSL1l1: ably-cocoa has no publish-with-params API — see deviations.md (Failing Tests)") + } + + // UTS: rest/integration/RSL1m4/clientid-mismatch-rejected-0 + @Test(arguments: [false, true]) // useBinaryProtocol: false = JSON, true = msgpack + func test_RSL1m4_clientId_mismatch_rejection(useBinaryProtocol: Bool) async throws { + try await withSandboxApp { app in + // Setup + // Create a token with a specific clientId + let keyClientOptions = ARTClientOptions(key: app.defaultKey) + keyClientOptions.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + keyClientOptions.useBinaryProtocol = useBinaryProtocol + let keyClient = ARTRest(options: keyClientOptions) + + let token = try #require(await self.requestToken( + keyClient, tokenParams: ARTTokenParams(clientId: "authenticated-client-id"))) + + // Client using token with clientId + let tokenClientOptions = ARTClientOptions(token: token) + tokenClientOptions.restHost = SandboxApp.sandboxHost // the spec's endpoint: "nonprod:sandbox" + tokenClientOptions.useBinaryProtocol = useBinaryProtocol + let tokenClient = ARTRest(options: tokenClientOptions) + + let channelName = "clientid-mismatch-\(UUID().uuidString)" + let channel = tokenClient.channels.get(channelName) + + // Test Steps + let message = ARTMessage(name: "event", + data: "data", + clientId: "different-client-id") // Doesn't match authenticated clientId + // AWAIT channel.publish(message: ...) FAILS WITH error + let error = try #require(await self.publishError(channel, messages: [message])) + #expect(error.code == 40012) // Incompatible clientId + #expect(error.statusCode == 400) + } + } +} + +extension PublishTests { + /// Awaits `publish(name:data:)` expecting it to fail (the spec's `AWAIT publish FAILS WITH + /// error`), returning the error — or nil, after recording an issue, if it unexpectedly + /// succeeded (tests unwrap with `try #require`). + private func publishError(_ channel: ARTRestChannel, + name: String, + data: String, + sourceLocation: SourceLocation = #_sourceLocation) async -> ARTErrorInfo? { + await withCheckedContinuation { (continuation: CheckedContinuation) in + channel.publish(name, data: data, callback: { error in + if error == nil { + Issue.record("publish(\(name)) unexpectedly succeeded", sourceLocation: sourceLocation) + } + continuation.resume(returning: error) + }) + } + } + + /// Awaits `publish(messages:)` expecting it to fail, returning the error — or nil, after + /// recording an issue, if it unexpectedly succeeded (tests unwrap with `try #require`). + private func publishError(_ channel: ARTRestChannel, + messages: [ARTMessage], + sourceLocation: SourceLocation = #_sourceLocation) async -> ARTErrorInfo? { + await withCheckedContinuation { (continuation: CheckedContinuation) in + channel.publish(messages, callback: { error in + if error == nil { + Issue.record("publish(messages) unexpectedly succeeded", sourceLocation: sourceLocation) + } + continuation.resume(returning: error) + }) + } + } + + /// Awaits the publish acknowledgement and returns the `ARTPublishResult` (the spec's + /// `result = AWAIT channel.publish(name:data:)`), recording an issue on error. + private func publishResult(_ channel: ARTRestChannel, + name: String, + data: String, + sourceLocation: SourceLocation = #_sourceLocation) async -> ARTPublishResult? { + await withCheckedContinuation { (continuation: CheckedContinuation) in + channel.publish(name, data: data, resultCallback: { result, error in + if let error { + Issue.record("publish(\(name)) failed: \(error)", sourceLocation: sourceLocation) + } + continuation.resume(returning: result) + }) + } + } + + /// Awaits the publish acknowledgement for an array of messages and returns the + /// `ARTPublishResult` (the spec's `result = AWAIT channel.publish(messages:)`), recording an + /// issue on error. + private func publishResult(_ channel: ARTRestChannel, + messages: [ARTMessage], + sourceLocation: SourceLocation = #_sourceLocation) async -> ARTPublishResult? { + await withCheckedContinuation { (continuation: CheckedContinuation) in + channel.publish(messages, resultCallback: { result, error in + if let error { + Issue.record("publish(messages) failed: \(error)", sourceLocation: sourceLocation) + } + continuation.resume(returning: result) + }) + } + } + + /// Awaits the publish acknowledgement of a single pre-built message (the spec's + /// `AWAIT channel.publish(message: ...)`), recording an issue on error. + private func awaitPublish(_ channel: ARTRestChannel, + message: ARTMessage, + sourceLocation: SourceLocation = #_sourceLocation) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + channel.publish([message], callback: { error in + if let error { + Issue.record("publish(\(message.id ?? "?")) failed: \(error)", sourceLocation: sourceLocation) + } + continuation.resume() + }) + } + } + + /// Fetches the channel's history (default query) and returns its items, recording an issue on + /// error. + private func historyItems(of channel: ARTRestChannel, + sourceLocation: SourceLocation = #_sourceLocation) async -> [ARTMessage] { + await withCheckedContinuation { (continuation: CheckedContinuation<[ARTMessage], Never>) in + channel.history { result, error in + if let error { + Issue.record("history() failed: \(error)", sourceLocation: sourceLocation) + } + continuation.resume(returning: result?.items ?? []) + } + } + } + + /// Requests a token from the sandbox (the spec's `AWAIT key_client.auth.requestToken(...)`), + /// returning the issued `TokenDetails.token` string — the only field the tests use, and + /// `ARTTokenDetails` is not Sendable so it cannot cross the continuation. Returns nil, after + /// recording an issue, on failure (tests unwrap with `try #require`). + private func requestToken(_ client: ARTRest, + tokenParams: ARTTokenParams, + sourceLocation: SourceLocation = #_sourceLocation) async -> String? { + await withCheckedContinuation { (continuation: CheckedContinuation) in + client.auth.requestToken(tokenParams, with: nil) { tokenDetails, error in + if let error { + Issue.record("requestToken failed: \(error)", sourceLocation: sourceLocation) + } + continuation.resume(returning: tokenDetails?.token) + } + } + } +}