diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index b50e21fe..3c9dfdef 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -112,7 +112,11 @@ jobs: # https://forums.swift.org/t/warnings-as-errors-for-libraries-frameworks/58393/2 - run: swift build -Xswiftc -warnings-as-errors - - run: swift test -Xswiftc -warnings-as-errors + # The `UTS` target's tests are translations of the LiveObjects Universal Test Suite against an + # as-yet-unimplemented (skeleton) public API, so many trap via `notImplemented()` at runtime. A + # trap is a `fatalError`, which aborts the whole `swift test` process, so we skip the UTS target + # here (it's still built above); it'll be run once the API it targets is implemented. + - run: swift test --skip 'UTS\.' -Xswiftc -warnings-as-errors build-release-configuration-spm: name: SPM, `release` configuration (Xcode ${{ matrix.tooling.xcodeVersion }}) diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme index 5c088be7..d36373d9 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme @@ -62,6 +62,16 @@ ReferencedContainer = "container:"> + + + + Note: Not yet implemented in this target — traps via ``notImplemented()``. + internal static func fromInternalObjectMessage(_ source: ProtocolTypes.InboundObjectMessage, channelName: String) -> ObjectMessage { + _ = (source, channelName) + notImplemented() + } } // MARK: - ObjectOperation (PAOOP) @@ -111,6 +121,16 @@ public struct ObjectOperation: Sendable, Equatable { self.objectDelete = objectDelete self.mapClear = mapClear } + + /// Builds the user-facing `ObjectOperation` from an internal (protocol) object operation. The + /// direct payloads are copied; `mapCreate` / `counterCreate` are resolved from either the direct + /// field or the `*WithObjectId` variant's retained `derivedFrom` value. Spec: `PAOOP3`. + /// + /// > Note: Not yet implemented in this target — traps via ``notImplemented()``. + internal static func fromInternalObjectOperation(_ source: ProtocolTypes.ObjectOperation) -> ObjectOperation { + _ = source + notImplemented() + } } // MARK: - ObjectOperationAction (OOP2) diff --git a/Tests/UTS/Harness/Captured.swift b/Tests/UTS/Harness/Captured.swift new file mode 100644 index 00000000..055c1707 --- /dev/null +++ b/Tests/UTS/Harness/Captured.swift @@ -0,0 +1,33 @@ +import Foundation + +/// A thread-safe collector for the spec's "local `captured_*` array" pattern +/// (`uts/.../helpers/mock_http.md` & `mock_websocket.md`, "Common Mistakes"). +/// +/// The mock handler closures (`onConnectionAttempt`, `onRequest`) are invoked by the SDK on its own +/// queues, while the test reads what was captured on the test thread. A plain local `var array` +/// captured into those `@Sendable` closures is a data race the Swift 6 compiler rejects; this small +/// lock-guarded, `Sendable` reference type lets a test keep a *local* collector (not a property on +/// the mock) while staying race-free. +final class Captured: @unchecked Sendable { + private let lock = NSLock() + private var items: [Element] = [] + + init() {} + + /// Records an element (called from a mock handler, on an SDK queue). + func append(_ element: Element) { + lock.lock() + items.append(element) + lock.unlock() + } + + /// A snapshot of everything captured so far (read from the test thread, after the operation has settled). + var all: [Element] { + lock.lock(); defer { lock.unlock() } + return items + } + + var count: Int { all.count } + var first: Element? { all.first } + subscript(_ index: Int) -> Element { all[index] } +} diff --git a/Tests/UTS/Harness/CapturingLog.swift b/Tests/UTS/Harness/CapturingLog.swift new file mode 100644 index 00000000..99530160 --- /dev/null +++ b/Tests/UTS/Harness/CapturingLog.swift @@ -0,0 +1,34 @@ +import Foundation +import Ably + +/// An `ARTLog` that records every message the SDK logs, for tests that assert on log output (e.g. +/// "an error is logged"). Install via `ARTClientOptions.logHandler`. +/// +/// The SDK's internal logger forwards every message to the injected `logHandler` (only the level +/// filter lives in `ARTLog.log:withLevel:`), so overriding `log(_:with:)` *without* calling `super` +/// captures everything regardless of `logLevel` — and keeps the console quiet. +final class CapturingLog: ARTLog { + struct Entry { + let level: ARTLogLevel + let message: String + } + + private let lock = NSLock() + private var storedEntries: [Entry] = [] + + var entries: [Entry] { + lock.lock(); defer { lock.unlock() } + return storedEntries + } + + override func log(_ message: String, with level: ARTLogLevel) { + lock.lock() + storedEntries.append(Entry(level: level, message: message)) + lock.unlock() + } + + /// Whether any captured message at `level` contains `substring` (case-insensitive). + func contains(level: ARTLogLevel, message substring: String) -> Bool { + entries.contains { $0.level == level && $0.message.localizedCaseInsensitiveContains(substring) } + } +} diff --git a/Tests/UTS/Harness/MockHTTPClient.swift b/Tests/UTS/Harness/MockHTTPClient.swift new file mode 100644 index 00000000..2ee8b81c --- /dev/null +++ b/Tests/UTS/Harness/MockHTTPClient.swift @@ -0,0 +1,138 @@ +import Foundation +import Ably +import Ably.Private + +/// The UTS `MockHttpClient` — a fake `ARTHTTPExecutor` that intercepts the SDK's outgoing HTTP +/// requests so tests can observe them and inject responses, with no real network. Installed via +/// `rest.internal.httpExecutor` (the cocoa mapping of the spec's `install_mock`). +/// +/// Mirrors `uts/rest/unit/helpers/mock_http.md`. The cocoa HTTP seam is **request-level** +/// (`executeRequest:completion:`), so each `execute(_:)` is a standalone attempt: `onConnectionAttempt` +/// is consulted first (its `respond_with_refused`/`timeout`/`dns_error` fail the request with the +/// corresponding `NSError`), and unless the connection failed, the request is delivered to `onRequest`. +final class MockHTTPClient: NSObject, ARTHTTPExecutor, Sendable { + /// Returns the error the connection should fail with, or `nil` if it succeeds. + typealias ConnectionHandler = @Sendable (PendingHTTPConnection) -> NSError? + typealias RequestHandler = @Sendable (PendingHTTPRequest) -> Void + + private let onConnectionAttempt: ConnectionHandler? + private let onRequest: RequestHandler? + + init(onConnectionAttempt: ConnectionHandler? = nil, onRequest: RequestHandler? = nil) { + self.onConnectionAttempt = onConnectionAttempt + self.onRequest = onRequest + super.init() + } + + // MARK: ARTHTTPExecutor + + func execute(_ request: URLRequest, completion callback: ((HTTPURLResponse?, Data?, Error?) -> Void)? = nil) -> (ARTCancellable & NSObjectProtocol)? { + // Connection phase — fail the request if the connection handler rejects it. + if let connectionError = onConnectionAttempt?(PendingHTTPConnection(request: request)) { + callback?(nil, nil, connectionError) + return NoopCancellable() + } + + // Request phase — deliver to onRequest. + guard let onRequest else { + // A request reached the mock with no handler installed: a test set-up error. + fatalError("MockHTTPClient received a request but no onRequest handler is installed") + } + onRequest(PendingHTTPRequest(request: request, completion: callback)) + return NoopCancellable() + } +} + +/// A connection attempt (UTS `PendingConnection`). The cocoa HTTP seam doesn't expose real TCP, so +/// this is derived from the request's URL; the `onConnectionAttempt` handler inspects it and returns +/// the resulting error (or `nil`). Immutable — the result is the handler's return value. +struct PendingHTTPConnection { + let host: String + let port: Int + let tls: Bool + + init(request: URLRequest) { + guard let url = request.url, let host = url.host else { + // The SDK should never make a request without a URL host; if it does, the test set-up + // (or the SDK) is broken, so fail fast rather than fabricate a connection. + fatalError("MockHTTPClient received a connection attempt for a request without a URL host") + } + self.tls = (url.scheme?.lowercased() == "https") + self.host = host + self.port = url.port ?? (tls ? 443 : 80) + } + + /// Connection succeeds; requests proceed (UTS `respond_with_success`). + func respondWithSuccess() -> NSError? { nil } + /// TCP connection refused (UTS `respond_with_refused`). + func respondWithRefused() -> NSError? { Self.urlError(.cannotConnectToHost) } + /// Connection times out (UTS `respond_with_timeout`). + func respondWithTimeout() -> NSError? { Self.urlError(.timedOut) } + /// DNS resolution fails (UTS `respond_with_dns_error`). + func respondWithDNSError() -> NSError? { Self.urlError(.cannotFindHost) } + + private static func urlError(_ code: URLError.Code) -> NSError { + NSError(domain: NSURLErrorDomain, code: code.rawValue, userInfo: nil) + } +} + +/// A request the SDK made (UTS `PendingRequest`): inspectable, and respondable by the test. Holds the +/// completion callback rather than any mutable state. +struct PendingHTTPRequest { + let request: URLRequest + private let completion: ((HTTPURLResponse?, Data?, Error?) -> Void)? + + init(request: URLRequest, completion: ((HTTPURLResponse?, Data?, Error?) -> Void)?) { + self.request = request + self.completion = completion + } + + var url: URL { + guard let url = request.url else { + fatalError("MockHTTPClient received a request without a URL") + } + return url + } + var method: String { request.httpMethod ?? "GET" } + var headers: [String: String] { request.allHTTPHeaderFields ?? [:] } + var body: Data? { request.httpBody } + + /// Query parameters parsed from the request URL (UTS `url.query_params`). + var queryParams: [String: String] { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true), + let items = components.queryItems else { return [:] } + var result: [String: String] = [:] + for item in items where item.value != nil { result[item.name] = item.value } + return result + } + + /// Sends an HTTP response (UTS `respond_with`). `body` may be `Data`, `String`, or a + /// JSON-serialisable value (dictionary/array); defaults to a JSON content type. + func respondWith(status: Int, body: Any, headers: [String: String] = [:]) { + var headerFields = headers + if headerFields["Content-Type"] == nil { + headerFields["Content-Type"] = "application/json" + } + let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: headerFields) + completion?(response, Self.data(from: body), nil) + } + + /// Simulates a request timeout after the connection was established (UTS `respond_with_timeout`). + func respondWithTimeout() { + completion?(nil, nil, NSError(domain: NSURLErrorDomain, code: URLError.timedOut.rawValue, userInfo: nil)) + } + + private static func data(from body: Any) -> Data { + switch body { + case let data as Data: return data + case let string as String: return Data(string.utf8) + default: return (try? JSONSerialization.data(withJSONObject: body)) ?? Data() + } + } +} + +/// No-op cancellable returned by `MockHTTPClient.execute` (the response is delivered synchronously, so +/// there's nothing to cancel). +private final class NoopCancellable: NSObject, ARTCancellable { + func cancel() {} +} diff --git a/Tests/UTS/Harness/MockTimeProvider.swift b/Tests/UTS/Harness/MockTimeProvider.swift new file mode 100644 index 00000000..bd74dc99 --- /dev/null +++ b/Tests/UTS/Harness/MockTimeProvider.swift @@ -0,0 +1,191 @@ +import Foundation +import Ably +import Ably.Private + +/// A deterministic `ARTTimeProvider` for UTS unit tests. +/// +/// Both the wall clock and the continuous clock start at a fixed origin and only ever move when the test calls +/// `advanceTime(byMilliseconds:)` — nothing fires on its own, so tests are fully in control +/// (the UTS `enable_fake_timers()` / `ADVANCE_TIME(ms)` primitives). +/// +/// Every block scheduled through the SDK's `scheduleAfter:queue:block:` is recorded here, which +/// means this provider doubles as the timer-leak safety net the UTS derived-tests guide asks for: +/// `cancelAllScheduled()` in teardown cancels every surviving timer, including ones the SDK may +/// have orphaned and which are no longer reachable through its public API. +final class MockTimeProvider: NSObject, TimeProvider, @unchecked Sendable { + /// A point on / duration of the continuous clock, in the same units as `clock_gettime_nsec_np`. + private typealias Nanoseconds = UInt64 + + private let lock = NSLock() + + /// Wall-clock origin: a fixed, arbitrary point in time (ms since the Unix epoch). + private var wallClockMilliseconds: Double = 1_600_000_000_000 + + /// Continuous-clock origin. + private var continuousNanoseconds: Nanoseconds = 1_000_000_000 + + private final class ScheduledBlock: @unchecked Sendable { + let fireAtNanoseconds: Nanoseconds + let queue: DispatchQueue + let block: @Sendable () -> Void + var isCancelled = false + + init(fireAtNanoseconds: Nanoseconds, queue: DispatchQueue, block: @escaping @Sendable () -> Void) { + self.fireAtNanoseconds = fireAtNanoseconds + self.queue = queue + self.block = block + } + } + + /// A cancellable handle returned to the SDK for a scheduled block. + private final class Handle: NSObject, SchedulerHandle { + private let owner: MockTimeProvider + fileprivate let scheduled: ScheduledBlock + + init(owner: MockTimeProvider, scheduled: ScheduledBlock) { + self.owner = owner + self.scheduled = scheduled + } + + func cancel() { + owner.cancel(scheduled) + } + } + + /// The fake clock's own continuous-clock instant. The system instant + /// (`ARTSystemContinuousClockInstant`) is private to `ARTSystemTimeProvider`, so the fake + /// provider supplies its own value type conforming to the boundary protocol. + private final class Instant: NSObject, ContinuousClockInstant, @unchecked Sendable { + let nanoseconds: UInt64 + init(nanoseconds: UInt64) { self.nanoseconds = nanoseconds } + + func isAfter(_ other: ContinuousClockInstant) -> Bool { + guard let other = other as? Instant else { return false } + return nanoseconds > other.nanoseconds + } + + func addingDuration(_ duration: TimeInterval) -> ContinuousClockInstant { + Instant(nanoseconds: nanoseconds + UInt64(max(0, duration) * 1_000_000_000)) + } + } + + private var scheduledBlocks: [ScheduledBlock] = [] + + private func withLock(_ body: () -> T) -> T { + lock.lock() + defer { lock.unlock() } + return body() + } + + /// Count of pending (not-yet-fired, not-cancelled) blocks. Caller must hold the lock. + private var pendingCount: Int { + scheduledBlocks.filter { !$0.isCancelled }.count + } + + // MARK: ARTTimeProvider + + func wallClockNow() -> Date { + withLock { Date(timeIntervalSince1970: wallClockMilliseconds / 1000.0) } + } + + func continuousClockNow() -> ContinuousClockInstant { + withLock { Instant(nanoseconds: continuousNanoseconds) } + } + + func schedule(after delay: TimeInterval, queue: DispatchQueue, block: @escaping @Sendable () -> Void) -> SchedulerHandle { + withLock { + rememberQueue(queue) + let fireAt = continuousNanoseconds + Nanoseconds(max(0, delay) * 1_000_000_000) + let scheduled = ScheduledBlock(fireAtNanoseconds: fireAt, queue: queue, block: block) + scheduledBlocks.append(scheduled) + return Handle(owner: self, scheduled: scheduled) + } + } + + // MARK: Test control + + /// Every distinct queue the SDK has scheduled a timer on. `advanceTime` barriers these to let + /// SDK work settle around firing timers. We remember them (rather than reading the queues of the + /// currently-pending blocks) because there are moments when no block is pending — at the leading + /// drain before a queued handler has scheduled its timer, and between firing a timer and its + /// cascade scheduling the next one — yet we still need to barrier the SDK's queue. The SDK only + /// schedules timers on its internal queue, so that queue is remembered the first time any timer + /// is scheduled (during connect for example); the callback queue isn't drained here, which is fine + /// since the tests observe state through the internal queue, not via listener callbacks. + private var knownQueues: [ObjectIdentifier: DispatchQueue] = [:] + + /// Caller must hold `lock`. Keyed by identity, so re-registering a queue is a no-op. + private func rememberQueue(_ queue: DispatchQueue) { + knownQueues[ObjectIdentifier(queue)] = queue + } + + /// Advances both clocks by `milliseconds` and runs the SDK to quiescence: + /// + /// 1. Drains first, so any already-queued work (e.g. an unexpected disconnect transitioning to + /// DISCONNECTED) finishes *registering its timer* before the clock moves past it. + /// 2. Advances both clocks. + /// 3. Fires every now-due block and drains the cascade it triggers, repeating while the cascade + /// schedules further blocks that are themselves already due (e.g. a zero-delay reschedule). + /// + /// After it returns, the SDK has fully reacted to the elapsed time: state has settled and the + /// next timer (if any) is registered. Chained retries that are scheduled *relative to the new + /// time* remain in the future and need a subsequent `advanceTime` — matching real elapsed time. + func advanceTime(byMilliseconds milliseconds: Double) { + drainAllQueues() + + let target = withLock { () -> Nanoseconds in + continuousNanoseconds = continuousNanoseconds + Nanoseconds(max(0, milliseconds) * 1_000_000) + wallClockMilliseconds += milliseconds + return continuousNanoseconds + } + + while fireBlocksDue(upTo: target) { + drainAllQueues() + } + } + + /// Dispatches every not-yet-fired, not-cancelled block whose fire time is `<= target` onto its + /// target queue (in fire-time order). Returns `true` if it dispatched anything. + private func fireBlocksDue(upTo target: Nanoseconds) -> Bool { + let due = withLock { () -> [ScheduledBlock] in + let due = scheduledBlocks + .filter { !$0.isCancelled && $0.fireAtNanoseconds <= target } + .sorted { $0.fireAtNanoseconds < $1.fireAtNanoseconds } + scheduledBlocks.removeAll { scheduled in due.contains { $0 === scheduled } } + return due + } + for scheduled in due { + scheduled.queue.async(execute: scheduled.block) + } + return !due.isEmpty + } + + /// Settles the SDK's queues until a pass no longer changes the number of scheduled timers — + /// i.e. the work has stopped scheduling (or cancelling) timers and has quiesced. Each pass takes + /// the lock once (snapshotting both the queues to barrier and the pending count together). + private func drainAllQueues(maxPasses: Int = 100) { + var previousCount = -1 + for _ in 0.. Void + /// Handler invoked with each `ARTProtocolMessage` the SDK sends towards the server (UTS + /// `onMessageFromClient`). Used by the LiveObjects `setup_synced_channel` pattern to respond to + /// `ATTACH` with `ATTACHED`+`OBJECT_SYNC` and to auto-`ACK` `OBJECT` publishes. + typealias MessageFromClientHandler = @Sendable (ARTProtocolMessage) -> Void + + private let onConnectionAttempt: ConnectionAttemptHandler? + private let onMessageFromClient: MessageFromClientHandler? + private let lock = NSLock() + private var latestConnection: MockWebSocket? + + /// The most recent connection (UTS `active_connection`). Set on the internal queue, read from + /// the test thread, so guarded by a lock. Tests that need the full history of connection + /// attempts capture them into a local array inside `onConnectionAttempt` (the spec's pattern). + var activeConnection: MockWebSocket? { + lock.lock(); defer { lock.unlock() } + return latestConnection + } + + init(onConnectionAttempt: ConnectionAttemptHandler? = nil, + onMessageFromClient: MessageFromClientHandler? = nil) { + self.onConnectionAttempt = onConnectionAttempt + self.onMessageFromClient = onMessageFromClient + } + + fileprivate func register(_ socket: MockWebSocket) { + lock.lock() + latestConnection = socket + lock.unlock() + } + + fileprivate func fireConnectionAttempt(_ socket: MockWebSocket) { + onConnectionAttempt?(socket) + } + + fileprivate func fireMessageFromClient(_ message: ARTProtocolMessage) { + onMessageFromClient?(message) + } +} + +/// A single simulated WebSocket connection. Conforms to `ARTWebSocket` so the real +/// `ARTWebSocketTransport` can drive it, and exposes the UTS server-side API +/// (`respondWithSuccess`, `sendToClient`, `simulateDisconnect`, …) for tests. +final class MockWebSocket: NSObject, ARTWebSocket, @unchecked Sendable { + + // MARK: ARTWebSocket + + weak var delegate: ARTWebSocketDelegate? + var delegateDispatchQueue: DispatchQueue? + private(set) var readyState: ARTWebSocketReadyState = .connecting + + // MARK: Inspection + + /// The full request the SDK built for this connection (real production URL building). + let request: URLRequest + /// Convenience accessor for the connection URL. + var url: URL { request.url ?? URL(string: "wss://invalid")! } + /// Query parameters parsed from the connection URL (UTS `url.query_params`). + var queryParams: [String: String] { + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: true), + let items = components.queryItems else { return [:] } + var result: [String: String] = [:] + for item in items where item.value != nil { + result[item.name] = item.value + } + return result + } + + /// Protocol messages the SDK has sent towards the server, decoded, in order + /// (UTS client-to-server `ws_frame` events). Appended on the internal queue, read from the + /// test thread, so guarded by a lock. + var sentMessages: [ARTProtocolMessage] { + lock.lock(); defer { lock.unlock() } + return sent + } + private var sent: [ARTProtocolMessage] = [] + + /// The raw frames the SDK has sent towards the server, in order. Object-message tests inspect the + /// generated wire form here (the decoded `ARTProtocolMessage.state` drops the outbound-only + /// `*WithObjectId` fields), mirroring ably-js's `captured.state` wire inspection. + var sentFrames: [Data] { + lock.lock(); defer { lock.unlock() } + return frames + } + private var frames: [Data] = [] + private let lock = NSLock() + + private let decoder: ARTEncoder + + /// The provider that created this socket, notified of each client-sent message (UTS + /// `onMessageFromClient`). Weak to avoid a retain cycle; the provider outlives the socket. + fileprivate weak var provider: MockWebSocketProvider? + + init(request: URLRequest, decoder: ARTEncoder) { + self.request = request + self.decoder = decoder + super.init() + } + + func setDelegateDispatchQueue(_ queue: DispatchQueue) { + delegateDispatchQueue = queue + } + + func open() { + // No-op: opening is driven by the test via `onConnectionAttempt` → `respondWithSuccess()`, + // not by the real network. (The transport calls this on a private queue we don't control; + // keeping it a no-op means all simulated server activity is delivered via the delegate queue.) + } + + func close(withCode code: Int, reason: String?) { + readyState = .closed + } + + func send(_ message: Any?) { + guard let data = message as? Data else { return } + guard let decoded = try? decoder.decodeProtocolMessage(data) else { return } + lock.lock() + sent.append(decoded) + frames.append(data) + lock.unlock() + // UTS `onMessageFromClient`: let the test's simulated server react to what the client sent. + provider?.fireMessageFromClient(decoded) + } + + // MARK: UTS server-side API + + /// Accepts the connection at the transport level (UTS `respond_with_success()`). + func respondWithSuccess() { + readyState = .open + deliverToDelegate { [weak self] in + guard let self else { return } + self.delegate?.webSocketDidOpen?(self) + } + } + + /// Delivers a protocol message to the client, leaving the connection open + /// (UTS `send_to_client`). + func sendToClient(_ message: ProtocolMessage) { + deliverToDelegate { [weak self] in + guard let self else { return } + guard self.readyState == .open else { return } + self.delegate?.webSocket?(self, didReceiveMessage: message.makeProtocolMessage()) + } + } + + /// Delivers a protocol message and then closes the connection (UTS `send_to_client_and_close`). + /// Used for connection-level `ERROR` (no channel) and `DISCONNECTED`. + func sendToClientAndClose(_ message: ProtocolMessage) { + deliverToDelegate { [weak self] in + guard let self else { return } + if self.readyState == .open { + self.delegate?.webSocket?(self, didReceiveMessage: message.makeProtocolMessage()) + } + self.readyState = .closed + self.delegate?.webSocket?(self, didCloseWithCode: WSCloseCode.normal, reason: "Normal Closure", wasClean: true) + } + } + + /// Refuses the connection at the network level (UTS `respond_with_refused()`); the SDK treats + /// this as a retryable connection failure (`realtimeTransportRefused:`). + func respondWithRefused() { + deliverToDelegate { [weak self] in + guard let self else { return } + self.readyState = .closed + self.delegate?.webSocket?(self, didCloseWithCode: WSCloseCode.refuse, reason: "Connection refused", wasClean: false) + } + } + + /// Drops the connection without any server message (UTS `simulate_disconnect`). + func simulateDisconnect() { + deliverToDelegate { [weak self] in + guard let self else { return } + self.readyState = .closed + // GoingAway maps to `realtimeTransportDisconnected:` (an unexpected connectivity drop). + self.delegate?.webSocket?(self, didCloseWithCode: WSCloseCode.goingAway, reason: "Going Away", wasClean: false) + } + } + + /// Delivers a delegate callback on the `delegateDispatchQueue` the transport set, per the + /// `ARTWebSocket` contract (the real `ARTSRWebSocket` calls its delegate on this queue). It is + /// always set before any server-side method runs: the transport sets it synchronously in + /// `setupWebSocket`, before `onConnectionAttempt` (and any later test call) fires. + private func deliverToDelegate(_ block: @escaping @Sendable () -> Void) { + guard let queue = delegateDispatchQueue else { + assertionFailure("MockWebSocket delegate callback before delegateDispatchQueue was set") + return + } + queue.async(execute: block) + } +} + +/// WebSocket close codes interpreted by `ARTWebSocketTransport.webSocket:didCloseWithCode:…`. +private enum WSCloseCode { + static let normal = 1000 // ARTWsCloseNormal -> realtimeTransportClosed: + static let goingAway = 1001 // ARTWsGoingAway -> realtimeTransportDisconnected: + static let refuse = 1003 // ARTWsRefuse -> realtimeTransportRefused: +} + +/// Creates `MockWebSocket`s and notifies the `MockWebSocketProvider` of each connection attempt. +final class MockWebSocketFactory: NSObject, WebSocketFactory { + private let workQueue: DispatchQueue + private let decoder: ARTEncoder + private let wsProvider: MockWebSocketProvider + + init(workQueue: DispatchQueue, decoder: ARTEncoder, wsProvider: MockWebSocketProvider) { + self.workQueue = workQueue + self.decoder = decoder + self.wsProvider = wsProvider + super.init() + } + + func createWebSocket(with request: URLRequest, logger: InternalLog?) -> ARTWebSocket { + let webSocket = MockWebSocket(request: request, decoder: decoder) + webSocket.provider = wsProvider + wsProvider.register(webSocket) + // Fire `onConnectionAttempt` on the work queue. This block is enqueued during the transport's + // `setupWebSocket` turn, so it runs *after* the transport has wired up `delegate` and + // `delegateDispatchQueue` (both set synchronously right after this method returns). + let wsProvider = self.wsProvider + workQueue.async { + wsProvider.fireConnectionAttempt(webSocket) + } + return webSocket + } +} + +/// A `RealtimeTransportFactory` that builds a real `ARTWebSocketTransport` backed by a +/// `MockWebSocketFactory`. Installed via `ARTClientOptions.testOptions.transportFactory`. +final class MockWebSocketTransportFactory: NSObject, RealtimeTransportFactory { + private let wsProvider: MockWebSocketProvider + + init(wsProvider: MockWebSocketProvider) { + self.wsProvider = wsProvider + super.init() + } + + func transport(withRest rest: ARTRestInternal, options: ARTClientOptions, resumeKey: String?, logger: InternalLog) -> ARTRealtimeTransport { + let webSocketFactory = MockWebSocketFactory(workQueue: rest.queue, decoder: rest.defaultEncoder, wsProvider: wsProvider) + return ARTWebSocketTransport(rest: rest, options: options, resumeKey: resumeKey, logger: logger, webSocketFactory: webSocketFactory) + } +} diff --git a/Tests/UTS/Harness/NoOpReachability.swift b/Tests/UTS/Harness/NoOpReachability.swift new file mode 100644 index 00000000..935acfcb --- /dev/null +++ b/Tests/UTS/Harness/NoOpReachability.swift @@ -0,0 +1,22 @@ +import Foundation +import Ably +import Ably.Private + +/// A reachability implementation that never reports any network changes. +/// +/// Unit tests use the `MockWebSocket` transport and must not touch the real network; installing +/// this via `options.testOptions.reachabilityClass` stops the SDK from starting OS-level +/// network monitoring during a test. +final class NoOpReachability: NSObject, ARTReachability { + init(logger: InternalLog, queue: DispatchQueue) { + super.init() + } + + func listen(forHost host: String, callback: @escaping (Bool) -> Void) { + // Intentionally empty: never deliver a reachability change. + } + + func off() { + // Nothing to tear down. + } +} diff --git a/Tests/UTS/Harness/ProtocolMessage.swift b/Tests/UTS/Harness/ProtocolMessage.swift new file mode 100644 index 00000000..d937b20a --- /dev/null +++ b/Tests/UTS/Harness/ProtocolMessage.swift @@ -0,0 +1,162 @@ +import Foundation +import Ably +import Ably.Private +import _AblyPluginSupportPrivate +@testable import AblyLiveObjects + +/// A `Sendable` description of a server-to-client protocol message that a test injects via +/// `MockWebSocket.sendToClient(_:)` / `sendToClientAndClose(_:)`. +/// +/// The LiveObjects `OBJECT` / `OBJECT_SYNC` variants carry decoded ``ProtocolTypes/InboundObjectMessage`` +/// values (which are `Sendable`); the concrete `ARTProtocolMessage.state` — an array of the plugin's +/// `ObjectMessageBox` boxes — is built at delivery time in `makeProtocolMessage()`, so no +/// non-`Sendable` value crosses the queue hop. This mirrors what ably-cocoa's wire decoder produces +/// (the `state` property holds already-decoded `APObjectMessageProtocol` boxes), so the mock can +/// bypass wire encoding entirely. +struct ProtocolMessage: Sendable { + + private enum Kind: Sendable { + case connected(connectionId: String, connectionKey: String, maxIdleInterval: TimeInterval, connectionStateTtl: TimeInterval, siteCode: String?, objectsGCGracePeriod: Double?) + case attached(channel: String, channelSerial: String, hasObjects: Bool) + case error(code: Int, statusCode: Int, message: String) + case ack(msgSerial: Int, count: Int, serials: [String?]?) + case closed + case detached(channel: String) + case channelError(channel: String, code: Int, statusCode: Int, message: String) + case objectSync(channel: String, channelSerial: String?, state: [ProtocolTypes.InboundObjectMessage]) + case object(channel: String, state: [ProtocolTypes.InboundObjectMessage]) + } + + private let kind: Kind + + /// A `CONNECTED` message carrying connection details (UTS `ProtocolMessage(action: CONNECTED, ...)`). + static func connected(connectionId: String, + connectionKey: String, + maxIdleInterval: TimeInterval = 15, + connectionStateTtl: TimeInterval = 120, + siteCode: String? = nil, + objectsGCGracePeriod: Double? = nil) -> ProtocolMessage { + .init(kind: .connected(connectionId: connectionId, connectionKey: connectionKey, maxIdleInterval: maxIdleInterval, connectionStateTtl: connectionStateTtl, siteCode: siteCode, objectsGCGracePeriod: objectsGCGracePeriod)) + } + + /// An `ATTACHED` message for a channel (UTS `ProtocolMessage(action: ATTACHED, ...)`). Set + /// `hasObjects` to raise the `HAS_OBJECTS` flag (RTO4). + static func attached(channel: String, channelSerial: String, hasObjects: Bool = false) -> ProtocolMessage { + .init(kind: .attached(channel: channel, channelSerial: channelSerial, hasObjects: hasObjects)) + } + + /// An `ERROR` message (UTS `ProtocolMessage(action: ERROR, error: ErrorInfo(...))`). + static func error(code: Int, statusCode: Int, message: String) -> ProtocolMessage { + .init(kind: .error(code: code, statusCode: statusCode, message: message)) + } + + /// An `ACK` message (UTS `ProtocolMessage(action: ACK, msgSerial: ..., count: ...)`). Pass + /// `serials` to populate the `res` array (UTS `build_ack_message`, one `res` entry carrying all + /// the object serials). + static func ack(msgSerial: Int, count: Int, serials: [String?]? = nil) -> ProtocolMessage { + .init(kind: .ack(msgSerial: msgSerial, count: count, serials: serials)) + } + + /// A `CLOSED` message (UTS `ProtocolMessage(action: CLOSED)`). + static func closed() -> ProtocolMessage { + .init(kind: .closed) + } + + /// A channel `DETACHED` message (UTS `ProtocolMessage(action: DETACHED, channel: ...)`). + static func detached(channel: String) -> ProtocolMessage { + .init(kind: .detached(channel: channel)) + } + + /// A channel-level `ERROR` message that transitions the channel to `FAILED` + /// (UTS `ProtocolMessage(action: ERROR, channel: ..., error: ...)`). + static func channelError(channel: String, code: Int, statusCode: Int, message: String) -> ProtocolMessage { + .init(kind: .channelError(channel: channel, code: code, statusCode: statusCode, message: message)) + } + + /// An `OBJECT_SYNC` message carrying object state (UTS `build_object_sync_message`). + static func objectSync(channel: String, channelSerial: String?, state: [ProtocolTypes.InboundObjectMessage]) -> ProtocolMessage { + .init(kind: .objectSync(channel: channel, channelSerial: channelSerial, state: state)) + } + + /// An `OBJECT` message carrying object operations (UTS `build_object_message`). + static func object(channel: String, state: [ProtocolTypes.InboundObjectMessage]) -> ProtocolMessage { + .init(kind: .object(channel: channel, state: state)) + } + + /// Builds the concrete `ARTProtocolMessage`. Call on the delegate queue, at delivery time. + func makeProtocolMessage() -> ARTProtocolMessage { + let message = ARTProtocolMessage() + switch kind { + case let .connected(connectionId, connectionKey, maxIdleInterval, connectionStateTtl, siteCode, objectsGCGracePeriod): + message.action = .connected + message.connectionId = connectionId + message.connectionKey = connectionKey + message.connectionDetails = ARTConnectionDetails( + clientId: nil, + connectionKey: connectionKey, + maxMessageSize: 0, + maxFrameSize: 0, + maxInboundRate: 0, + connectionStateTtl: connectionStateTtl, + serverId: "", + maxIdleInterval: maxIdleInterval, + objectsGCGracePeriod: objectsGCGracePeriod.map { NSNumber(value: $0) }, + siteCode: siteCode + ) + case let .attached(channel, channelSerial, hasObjects): + message.action = .attached + message.channel = channel + message.channelSerial = channelSerial + if hasObjects { + // ARTProtocolMessageFlagHasObjects = 1 << 7 + message.flags |= (1 << 7) + } + case let .error(code, statusCode, text): + message.action = .error + message.error = ARTErrorInfo.create(withCode: code, status: statusCode, message: text) + case let .ack(msgSerial, count, serials): + message.action = .ack + message.msgSerial = NSNumber(value: msgSerial) + message.count = Int32(count) + if let serials { + message.res = [ARTPublishResult(serials: serials.map { ARTPublishResultSerial(value: $0) })] + } + case .closed: + message.action = .closed + case let .detached(channel): + message.action = .detached + message.channel = channel + case let .channelError(channel, code, statusCode, text): + message.action = .error + message.channel = channel + message.error = ARTErrorInfo.create(withCode: code, status: statusCode, message: text) + case let .objectSync(channel, channelSerial, state): + message.action = .objectSync + message.channel = channel + message.channelSerial = channelSerial + message.setState(state) + case let .object(channel, state): + message.action = .object + message.channel = channel + message.setState(state) + } + return message + } +} + +private extension ARTProtocolMessage { + /// Sets the object `state` on the protocol message. + /// + /// `ARTProtocolMessage.state` is gated behind `#ifdef ABLY_SUPPORTS_PLUGINS` in ably-cocoa, a + /// define set only for ably-cocoa's own target — so the property is absent from the `Ably` Swift + /// interface for consumers. (Defining the macro for our import isn't viable: it unlocks other + /// gated headers, e.g. `ARTPluginAPI.h` → `ARTTypes.h`, that aren't on a consumer's header search + /// path, breaking the `Ably` module build.) The property exists in the compiled runtime, so we + /// set it via KVC. The boxes are the same `ObjectMessageProtocol` type the plugin emits and + /// receives, so ably-cocoa passes them straight back to the plugin's `handleObjectSync…` hook — + /// mirroring what the wire decoder would have produced, without needing wire encoding. + func setState(_ objectMessages: [ProtocolTypes.InboundObjectMessage]) { + let boxes = objectMessages.map { DefaultInternalPlugin.ObjectMessageBox(objectMessage: $0) } + setValue(boxes, forKey: "state") + } +} diff --git a/Tests/UTS/Harness/UTSTestCase.swift b/Tests/UTS/Harness/UTSTestCase.swift new file mode 100644 index 00000000..5d413e63 --- /dev/null +++ b/Tests/UTS/Harness/UTSTestCase.swift @@ -0,0 +1,188 @@ +import Foundation +import Testing +import Ably +import Ably.Private + +/// Base class for UTS-derived unit tests. +/// +/// UTS suites are Swift Testing suites that subclass this (`@Suite(.serialized) final class FooTests: UTSTestCase`). +/// Swift Testing creates a fresh instance per `@Test`, so each test gets its own clients/mocks and +/// `deinit` tears them down. +/// +/// Provides the cocoa mappings of the UTS harness primitives: +/// - `installMock(_:)` — the spec's `install_mock`: registers the `MockWebSocketProvider` / `MockHTTPClient` +/// so the next client built by `makeRealtime()` / `makeRest()` picks it up. The mock is never +/// passed to the client constructor (a mistake per `uts/docs/writing-test-specs.md`). +/// - `awaitConnectionState` / `awaitChannelState` — the spec's `AWAIT_STATE`: proceed immediately +/// if the condition already holds, otherwise poll until it does or the timeout expires. +/// - `advanceTime(byMilliseconds:)` — fake-timer advancement (`ADVANCE_TIME`). +/// - protocol-message builders, and cleanup of clients and scheduled timers. +class UTSTestCase { + + /// Default `AWAIT_STATE` timeout. Generous: with a frozen `MockTimeProvider` the SDK settles in + /// microseconds, so this only bites when a state genuinely never arrives (a real failure). + static let defaultAwaitTimeout: TimeInterval = 2 + + /// The deterministic clock, installed into clients only after `enableFakeTimers()` is called. + var timeProvider: MockTimeProvider? + + private var installedWebSocketProvider: MockWebSocketProvider? + private var installedMockHTTPClient: MockHTTPClient? + private var clients: [ARTRealtime] = [] + + // MARK: Enable fake timers + + /// UTS `enable_fake_timers()`. Clients built *after* this call use the deterministic + /// `MockTimeProvider` (advanced via `advanceTime(byMilliseconds:)`); by default — and for clients + /// built before this call — they use the real `ARTSystemTimeProvider`. Mirrors the spec, where + /// most tests run on real timers and only those exercising timeouts/retries opt into fake ones. + func enableFakeTimers() { + timeProvider = MockTimeProvider() + } + + // MARK: Install mock + + /// Registers `wsProvider` to be used by the next `makeRealtime()` call (UTS `install_mock`). + func installMock(_ wsProvider: MockWebSocketProvider) { + installedWebSocketProvider = wsProvider + } + + /// Registers `mockHTTP` to be used by the next `makeRest()` call (UTS `install_mock`). + func installMock(_ mockHTTP: MockHTTPClient) { + installedMockHTTPClient = mockHTTP + } + + // MARK: Client construction + + /// Builds an `ARTRealtime` wired to the currently installed `MockWebSocketProvider` and the + /// shared `MockTimeProvider`. A provider must be installed first via `installMock(_:)`. Like the + /// UTS specs, this leaves the SDK's `autoConnect` default (`true`) in place; tests that need to + /// control connection timing set `options.autoConnect = false` in the `configure` closure. + func makeRealtime(configure: (ARTClientOptions) -> Void = { _ in }, sourceLocation: SourceLocation = #_sourceLocation) -> ARTRealtime { + guard let wsProvider = installedWebSocketProvider else { + Issue.record("No MockWebSocketProvider installed — call installMock(_:) before makeRealtime()", sourceLocation: sourceLocation) + fatalError("No MockWebSocketProvider installed") + } + + let options = ARTClientOptions() + options.useBinaryProtocol = false + + let suffix = UUID().uuidString + options.internalDispatchQueue = DispatchQueue(label: "io.ably.uts.internal.\(suffix)") + options.dispatchQueue = DispatchQueue(label: "io.ably.uts.callback.\(suffix)") + + if let timeProvider { + options.testOptions.timeProvider = timeProvider + } + options.testOptions.transportFactory = MockWebSocketTransportFactory(wsProvider: wsProvider) + options.testOptions.reachabilityClass = NoOpReachability.self + // Realtime specs also use `install_mock(mock_http)` (auth callbacks, connection failures, + // fallback hosts, …), so wire an installed MockHTTPClient into the client's REST layer too. + if let installedMockHTTPClient { + options.testOptions.httpExecutor = installedMockHTTPClient + } + + configure(options) + + let client = ARTRealtime(options: options) + clients.append(client) + return client + } + + /// Builds an `ARTRest` whose HTTP layer is the currently installed `MockHTTPClient` (so requests are + /// intercepted, not sent over the network). A mock must be installed first via `installMock(_:)`. + func makeRest(configure: (ARTClientOptions) -> Void = { _ in }, sourceLocation: SourceLocation = #_sourceLocation) -> ARTRest { + guard let mockHTTP = installedMockHTTPClient else { + Issue.record("No MockHTTPClient installed — call installMock(_:) before makeRest()", sourceLocation: sourceLocation) + fatalError("No MockHTTPClient installed") + } + + let options = ARTClientOptions() + options.useBinaryProtocol = false + + let suffix = UUID().uuidString + options.internalDispatchQueue = DispatchQueue(label: "io.ably.uts.internal.\(suffix)") + options.dispatchQueue = DispatchQueue(label: "io.ably.uts.callback.\(suffix)") + + if let timeProvider { + options.testOptions.timeProvider = timeProvider + } + options.testOptions.httpExecutor = mockHTTP + + configure(options) + + let rest = ARTRest(options: options) + return rest + } + + // MARK: AWAIT_STATE + + /// Waits until `client.connection.state == expected` (UTS `AWAIT_STATE`). + func awaitConnectionState(_ client: ARTRealtime, + _ expected: ARTRealtimeConnectionState, + timeout: TimeInterval = defaultAwaitTimeout, + sourceLocation: SourceLocation = #_sourceLocation) { + poll("connection.state == \(ARTRealtimeConnectionStateToStr(expected))", + timeout: timeout, sourceLocation: sourceLocation) { + client.connection.state == expected + } + } + + /// Waits until `channel.state == expected` (UTS `AWAIT_STATE`). + func awaitChannelState(_ channel: ARTRealtimeChannel, + _ expected: ARTRealtimeChannelState, + timeout: TimeInterval = defaultAwaitTimeout, + sourceLocation: SourceLocation = #_sourceLocation) { + poll("channel '\(channel.name)'.state == \(ARTRealtimeChannelStateToStr(expected))", + timeout: timeout, sourceLocation: sourceLocation) { + channel.state == expected + } + } + + /// Generic `AWAIT_STATE`: proceed immediately if `condition` already holds, otherwise poll until + /// it does or `timeout` elapses (then fail). Used for non-state conditions such as "a frame has + /// been sent". The small sleep just avoids a hot busy-loop. + @discardableResult + func poll(_ description: String, + timeout: TimeInterval = defaultAwaitTimeout, + sourceLocation: SourceLocation = #_sourceLocation, + until condition: () -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while !condition() { + if Date() >= deadline { + Issue.record("Timed out after \(timeout)s awaiting: \(description)", sourceLocation: sourceLocation) + return false + } + Thread.sleep(forTimeInterval: 0.0005) + } + return true + } + + // MARK: Fake timers + + /// Advances the fake clock (UTS `ADVANCE_TIME`). Requires `enableFakeTimers()` to have been + /// called — otherwise clients are on the real clock and there is nothing to advance. + func advanceTime(byMilliseconds milliseconds: Double, sourceLocation: SourceLocation = #_sourceLocation) { + guard let timeProvider else { + Issue.record("advanceTime requires enableFakeTimers() before makeRealtime()", sourceLocation: sourceLocation) + return + } + timeProvider.advanceTime(byMilliseconds: milliseconds) + } + + /// Closes a client (UTS `CLOSE_CLIENT`). + func closeClient(_ client: ARTRealtime) { + client.close() + } + + // MARK: Cleanup + + /// Per-test teardown. Swift Testing releases the suite instance after each `@Test`, so this runs + /// once per test: it closes any clients and cancels surviving fake timers (the leak safety net). + deinit { + for client in clients { + client.close() + } + timeProvider?.cancelAllScheduled() + } +} diff --git a/Tests/UTS/Helpers/Instance+Testing.swift b/Tests/UTS/Helpers/Instance+Testing.swift new file mode 100644 index 00000000..1b0f32cd --- /dev/null +++ b/Tests/UTS/Helpers/Instance+Testing.swift @@ -0,0 +1,57 @@ +import Ably +import Foundation +@testable import AblyLiveObjects + +/// Payload extraction for ``Instance`` — the Swift equivalent of the spec's loosely-typed `Instance` +/// casts. Non-optional: use ``Instance/type`` to discriminate the case first (these trap on a +/// mismatch, like a forced cast). +extension Instance { + /// The `objectId` of the wrapped live object (RTINS3), or `nil` for a primitive. + func id() -> String? { + switch self { + case let .liveMap(map): + map.id + case let .liveCounter(counter): + counter.id + case .primitive: + nil + } + } + + func asLiveMap() -> any LiveMapInstance { + guard case let .liveMap(map) = self else { preconditionFailure("Instance is not a LiveMap") } + return map + } + + func asLiveCounter() -> any LiveCounterInstance { + guard case let .liveCounter(counter) = self else { preconditionFailure("Instance is not a LiveCounter") } + return counter + } + + func asPrimitive() -> any PrimitiveInstance { + guard case let .primitive(primitive) = self else { preconditionFailure("Instance is not a Primitive") } + return primitive + } +} + +/// Typed shortcuts through ``PrimitiveInstance/value`` so tests can write +/// `asPrimitive().stringValue` instead of `asPrimitive().value.stringValue`. +extension PrimitiveInstance { + var stringValue: String? { get throws(ARTErrorInfo) { try value.stringValue } } + var numberValue: Double? { get throws(ARTErrorInfo) { try value.numberValue } } + var boolValue: Bool? { get throws(ARTErrorInfo) { try value.boolValue } } + var dataValue: Data? { get throws(ARTErrorInfo) { try value.dataValue } } + var jsonArrayValue: [JSONValue]? { get throws(ARTErrorInfo) { try value.jsonArrayValue } } + var jsonObjectValue: [String: JSONValue]? { get throws(ARTErrorInfo) { try value.jsonObjectValue } } +} + +/// Typed shortcuts through ``PrimitivePathObject/value()`` so tests can write +/// `asPrimitive().stringValue` instead of `asPrimitive().value()?.stringValue`. +extension PrimitivePathObject { + var stringValue: String? { get throws(ARTErrorInfo) { try value()?.stringValue } } + var numberValue: Double? { get throws(ARTErrorInfo) { try value()?.numberValue } } + var boolValue: Bool? { get throws(ARTErrorInfo) { try value()?.boolValue } } + var dataValue: Data? { get throws(ARTErrorInfo) { try value()?.dataValue } } + var jsonArrayValue: [JSONValue]? { get throws(ARTErrorInfo) { try value()?.jsonArrayValue } } + var jsonObjectValue: [String: JSONValue]? { get throws(ARTErrorInfo) { try value()?.jsonObjectValue } } +} diff --git a/Tests/UTS/Helpers/StandardTestPool.swift b/Tests/UTS/Helpers/StandardTestPool.swift new file mode 100644 index 00000000..be66f8b7 --- /dev/null +++ b/Tests/UTS/Helpers/StandardTestPool.swift @@ -0,0 +1,217 @@ +import Ably +import Foundation +@testable import AblyLiveObjects + +/// Shared fixtures, protocol-message builders, and canonical constants for the LiveObjects UTS +/// tests. Derived from `uts/objects/helpers/standard_test_pool.md`. +/// +/// The pool objects are built directly as internal ``ProtocolTypes/InboundObjectMessage`` values +/// (reachable via `@testable`) and delivered through the mock as an `OBJECT_SYNC` protocol message; +/// this mirrors what ably-cocoa's wire decoder produces (see ``ProtocolMessage``). +enum StandardTestPool { + // MARK: - Canonical Constants + + /// The harness `ConnectionDetails.siteCode`. + static let siteCode = "test-site" + + /// The timeserial every standard-pool entry and object is seeded with. + static let poolSerial = "t:0" + + /// The serial the harness assigns to a locally-published operation applied on its ACK; sorts + /// after ``poolSerial``. `ackSerial(0, 0) == "t:1:0"`. + static func ackSerial(_ msgSerial: Int, _ index: Int) -> String { + "t:\(msgSerial + 1):\(index)" + } + + /// A REMOTE inbound "winning" serial on an existing pool entry; sorts after ``poolSerial``. + /// `remoteSerial(0) == "t:1"`. + static func remoteSerial(_ index: Int) -> String { + "t:\(index + 1)" + } + + /// A serial that is not an ack-serial (escapes RTO9a3 dedup) yet sorts below the first + /// ack-serial, while still after ``poolSerial``. `belowAckSerial(9) == "t:0:9"`. + static func belowAckSerial(_ index: Int) -> String { + "t:0:\(index)" + } + + // MARK: - Standard Pool Objects + + /// The fixed LiveObjects tree used across test files, as `OBJECT_SYNC` state. See the tree + /// diagram in `standard_test_pool.md`. + static var objects: [ProtocolTypes.InboundObjectMessage] { + [ + objectStateMessage( + objectId: "root", + map: objectsMap( + semantics: .lww, + entries: mapEntries([ + "name": data(string: "Alice"), + "age": data(number: 30), + "active": data(boolean: true), + "score": data(objectId: "counter:score@1000"), + "profile": data(objectId: "map:profile@1000"), + "data": data(json: ["tags": ["a", "b"]]), + "avatar": data(bytes: Data([1, 2, 3])), + ]), + ), + createOp: mapCreateOp(objectId: "root"), + ), + objectStateMessage( + objectId: "counter:score@1000", + // count = 0 post-create; createOp carries the initial 100 -> materialises to 100. + counter: WireObjectsCounter(count: NSNumber(value: 0)), + createOp: counterCreateOp(objectId: "counter:score@1000", count: 100), + ), + objectStateMessage( + objectId: "map:profile@1000", + map: objectsMap( + semantics: .lww, + entries: mapEntries([ + "email": data(string: "alice@example.com"), + "nested_counter": data(objectId: "counter:nested@1000"), + "prefs": data(objectId: "map:prefs@1000"), + ]), + ), + createOp: mapCreateOp(objectId: "map:profile@1000"), + ), + objectStateMessage( + objectId: "counter:nested@1000", + counter: WireObjectsCounter(count: NSNumber(value: 0)), + createOp: counterCreateOp(objectId: "counter:nested@1000", count: 5), + ), + objectStateMessage( + objectId: "map:prefs@1000", + map: objectsMap( + semantics: .lww, + entries: mapEntries([ + "theme": data(string: "dark"), + ]), + ), + createOp: mapCreateOp(objectId: "map:prefs@1000"), + ), + ] + } + + // MARK: - ObjectData builders + + static func data(objectId: String? = nil, boolean: Bool? = nil, bytes: Data? = nil, number: Double? = nil, string: String? = nil, json: JSONObjectOrArray? = nil) -> ProtocolTypes.ObjectData { + ProtocolTypes.ObjectData( + objectId: objectId, + boolean: boolean, + bytes: bytes, + number: number.map { NSNumber(value: $0) }, + string: string, + json: json, + ) + } + + // MARK: - ObjectState builders + + /// Builds a map of ``ProtocolTypes/ObjectsMapEntry`` from key -> data, all seeded with + /// ``poolSerial`` and `tombstone: false`. + static func mapEntries(_ entries: [String: ProtocolTypes.ObjectData]) -> [String: ProtocolTypes.ObjectsMapEntry] { + entries.mapValues { data in + ProtocolTypes.ObjectsMapEntry(tombstone: false, timeserial: poolSerial, data: data, serialTimestamp: nil) + } + } + + /// Builds an ``ProtocolTypes/ObjectsMap`` — the `{ semantics, entries }` object of the spec's + /// `build_object_state`. `clearTimeserial` is omitted unless the spec shows one. + static func objectsMap( + semantics: ProtocolTypes.ObjectsMapSemantics, + entries: [String: ProtocolTypes.ObjectsMapEntry], + clearTimeserial: String? = nil, + ) -> ProtocolTypes.ObjectsMap { + ProtocolTypes.ObjectsMap(semantics: .known(semantics), entries: entries, clearTimeserial: clearTimeserial) + } + + /// A MAP_CREATE createOp with empty entries (RTLM16 no-op merge; entries already present in map). + static func mapCreateOp(objectId: String) -> ProtocolTypes.ObjectOperation { + ProtocolTypes.ObjectOperation( + action: .known(.mapCreate), + objectId: objectId, + mapCreate: ProtocolTypes.MapCreate(semantics: .known(.lww), entries: [:]), + ) + } + + /// A COUNTER_CREATE createOp carrying the initial `count`. + static func counterCreateOp(objectId: String, count: Double) -> ProtocolTypes.ObjectOperation { + ProtocolTypes.ObjectOperation( + action: .known(.counterCreate), + objectId: objectId, + counterCreate: WireCounterCreate(count: NSNumber(value: count)), + ) + } + + /// Builds an inbound `ObjectMessage` carrying an ``ProtocolTypes/ObjectState`` (UTS + /// `build_object_state` composed with `build_object_message_with_state`). + static func objectStateMessage( + objectId: String, + siteTimeserials: [String: String] = ["aaa": poolSerial], + tombstone: Bool = false, + map: ProtocolTypes.ObjectsMap? = nil, + counter: WireObjectsCounter? = nil, + createOp: ProtocolTypes.ObjectOperation? = nil, + ) -> ProtocolTypes.InboundObjectMessage { + ProtocolTypes.InboundObjectMessage(object: ProtocolTypes.ObjectState( + objectId: objectId, + siteTimeserials: siteTimeserials, + tombstone: tombstone, + createOp: createOp, + map: map, + counter: counter, + )) + } + + // MARK: - Operation ObjectMessage builders + + static func counterInc(objectId: String, number: Double, serial: String, siteCode: String) -> ProtocolTypes.InboundObjectMessage { + operationMessage(serial: serial, siteCode: siteCode, operation: ProtocolTypes.ObjectOperation( + action: .known(.counterInc), + objectId: objectId, + counterInc: WireCounterInc(number: NSNumber(value: number)), + )) + } + + static func mapSet(objectId: String, key: String, value: ProtocolTypes.ObjectData, serial: String, siteCode: String) -> ProtocolTypes.InboundObjectMessage { + operationMessage(serial: serial, siteCode: siteCode, operation: ProtocolTypes.ObjectOperation( + action: .known(.mapSet), + objectId: objectId, + mapSet: ProtocolTypes.MapSet(key: key, value: value), + )) + } + + static func mapRemove(objectId: String, key: String, serial: String, siteCode: String, serialTimestamp: Date? = nil) -> ProtocolTypes.InboundObjectMessage { + operationMessage(serial: serial, siteCode: siteCode, serialTimestamp: serialTimestamp, operation: ProtocolTypes.ObjectOperation( + action: .known(.mapRemove), + objectId: objectId, + mapRemove: WireMapRemove(key: key), + )) + } + + static func mapClear(objectId: String, serial: String, siteCode: String) -> ProtocolTypes.InboundObjectMessage { + operationMessage(serial: serial, siteCode: siteCode, operation: ProtocolTypes.ObjectOperation( + action: .known(.mapClear), + objectId: objectId, + mapClear: WireMapClear(), + )) + } + + static func objectDelete(objectId: String, serial: String, siteCode: String, serialTimestamp: Date? = nil) -> ProtocolTypes.InboundObjectMessage { + operationMessage(serial: serial, siteCode: siteCode, serialTimestamp: serialTimestamp, operation: ProtocolTypes.ObjectOperation( + action: .known(.objectDelete), + objectId: objectId, + objectDelete: WireObjectDelete(), + )) + } + + private static func operationMessage(serial: String, siteCode: String, serialTimestamp: Date? = nil, operation: ProtocolTypes.ObjectOperation) -> ProtocolTypes.InboundObjectMessage { + ProtocolTypes.InboundObjectMessage( + operation: operation, + serial: serial, + siteCode: siteCode, + serialTimestamp: serialTimestamp, + ) + } +} diff --git a/Tests/UTS/Helpers/UTSTestCase+LiveObjects.swift b/Tests/UTS/Helpers/UTSTestCase+LiveObjects.swift new file mode 100644 index 00000000..3b60ac7a --- /dev/null +++ b/Tests/UTS/Helpers/UTSTestCase+LiveObjects.swift @@ -0,0 +1,130 @@ +import Ably +import Ably.Private +import Foundation +import Testing +@testable import AblyLiveObjects + +/// LiveObjects additions to the UTS harness: the `setup_synced_channel` pattern from +/// `helpers/standard_test_pool.md`. +extension UTSTestCase { + /// Builds a connected client and an objects channel, but does **not** call `get()` — for tests + /// that drive `get()` themselves or exercise attach/sync timing. The simulated server responds to + /// `ATTACH` with `ATTACHED` (`HAS_OBJECTS`) and, when `sync` is `true`, an `OBJECT_SYNC` carrying + /// ``StandardTestPool/objects``; when `autoAck` is `true`, it auto-`ACK`s `OBJECT` publishes. + /// + /// > Note: the granted channel modes carried by `ATTACHED` aren't modelled (the mock always sends + /// > the standard `HAS_OBJECTS` attach); mode-enforcement tests still request the appropriate + /// > `modes` on the channel, and trap at the unimplemented API before the granted-mode check runs. + func objectsChannel( + _ channelName: String, + modes: ARTChannelMode = [.objectSubscribe, .objectPublish], + echoMessages: Bool = true, + siteCode: String? = StandardTestPool.siteCode, + gcGracePeriod: Double? = 86_400_000, + sync: Bool = true, + autoAck: Bool = true, + ) -> (client: ARTRealtime, channel: ARTRealtimeChannel, ws: MockWebSocketProvider) { + let connections = Captured() + let provider = MockWebSocketProvider( + onConnectionAttempt: { conn in + connections.append(conn) + conn.respondWithSuccess() + conn.sendToClient(.connected( + connectionId: "conn-1", + connectionKey: "conn-key-1", + siteCode: siteCode, + objectsGCGracePeriod: gcGracePeriod, + )) + }, + onMessageFromClient: { msg in + guard let conn = connections.all.last else { return } + switch msg.action { + case .attach: + let channel = msg.channel ?? channelName + conn.sendToClient(.attached(channel: channel, channelSerial: "sync1:", hasObjects: true)) + if sync { + conn.sendToClient(.objectSync(channel: channel, channelSerial: "sync1:", state: StandardTestPool.objects)) + } + case .object: + guard autoAck else { break } + // `state` isn't surfaced to Swift (see ProtocolMessage), so read it via KVC. + let stateCount = (msg.value(forKey: "state") as? [Any])?.count ?? 0 + let msgSerial = msg.msgSerial?.intValue ?? 0 + let serials: [String?] = (0 ..< stateCount).map { StandardTestPool.ackSerial(msgSerial, $0) } + conn.sendToClient(.ack(msgSerial: msgSerial, count: 1, serials: serials)) + default: + break + } + }, + ) + installMock(provider) + + let client = makeRealtime { options in + options.key = "fake:key" + options.echoMessages = echoMessages + options.plugins = [.liveObjects: AblyLiveObjects.Plugin.self] + } + + let channelOptions = ARTRealtimeChannelOptions() + channelOptions.modes = modes + let channel = client.channels.get(channelName, options: channelOptions) + return (client: client, channel: channel, ws: provider) + } + + /// Creates a connected client with a synced channel containing the standard test pool (UTS + /// `setup_synced_channel`), resolving `root` via `channel.object.get()`. + func setupSyncedChannel(_ channelName: String, autoAck: Bool = true, sourceLocation _: SourceLocation = #_sourceLocation) async throws -> (client: ARTRealtime, channel: ARTRealtimeChannel, root: any LiveMapPathObject, ws: MockWebSocketProvider) { + let objects = objectsChannel(channelName, autoAck: autoAck) + let root = try await objects.channel.object.get() + return (client: objects.client, channel: objects.channel, root: root, ws: objects.ws) + } + + /// The `operation` of every `OBJECT` message the client has sent, decoded into the internal wire + /// type ``WireObjectOperation`` (UTS `captured.flatMap(c => c.state).map(op => op.operation)`). + /// + /// The captured frames are decoded into the *wire* representation rather than the + /// `ProtocolTypes` domain type on purpose: the outbound-only `counterCreateWithObjectId` / + /// `mapCreateWithObjectId` fields that the create tests assert on are retained by + /// ``WireObjectOperation`` but nil'd out by the `ProtocolTypes.ObjectOperation` inbound + /// conversion. + func sentObjectOperations(_ ws: MockWebSocketProvider) throws -> [WireObjectOperation] { + guard let connection = ws.activeConnection else { return [] } + var operations: [WireObjectOperation] = [] + for frame in connection.sentFrames { + guard let root = try? JSONSerialization.jsonObject(with: frame) as? [String: Any], + (root["action"] as? Int) == WireAction.object, + let state = root["state"] as? [[String: Any]] else { continue } + for objectMessage in state { + guard let operationData = objectMessage["operation"] as? [String: Any] else { continue } + // Decode the operation directly into its wire type. We don't route through + // `InboundWireObjectMessage`: these are messages the client *sent*, so the inbound + // decoder (and its `DecodingContext`, which drives inbound synthetic-ID rules) would + // be the wrong direction. `WireObjectOperation` is `WireObjectCodable`, and its wire + // form is direction-agnostic and retains the outbound-only `*WithObjectId` fields. + let wireOperation = WireValue.objectFromPluginSupportData(operationData) + operations.append(try WireObjectOperation(wireObject: wireOperation)) + } + } + return operations + } + + /// Delivers an `OBJECT` message carrying `state` to the client through the mock (UTS + /// `mock_ws.send_to_client(build_object_message(channel, state))`). `state` is the decoded inbound + /// object-message form produced by the ``StandardTestPool`` builders. The channel defaults to + /// `"test"` (the name every UTS setup uses). + func sendToClient(_ ws: MockWebSocketProvider, channel: String = "test", _ state: [ProtocolTypes.InboundObjectMessage]) { + ws.activeConnection?.sendToClient(.object(channel: channel, state: state)) + } +} + +/// Wire-level `ProtocolMessage.action` codes (`ARTProtocolMessageAction`). +enum WireAction { + static let object = 19 + static let objectSync = 20 +} + +/// Wire-level map semantics code (`ObjectsMapSemantics`, spec OMP2). Used to assert on the +/// `initialValue` JSON string, which encodes semantics as its raw integer. +enum WireMapSemantics { + static let lww = 0 +} diff --git a/Tests/UTS/Tests/Internal/PublicObjectMessageTests.swift b/Tests/UTS/Tests/Internal/PublicObjectMessageTests.swift new file mode 100644 index 00000000..1c4b65e4 --- /dev/null +++ b/Tests/UTS/Tests/Internal/PublicObjectMessageTests.swift @@ -0,0 +1,311 @@ +import Ably +import Foundation +import Testing +@testable import AblyLiveObjects + +/// Construction of the user-facing `ObjectMessage` / `ObjectOperation` from their internal +/// (`ProtocolTypes`) counterparts (`PAOM3`, `PAOOP3`). +/// Derived from https://github.com/ably/specification/blob/0a531c79adfc072c6d1441591f2dd838913dfe73/uts/objects/unit/public_object_message.md +/// +/// The spec's `PublicObjectMessage.fromObjectMessage(source, channel)` / +/// `PublicObjectOperation.fromObjectOperation(op)` map to +/// ``ObjectMessage/fromInternalObjectMessage(_:channelName:)`` / ``ObjectOperation/fromInternalObjectOperation(_:)`` +/// (the SDK's public value types are named `ObjectMessage` / `ObjectOperation`; the `channel` object +/// is represented by its name). The conversion is a skeleton in this target, so every case traps via +/// `notImplemented()` at runtime; the goal here is a faithful translation that compiles and will pass +/// once the conversion is implemented. +@Suite(.serialized) +struct PublicObjectMessageTests { + // MARK: - PAOM3 — ObjectMessage construction + + // UTS: objects/unit/PAOM3/construction-all-fields-0 + @Test + func test_PAOM3_construction_copies_all_fields() { + let source = ProtocolTypes.InboundObjectMessage( + id: "msg-id-1", + clientId: "client-1", + connectionId: "conn-1", + extras: ["key": "value"], + timestamp: Date(timeIntervalSince1970: 1_700_000_000), + operation: ProtocolTypes.ObjectOperation( + action: .known(.mapSet), + objectId: "map:abc@1000", + mapSet: ProtocolTypes.MapSet(key: "name", value: StandardTestPool.data(string: "Alice")), + ), + serial: "01", + siteCode: "site1", + serialTimestamp: Date(timeIntervalSince1970: 1_700_000_001), + ) + + let publicMsg = ObjectMessage.fromInternalObjectMessage(source, channelName: "test-channel") + + #expect(publicMsg.id == "msg-id-1") + #expect(publicMsg.clientId == "client-1") + #expect(publicMsg.connectionId == "conn-1") + #expect(publicMsg.timestamp == Date(timeIntervalSince1970: 1_700_000_000)) + #expect(publicMsg.channel == "test-channel") + #expect(publicMsg.serial == "01") + #expect(publicMsg.serialTimestamp == Date(timeIntervalSince1970: 1_700_000_001)) + #expect(publicMsg.siteCode == "site1") + #expect(publicMsg.extras == ["key": "value"]) + #expect(publicMsg.operation.action == .mapSet) + #expect(publicMsg.operation.objectId == "map:abc@1000") + #expect(publicMsg.operation.mapSet?.key == "name") + } + + // UTS: objects/unit/PAOM3/construction-optional-fields-missing-0 + @Test + func test_PAOM3_construction_with_optional_fields_missing() { + let source = ProtocolTypes.InboundObjectMessage( + operation: ProtocolTypes.ObjectOperation( + action: .known(.counterInc), + objectId: "counter:abc@1000", + counterInc: WireCounterInc(number: NSNumber(value: 5)), + ), + ) + + let publicMsg = ObjectMessage.fromInternalObjectMessage(source, channelName: "my-channel") + + #expect(publicMsg.id == nil) + #expect(publicMsg.clientId == nil) + #expect(publicMsg.connectionId == nil) + #expect(publicMsg.timestamp == nil) + #expect(publicMsg.channel == "my-channel") + #expect(publicMsg.serial == nil) + #expect(publicMsg.serialTimestamp == nil) + #expect(publicMsg.siteCode == nil) + #expect(publicMsg.extras == nil) + #expect(publicMsg.operation.action == .counterInc) + } + + // UTS: objects/unit/PAOM3/channel-from-channel-name-0 + @Test + func test_PAOM3_channel_is_set_from_channel_name() { + let source = ProtocolTypes.InboundObjectMessage( + operation: ProtocolTypes.ObjectOperation(action: .known(.objectDelete), objectId: "counter:abc@1000", objectDelete: WireObjectDelete()), + ) + + let publicMsg = ObjectMessage.fromInternalObjectMessage(source, channelName: "different-channel-name") + + #expect(publicMsg.channel == "different-channel-name") + } + + // MARK: - PAOOP3 — ObjectOperation construction (direct copies) + + // UTS: objects/unit/PAOOP3/map-set-copies-fields-0 + @Test + func test_PAOOP3_map_set_copies_fields_omits_others() { + let source = ProtocolTypes.ObjectOperation( + action: .known(.mapSet), + objectId: "map:abc@1000", + mapSet: ProtocolTypes.MapSet(key: "color", value: StandardTestPool.data(string: "blue")), + ) + + let publicOp = ObjectOperation.fromInternalObjectOperation(source) + + #expect(publicOp.action == .mapSet) + #expect(publicOp.objectId == "map:abc@1000") + #expect(publicOp.mapSet?.key == "color") + #expect(publicOp.mapSet?.value.string == "blue") + #expect(publicOp.mapCreate == nil) + #expect(publicOp.mapRemove == nil) + #expect(publicOp.counterCreate == nil) + #expect(publicOp.counterInc == nil) + #expect(publicOp.objectDelete == nil) + #expect(publicOp.mapClear == nil) + } + + // UTS: objects/unit/PAOOP3/map-remove-copies-fields-0 + @Test + func test_PAOOP3_map_remove_copies_fields_omits_others() { + let source = ProtocolTypes.ObjectOperation( + action: .known(.mapRemove), + objectId: "map:abc@1000", + mapRemove: WireMapRemove(key: "old-key"), + ) + + let publicOp = ObjectOperation.fromInternalObjectOperation(source) + + #expect(publicOp.action == .mapRemove) + #expect(publicOp.objectId == "map:abc@1000") + #expect(publicOp.mapRemove?.key == "old-key") + #expect(publicOp.mapCreate == nil) + #expect(publicOp.mapSet == nil) + #expect(publicOp.counterCreate == nil) + #expect(publicOp.counterInc == nil) + #expect(publicOp.objectDelete == nil) + #expect(publicOp.mapClear == nil) + } + + // UTS: objects/unit/PAOOP3/counter-inc-copies-fields-0 + @Test + func test_PAOOP3_counter_inc_copies_fields_omits_others() { + let source = ProtocolTypes.ObjectOperation( + action: .known(.counterInc), + objectId: "counter:abc@1000", + counterInc: WireCounterInc(number: NSNumber(value: 42)), + ) + + let publicOp = ObjectOperation.fromInternalObjectOperation(source) + + #expect(publicOp.action == .counterInc) + #expect(publicOp.objectId == "counter:abc@1000") + #expect(publicOp.counterInc?.number == 42) + #expect(publicOp.mapCreate == nil) + #expect(publicOp.mapSet == nil) + #expect(publicOp.mapRemove == nil) + #expect(publicOp.counterCreate == nil) + #expect(publicOp.objectDelete == nil) + #expect(publicOp.mapClear == nil) + } + + // UTS: objects/unit/PAOOP3/object-delete-copies-fields-0 + @Test + func test_PAOOP3_object_delete_copies_fields_omits_others() { + let source = ProtocolTypes.ObjectOperation( + action: .known(.objectDelete), + objectId: "counter:abc@1000", + objectDelete: WireObjectDelete(), + ) + + let publicOp = ObjectOperation.fromInternalObjectOperation(source) + + #expect(publicOp.action == .objectDelete) + #expect(publicOp.objectId == "counter:abc@1000") + #expect(publicOp.objectDelete != nil) + #expect(publicOp.mapCreate == nil) + #expect(publicOp.mapSet == nil) + #expect(publicOp.mapRemove == nil) + #expect(publicOp.counterCreate == nil) + #expect(publicOp.counterInc == nil) + #expect(publicOp.mapClear == nil) + } + + // UTS: objects/unit/PAOOP3/map-clear-copies-fields-0 + @Test + func test_PAOOP3_map_clear_copies_fields_omits_others() { + let source = ProtocolTypes.ObjectOperation( + action: .known(.mapClear), + objectId: "map:abc@1000", + mapClear: WireMapClear(), + ) + + let publicOp = ObjectOperation.fromInternalObjectOperation(source) + + #expect(publicOp.action == .mapClear) + #expect(publicOp.objectId == "map:abc@1000") + #expect(publicOp.mapClear != nil) + #expect(publicOp.mapCreate == nil) + #expect(publicOp.mapSet == nil) + #expect(publicOp.mapRemove == nil) + #expect(publicOp.counterCreate == nil) + #expect(publicOp.counterInc == nil) + #expect(publicOp.objectDelete == nil) + } + + // MARK: - PAOOP3b / PAOOP3c — create-payload resolution + + // UTS: objects/unit/PAOOP3/map-create-direct-0 + @Test + func test_PAOOP3b1_map_create_direct() { + let source = ProtocolTypes.ObjectOperation( + action: .known(.mapCreate), + objectId: "map:new@2000", + mapCreate: ProtocolTypes.MapCreate( + semantics: .known(.lww), + entries: ["key1": ProtocolTypes.ObjectsMapEntry(data: StandardTestPool.data(string: "val1"))], + ), + ) + + let publicOp = ObjectOperation.fromInternalObjectOperation(source) + + #expect(publicOp.action == .mapCreate) + #expect(publicOp.objectId == "map:new@2000") + #expect(publicOp.mapCreate?.semantics == .lww) + #expect(publicOp.mapCreate?.entries["key1"]?.data?.string == "val1") + #expect(publicOp.counterCreate == nil) + } + + // UTS: objects/unit/PAOOP3/map-create-from-with-object-id-0 + @Test + func test_PAOOP3b2_map_create_resolved_from_withObjectId() { + let source = ProtocolTypes.ObjectOperation( + action: .known(.mapCreate), + objectId: "map:derived@3000", + mapCreateWithObjectId: ProtocolTypes.MapCreateWithObjectId( + initialValue: #"{"map":{"semantics":0,"entries":{}}}"#, + nonce: "nonce-1", + derivedFrom: ProtocolTypes.MapCreate( + semantics: .known(.lww), + entries: ["x": ProtocolTypes.ObjectsMapEntry(data: StandardTestPool.data(number: 10))], + ), + ), + ) + + let publicOp = ObjectOperation.fromInternalObjectOperation(source) + + #expect(publicOp.action == .mapCreate) + #expect(publicOp.objectId == "map:derived@3000") + #expect(publicOp.mapCreate?.semantics == .lww) + #expect(publicOp.mapCreate?.entries["x"]?.data?.number == 10) + #expect(publicOp.counterCreate == nil) + } + + // UTS: objects/unit/PAOOP3/counter-create-from-with-object-id-0 + @Test + func test_PAOOP3c2_counter_create_resolved_from_withObjectId() { + let source = ProtocolTypes.ObjectOperation( + action: .known(.counterCreate), + objectId: "counter:derived@3000", + counterCreateWithObjectId: ProtocolTypes.CounterCreateWithObjectId( + initialValue: #"{"counter":{"count":100}}"#, + nonce: "nonce-2", + derivedFrom: WireCounterCreate(count: NSNumber(value: 100)), + ), + ) + + let publicOp = ObjectOperation.fromInternalObjectOperation(source) + + #expect(publicOp.action == .counterCreate) + #expect(publicOp.objectId == "counter:derived@3000") + #expect(publicOp.counterCreate?.count == 100) + #expect(publicOp.mapCreate == nil) + } + + // UTS: objects/unit/PAOOP3/create-payloads-omitted-0 + @Test + func test_PAOOP3b3_c3_create_payloads_omitted_when_absent() { + let source = ProtocolTypes.ObjectOperation( + action: .known(.mapSet), + objectId: "map:abc@1000", + mapSet: ProtocolTypes.MapSet(key: "k", value: StandardTestPool.data(string: "v")), + ) + + let publicOp = ObjectOperation.fromInternalObjectOperation(source) + + #expect(publicOp.mapCreate == nil) + #expect(publicOp.counterCreate == nil) + } + + // UTS: objects/unit/PAOOP3/only-relevant-field-per-action-0 + @Test + func test_PAOOP3_only_relevant_field_present_per_action() { + let source = ProtocolTypes.ObjectOperation( + action: .known(.counterCreate), + objectId: "counter:new@2000", + counterCreate: WireCounterCreate(count: NSNumber(value: 50)), + ) + + let publicOp = ObjectOperation.fromInternalObjectOperation(source) + + #expect(publicOp.action == .counterCreate) + #expect(publicOp.objectId == "counter:new@2000") + #expect(publicOp.counterCreate?.count == 50) + #expect(publicOp.mapCreate == nil) + #expect(publicOp.mapSet == nil) + #expect(publicOp.mapRemove == nil) + #expect(publicOp.counterInc == nil) + #expect(publicOp.objectDelete == nil) + #expect(publicOp.mapClear == nil) + } +} diff --git a/Tests/UTS/Tests/Path-Based/InstanceTests.swift b/Tests/UTS/Tests/Path-Based/InstanceTests.swift new file mode 100644 index 00000000..4f8d9a47 --- /dev/null +++ b/Tests/UTS/Tests/Path-Based/InstanceTests.swift @@ -0,0 +1,267 @@ +import Ably +import Foundation +import Testing +@testable import AblyLiveObjects + +/// Instance — identity-bound references (`RTINS1`–`RTINS16`). +/// Derived from https://github.com/ably/specification/blob/0a531c79adfc072c6d1441591f2dd838913dfe73/uts/objects/unit/instance.md +/// +/// Swift models `Instance` as an enum (`.liveMap`/`.liveCounter`/`.primitive`) whose payloads carry +/// the type-specific members (`id`, `value`, `get`, `set`, `subscribe`, …). The spec's loosely-typed +/// `instance.value()` / `instance.get()` therefore map to extracting the payload via the +/// `asLiveMap()` / `asLiveCounter()` / `asPrimitive()`. +@Suite(.serialized) +final class InstanceTests: UTSTestCase { + // MARK: - RTINS3 — id + + // UTS: objects/unit/RTINS3/id-returns-objectid-0 + @Test + func test_RTINS3_id_returns_objectId() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + + #expect(try root.get(key: "score").instance()?.id() == "counter:score@1000") // RTINS3a + #expect(try root.get(key: "profile").instance()?.id() == "map:profile@1000") + } + + // MARK: - RTINS4 — value + + // UTS: objects/unit/RTINS4/value-counter-0 + @Test + func test_RTINS4_value_returns_counter_number_or_null_for_map() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + + #expect(try root.get(key: "score").instance()?.asLiveCounter().value == 100) // RTINS4b + // RTINS4d: a map has no numeric value. Swift exposes no `value` on `LiveMapInstance`, so + // "value == null" is represented as the instance being a map. + #expect(try root.instance()?.type == .liveMap) + } + + // MARK: - RTINS5 — get + + // UTS: objects/unit/RTINS5/get-wraps-entry-0 + @Test + func test_RTINS5_get_returns_Instance_wrapping_entry_value() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + let rootInstance = try root.instance()?.asLiveMap() + + let nameInstance = try rootInstance?.get(key: "name") + #expect(try nameInstance?.asPrimitive().stringValue == "Alice") // RTINS5c + + let scoreInstance = try rootInstance?.get(key: "score") + #expect(scoreInstance?.id() == "counter:score@1000") + + #expect(try rootInstance?.get(key: "nonexistent") == nil) + } + + // MARK: - RTINS6 — entries + + // UTS: objects/unit/RTINS6/entries-yields-instances-0 + @Test + func test_RTINS6_entries_returns_array_of_key_instance_pairs() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + let rootInstance = try root.instance()?.asLiveMap() + + var entries: [String: Instance] = [:] + for (key, instance) in try #require(try rootInstance?.entries()) { + entries[key] = instance + } + #expect(entries.count == 7) // RTINS6b + #expect(try entries["name"]?.asPrimitive().stringValue == "Alice") + } + + // MARK: - RTINS9 — size + + // UTS: objects/unit/RTINS9/size-0 + @Test + func test_RTINS9_size_returns_non_tombstoned_count() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + + #expect(try root.instance()?.asLiveMap().size == 7) // RTINS9b + // RTINS9c: a counter has no size. Swift exposes no `size` on `LiveCounterInstance`, so + // "size == null" is represented as the instance being a counter. + #expect(try root.get(key: "score").instance()?.type == .liveCounter) + } + + // MARK: - RTINS10 — compact + + // UTS: objects/unit/RTINS10/compact-0 + @Test + func test_RTINS10_compact_recursively_compacts() async throws { + // DEVIATION (RTINS10): only `compactJson()` is exposed publicly (not `compact()`), so this + // asserts on the JSON form. See deviations.md. + let (_, _, root, _) = try await setupSyncedChannel("test") + + let result = try root.instance()?.compactJson().objectValue + #expect(result?["name"]?.stringValue == "Alice") + #expect(result?["score"]?.numberValue == 100) + #expect(result?["profile"]?.objectValue?["email"]?.stringValue == "alice@example.com") + } + + // MARK: - RTINS12 — set + + // UTS: objects/unit/RTINS12/set-delegates-0 + @Test + func test_RTINS12_set_delegates_to_map() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + try await root.instance()?.asLiveMap().set(key: "name", value: "Bob") + #expect(try root.get(key: "name").asPrimitive().stringValue == "Bob") + } + + // UTS: objects/unit/RTINS12d/set-non-map-throws-0 + @Test + func test_RTINS12d_set_on_non_map_throws_92007() throws { + // DEVIATION (RTINS12d): `set` exists only on `LiveMapInstance`, so it cannot be *called* on a + // counter payload — the "throws 92007" path is compile-time-unreachable. See deviations.md. + } + + // MARK: - RTINS13 — remove + + // UTS: objects/unit/RTINS13/remove-delegates-0 + @Test + func test_RTINS13_remove_delegates_to_map() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + try await root.instance()?.asLiveMap().remove(key: "name") + #expect(try root.get(key: "name").asPrimitive().value() == nil) + } + + // MARK: - RTINS14 — increment + + // UTS: objects/unit/RTINS14/increment-delegates-0 + @Test + func test_RTINS14_increment_delegates_to_counter() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + try await root.get(key: "score").instance()?.asLiveCounter().increment(amount: 25) + #expect(try root.get(key: "score").asLiveCounter().value() == 125) + } + + // UTS: objects/unit/RTINS14a/increment-default-0 + @Test + func test_RTINS14a_increment_defaults_to_1() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + try await root.get(key: "score").instance()?.asLiveCounter().increment() + #expect(try root.get(key: "score").asLiveCounter().value() == 101) + } + + // UTS: objects/unit/RTINS14d/increment-non-counter-throws-0 + @Test + func test_RTINS14d_increment_on_non_counter_throws_92007() throws { + // DEVIATION (RTINS14d): `increment` exists only on `LiveCounterInstance`, so it cannot be + // called on a map payload — the "throws 92007" path is compile-time-unreachable. See deviations.md. + } + + // MARK: - RTINS15 — decrement + + // UTS: objects/unit/RTINS15/decrement-delegates-0 + @Test + func test_RTINS15_decrement_delegates_to_counter() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + try await root.get(key: "score").instance()?.asLiveCounter().decrement(amount: 10) + #expect(try root.get(key: "score").asLiveCounter().value() == 90) + } + + // UTS: objects/unit/RTINS15a/decrement-default-0 + @Test + func test_RTINS15a_decrement_defaults_to_1() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + try await root.get(key: "score").instance()?.asLiveCounter().decrement() + #expect(try root.get(key: "score").asLiveCounter().value() == 99) + } + + // UTS: objects/unit/RTINS15d/decrement-non-counter-throws-0 — covered by the RTINS14d deviation + // (decrement, like increment, exists only on `LiveCounterInstance`). See deviations.md. + + // MARK: - RTINS16 — subscribe + + // UTS: objects/unit/RTINS16/subscribe-receives-events-0 + @Test + func test_RTINS16_subscribe_receives_events() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let counter = try root.get(key: "score").instance()?.asLiveCounter() + let events = Captured() + let sub = try counter?.subscribe(listener: { events.append($0) }) + + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")]) + poll("events.count >= 1") { events.count >= 1 } + + _ = sub // RTINS16f + #expect(events.count == 1) + #expect(events.first?.object.id() == "counter:score@1000") // RTINS16e1 + } + + // UTS: objects/unit/RTINS16c/subscribe-primitive-throws-0 + @Test + func test_RTINS16c_subscribe_on_primitive_throws_92007() throws { + // DEVIATION (RTINS16c): `subscribe` exists only on the `LiveMapInstance` / `LiveCounterInstance` + // payloads, not `PrimitiveInstance`, so it cannot be called on a primitive — the "throws 92007" + // path is compile-time-unreachable. See deviations.md. + } + + // UTS: objects/unit/RTINS16e2/subscription-event-message-0 + @Test + func test_RTINS16e2_event_contains_public_object_message() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let rootInstance = try root.instance()?.asLiveMap() + let events = Captured() + _ = try rootInstance?.subscribe(listener: { events.append($0) }) + + sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "name", value: StandardTestPool.data(string: "Bob"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")]) + poll("events.count >= 1") { events.count >= 1 } + + let event = try #require(events.first) + #expect(event.object.id() == "root") // RTINS16e1 + let message = try #require(event.message) // RTINS16e2 + #expect(message.channel == "test") + #expect(message.operation.action == .mapSet) + #expect(message.operation.objectId == "root") + #expect(message.operation.mapSet?.key == "name") + } + + // UTS: objects/unit/RTINS16f/subscribe-returns-subscription-0 + @Test + func test_RTINS16f_unsubscribe_deregisters() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let counter = try root.get(key: "score").instance()?.asLiveCounter() + let events = Captured() + let sub = try counter?.subscribe(listener: { events.append($0) }) + sub?.unsubscribe() + + // Quiescence control: a second, still-subscribed listener on the same counter instance. + let control = Captured() + _ = try counter?.subscribe(listener: { control.append($0) }) + + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")]) + poll("control fired") { control.count >= 1 } + + #expect(events.count == 0) + } + + // UTS: objects/unit/RTINS16g/subscription-follows-identity-0 + @Test + func test_RTINS16g_subscription_follows_identity_not_path() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let counter = try root.get(key: "score").instance()?.asLiveCounter() + let events = Captured() + _ = try counter?.subscribe(listener: { events.append($0) }) + + // Repoint "score" to a new object, then mutate the ORIGINAL counter:score@1000. + sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "score", value: StandardTestPool.data(objectId: "counter:new@2000"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")]) + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 10, serial: "100", siteCode: "remote")]) + poll("events.count >= 1") { events.count >= 1 } + + #expect(events.count >= 1) + // Identity-based: the event carries the original object regardless of the "score" repoint. + #expect(events.first?.object.id() == "counter:score@1000") + } + + // UTS: objects/unit/RTINS16h/subscribe-no-side-effects-0 + @Test + func test_RTINS16h_subscribe_has_no_side_effects() async throws { + let (_, channel, root, _) = try await setupSyncedChannel("test") + let counter = try root.get(key: "score").instance()?.asLiveCounter() + let stateBefore = channel.state + + _ = try counter?.subscribe(listener: { _ in }) + + #expect(channel.state == stateBefore) + } +} diff --git a/Tests/UTS/Tests/Path-Based/LiveObjectSubscribeTests.swift b/Tests/UTS/Tests/Path-Based/LiveObjectSubscribeTests.swift new file mode 100644 index 00000000..84f897be --- /dev/null +++ b/Tests/UTS/Tests/Path-Based/LiveObjectSubscribeTests.swift @@ -0,0 +1,221 @@ +import Ably +import Foundation +import Testing +@testable import AblyLiveObjects + +/// LiveObject subscribe via `Instance#subscribe` (`RTLO4b`, `RTINS16`). +/// Derived from https://github.com/ably/specification/blob/0a531c79adfc072c6d1441591f2dd838913dfe73/uts/objects/unit/live_object_subscribe.md +/// +/// The spec's `root.get("score").instance()` maps to `root.get(key: "score").instance()`, whose +/// payload is extracted with `asLiveCounter()` / `asLiveMap()` (see ``Instance`` and +/// `Instance+Testing`). Listeners are captured with the thread-safe ``Captured`` and deliveries are +/// awaited with `poll(...)`; inbound operations are injected as `OBJECT` messages through the mock. +/// The "negative-assertion quiescence" pattern from `standard_test_pool.md` is preserved. +@Suite(.serialized) +final class LiveObjectSubscribeTests: UTSTestCase { + // MARK: - RTLO4b — subscribe registers listener + + // UTS: objects/unit/RTLO4b/subscribe-receives-updates-0 + @Test + func test_RTLO4b_subscribe_registers_listener_for_data_updates() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let updates = Captured() + let instance = try root.get(key: "score").instance()?.asLiveCounter() + let sub = try #require(instance).subscribe(listener: { updates.append($0) }) + + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")]) + poll("updates.count >= 1") { updates.count >= 1 } + + _ = sub // RTLO4b7: returns a Subscription + #expect(updates.count == 1) + } + + // MARK: - RTLO4b7 — Subscription + + // UTS: objects/unit/RTLO4b7/subscribe-returns-subscription-0 + @Test + func test_RTLO4b7_subscribe_returns_Subscription_with_unsubscribe() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + let instance = try root.get(key: "score").instance()?.asLiveCounter() + + let sub = try #require(instance).subscribe(listener: { _ in }) + + // RTLO4b7: `sub` conforms to `Subscription` (has `unsubscribe()`). + _ = (sub as any Subscription).unsubscribe + } + + // UTS: objects/unit/RTLO4b7/subscription-unsubscribe-stops-delivery-0 + @Test + func test_RTLO4b7_unsubscribe_stops_delivery() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let updates = Captured() + let control = Captured() + let instance = try #require(try root.get(key: "score").instance()?.asLiveCounter()) + let sub = try instance.subscribe(listener: { updates.append($0) }) + + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 5, serial: "01", siteCode: "remote")]) + poll("updates.count >= 1") { updates.count >= 1 } + + sub.unsubscribe() + + // Quiescence: a still-registered control listener that WILL fire on the same dispatch. + _ = try instance.subscribe(listener: { control.append($0) }) + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 10, serial: "02", siteCode: "remote")]) + poll("control fired") { control.count >= 1 } + + #expect(updates.count == 1) + } + + // UTS: objects/unit/RTLO4b7/subscription-unsubscribe-idempotent-0 + @Test + func test_RTLO4b7_unsubscribe_is_idempotent() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + let instance = try #require(try root.get(key: "score").instance()?.asLiveCounter()) + let sub = try instance.subscribe(listener: { _ in }) + + sub.unsubscribe() + sub.unsubscribe() // SUB2b: calling more than once is a no-op (must not throw) + } + + // MARK: - RTLO4b4c1 — noop update + + // UTS: objects/unit/RTLO4b4c1/noop-no-trigger-0 + @Test + func test_RTLO4b4c1_noop_update_does_not_trigger_listener() async throws { + // DEVIATION (RTLO4b4c1 / RTLC9h): the noop stimulus is a COUNTER_INC whose `counterInc` has no + // `number` field. Swift's `WireCounterInc.number` is a non-optional `NSNumber`, so an empty + // `counterInc: {}` isn't constructible — the RTLC9h noop branch can't be exercised through the + // typed builder. The test keeps the spec point and asserts that two real increments produce + // exactly two updates (the noop, were it sendable, would sit between them and add nothing). See + // deviations.md. + let (_, _, root, ws) = try await setupSyncedChannel("test") + let updates = Captured() + let instance = try #require(try root.get(key: "score").instance()?.asLiveCounter()) + try instance.subscribe(listener: { updates.append($0) }) + + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 5, serial: "01", siteCode: "remote")]) + poll("updates.count >= 1") { updates.count >= 1 } + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 3, serial: "03", siteCode: "remote")]) + poll("updates.count >= 2") { updates.count >= 2 } + + #expect(updates.count == 2) + } + + // MARK: - RTLO4b6 — no side effects + + // UTS: objects/unit/RTLO4b6/subscribe-no-side-effects-0 + @Test + func test_RTLO4b6_subscribe_has_no_side_effects() async throws { + let (_, channel, root, _) = try await setupSyncedChannel("test") + let stateBefore = channel.state + let instance = try #require(try root.get(key: "score").instance()?.asLiveCounter()) + + try instance.subscribe(listener: { _ in }) + + #expect(channel.state == stateBefore) + } + + // MARK: - RTLO4b — map update + + // UTS: objects/unit/RTLO4b/subscribe-map-update-0 + @Test + func test_RTLO4b_subscribe_on_map_receives_LiveMapUpdate() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let updates = Captured() + let instance = try #require(try root.instance()?.asLiveMap()) + try instance.subscribe(listener: { updates.append($0) }) + + sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "name", value: StandardTestPool.data(string: "Bob"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")]) + poll("updates.count >= 1") { updates.count >= 1 } + + #expect(updates.count == 1) + } + + // MARK: - RTLO4b4c3c — tombstone deregisters listeners + + // UTS: objects/unit/RTLO4b4c3c/tombstone-deregisters-listeners-0 + @Test + func test_RTLO4b4c3c_tombstone_update_deregisters_all_listeners() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let updatesA = Captured() + let updatesB = Captured() + let control = Captured() + let instance = try #require(try root.get(key: "score").instance()?.asLiveCounter()) + try instance.subscribe(listener: { updatesA.append($0) }) + try instance.subscribe(listener: { updatesB.append($0) }) + + // OBJECT_DELETE → tombstone update; both listeners receive it before deregistration. + sendToClient(ws, [StandardTestPool.objectDelete(objectId: "counter:score@1000", serial: "50", siteCode: "remote")]) + poll("updatesA fired") { updatesA.count >= 1 } + poll("updatesB fired") { updatesB.count >= 1 } + + #expect(updatesA.count == 1) + #expect(updatesA.first?.message?.operation.action == .objectDelete) + #expect(updatesB.count == 1) + #expect(updatesB.first?.message?.operation.action == .objectDelete) + + // A tombstoned object ignores further ops (RTLC7e), so the control listener must be on a + // SEPARATE live object (map:profile@1000) to serve as the quiescence barrier. + let controlInstance = try #require(try root.get(key: "profile").instance()?.asLiveMap()) + try controlInstance.subscribe(listener: { control.append($0) }) + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 3, serial: "51", siteCode: "remote")]) + sendToClient(ws, [StandardTestPool.mapSet(objectId: "map:profile@1000", key: "quiescence_probe", value: StandardTestPool.data(string: "x"), serial: "52", siteCode: "remote")]) + poll("control fired") { control.count >= 1 } + + #expect(updatesA.count == 1) + #expect(updatesB.count == 1) + } + + // MARK: - RTLO4b4d — event.message populated + + // UTS: objects/unit/RTLO4b4d/update-has-object-message-0 + @Test + func test_RTLO4b4d_event_message_populated_from_source_ObjectMessage() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let updates = Captured() + let instance = try #require(try root.get(key: "score").instance()?.asLiveCounter()) + try instance.subscribe(listener: { updates.append($0) }) + + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")]) + poll("updates.count >= 1") { updates.count >= 1 } + + #expect(updates.count == 1) + let message = try #require(updates.first?.message) // RTINS16e + #expect(message.serial == "99") + #expect(message.siteCode == "remote") + #expect(message.operation.action == .counterInc) + #expect(message.operation.objectId == "counter:score@1000") + } + + // MARK: - RTLO4b4e — tombstone identified by OBJECT_DELETE + + // UTS: objects/unit/RTLO4b4e/tombstone-flag-true-0 + @Test + func test_RTLO4b4e_tombstone_update_carries_OBJECT_DELETE_action() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let updates = Captured() + let instance = try #require(try root.get(key: "score").instance()?.asLiveCounter()) + try instance.subscribe(listener: { updates.append($0) }) + + sendToClient(ws, [StandardTestPool.objectDelete(objectId: "counter:score@1000", serial: "50", siteCode: "remote")]) + poll("updates.count >= 1") { updates.count >= 1 } + + #expect(updates.count == 1) + #expect(updates.first?.message?.operation.action == .objectDelete) + } + + // UTS: objects/unit/RTLO4b4e/tombstone-flag-false-0 + @Test + func test_RTLO4b4e_normal_update_carries_non_tombstone_action() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let updates = Captured() + let instance = try #require(try root.get(key: "score").instance()?.asLiveCounter()) + try instance.subscribe(listener: { updates.append($0) }) + + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")]) + poll("updates.count >= 1") { updates.count >= 1 } + + #expect(updates.count == 1) + #expect(updates.first?.message?.operation.action == .counterInc) + } +} diff --git a/Tests/UTS/Tests/Path-Based/PathObjectMutationsTests.swift b/Tests/UTS/Tests/Path-Based/PathObjectMutationsTests.swift new file mode 100644 index 00000000..86a00a78 --- /dev/null +++ b/Tests/UTS/Tests/Path-Based/PathObjectMutationsTests.swift @@ -0,0 +1,160 @@ +import Ably +import Foundation +import Testing +@testable import AblyLiveObjects + +/// PathObject write operations (`RTPO15`–`RTPO18`, `RTPO3c2`). +/// Derived from https://github.com/ably/specification/blob/0a531c79adfc072c6d1441591f2dd838913dfe73/uts/objects/unit/path_object_mutations.md +/// +/// Same binding conventions as `PathObjectTests` (chained navigation via `asLiveMap()`, typed +/// `value()` accessors). Mutations live on the typed refinements: `set`/`remove` on +/// ``LiveMapPathObject``, `increment`/`decrement` on ``LiveCounterPathObject``. +@Suite(.serialized) +final class PathObjectMutationsTests: UTSTestCase { + // MARK: - RTPO15 — set + + // UTS: objects/unit/RTPO15/set-delegates-to-map-0 + @Test + func test_RTPO15_set_delegates_to_map() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + + try await root.set(key: "name", value: "Bob") + #expect(try root.get(key: "name").asPrimitive().stringValue == "Bob") + } + + // UTS: objects/unit/RTPO15/set-nested-path-0 + @Test + func test_RTPO15_set_on_nested_path() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + + try await root.get(key: "profile").asLiveMap().set(key: "email", value: "bob@example.com") + #expect(try root.get(key: "profile").asLiveMap().get(key: "email").asPrimitive().stringValue == "bob@example.com") + } + + // UTS: objects/unit/RTPO15d/set-non-map-throws-0 + @Test + func test_RTPO15d_set_on_non_map_throws_92007() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + // "score" is a counter, not a map (RTPO15e). + await expectError(code: 92007) { + try await root.get(key: "score").asLiveMap().set(key: "key", value: "value") + } + } + + // MARK: - RTPO16 — remove + + // UTS: objects/unit/RTPO16/remove-delegates-to-map-0 + @Test + func test_RTPO16_remove_delegates_to_map() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + + try await root.remove(key: "name") + #expect(try root.get(key: "name").asPrimitive().value() == nil) + } + + // UTS: objects/unit/RTPO16d/remove-non-map-throws-0 + @Test + func test_RTPO16d_remove_on_non_map_throws_92007() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + await expectError(code: 92007) { + try await root.get(key: "score").asLiveMap().remove(key: "key") + } + } + + // MARK: - RTPO17 — increment + + // UTS: objects/unit/RTPO17/increment-delegates-to-counter-0 + @Test + func test_RTPO17_increment_delegates_to_counter() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + + try await root.get(key: "score").asLiveCounter().increment(amount: 25) + #expect(try root.get(key: "score").asLiveCounter().value() == 125) + } + + // UTS: objects/unit/RTPO17/increment-default-amount-0 + @Test + func test_RTPO17_increment_defaults_to_1() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + + try await root.get(key: "score").asLiveCounter().increment() // RTPO17a1: defaults to 1 + #expect(try root.get(key: "score").asLiveCounter().value() == 101) + } + + // UTS: objects/unit/RTPO17d/increment-non-counter-throws-0 + @Test + func test_RTPO17d_increment_on_non_counter_throws_92007() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + // root is a map, not a counter (RTPO17e). + await expectError(code: 92007) { + try await root.asLiveCounter().increment(amount: 5) + } + } + + // MARK: - RTPO18 — decrement + + // UTS: objects/unit/RTPO18/decrement-delegates-to-counter-0 + @Test + func test_RTPO18_decrement_delegates_to_counter() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + + try await root.get(key: "score").asLiveCounter().decrement(amount: 10) + #expect(try root.get(key: "score").asLiveCounter().value() == 90) + } + + // UTS: objects/unit/RTPO18/decrement-default-amount-0 + @Test + func test_RTPO18_decrement_defaults_to_1() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + + try await root.get(key: "score").asLiveCounter().decrement() // RTPO18a1: defaults to 1 + #expect(try root.get(key: "score").asLiveCounter().value() == 99) + } + + // UTS: objects/unit/RTPO18d/decrement-non-counter-throws-0 + @Test + func test_RTPO18d_decrement_on_non_counter_throws_92007() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + await expectError(code: 92007) { + try await root.asLiveCounter().decrement(amount: 5) + } + } + + // MARK: - RTPO3c2 — write on unresolvable path + + // UTS: objects/unit/RTPO3c2/set-unresolvable-throws-0 + @Test + func test_RTPO3c2_set_on_unresolvable_path_throws_92005() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + await expectError(code: 92005, statusCode: 400) { + try await root.get(key: "nonexistent").asLiveMap().get(key: "deep").asLiveMap().set(key: "key", value: "value") + } + } + + // UTS: objects/unit/RTPO3c2/increment-unresolvable-throws-0 + @Test + func test_RTPO3c2_increment_on_unresolvable_path_throws_92005() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + await expectError(code: 92005, statusCode: 400) { + try await root.get(key: "nonexistent").asLiveCounter().increment(amount: 5) + } + } +} + +private extension PathObjectMutationsTests { + /// Asserts `operation` throws an `ARTErrorInfo` with the given `code` (and optional `statusCode`) + /// — the UTS `AWAIT op FAILS WITH error` / `ASSERT error.code == …` pattern. + func expectError(code: Int, statusCode: Int? = nil, sourceLocation: SourceLocation = #_sourceLocation, _ operation: () async throws -> Void) async { + do { + try await operation() + Issue.record("expected operation to throw ARTErrorInfo with code \(code)", sourceLocation: sourceLocation) + } catch let error as ARTErrorInfo { + #expect(error.code == code, sourceLocation: sourceLocation) + if let statusCode { + #expect(error.statusCode == statusCode, sourceLocation: sourceLocation) + } + } catch { + Issue.record("expected ARTErrorInfo, got \(error)", sourceLocation: sourceLocation) + } + } +} diff --git a/Tests/UTS/Tests/Path-Based/PathObjectSubscribeTests.swift b/Tests/UTS/Tests/Path-Based/PathObjectSubscribeTests.swift new file mode 100644 index 00000000..d4523145 --- /dev/null +++ b/Tests/UTS/Tests/Path-Based/PathObjectSubscribeTests.swift @@ -0,0 +1,426 @@ +import Ably +import Foundation +import Testing +@testable import AblyLiveObjects + +/// PathObject subscriptions (`RTPO19`, `RTO24`, `RTO25`). +/// Derived from https://github.com/ably/specification/blob/0a531c79adfc072c6d1441591f2dd838913dfe73/uts/objects/unit/path_object_subscribe.md +/// +/// Listeners are captured with the thread-safe ``Captured`` (the callback is `@Sendable`), and +/// deliveries are awaited with `poll(...)`. Inbound operations are injected as `OBJECT` messages +/// through the mock. The "negative-assertion quiescence" pattern from `standard_test_pool.md` is +/// preserved: a control listener that *does* fire is awaited before asserting a count is unchanged. +@Suite(.serialized) +final class PathObjectSubscribeTests: UTSTestCase { + // MARK: - RTPO19 — basic subscribe / event delivery + + // UTS: objects/unit/RTPO19/subscribe-receives-events-0 + @Test + func test_RTPO19_subscribe_returns_Subscription_and_receives_events() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let events = Captured() + let sub = try root.get(key: "score").subscribe(listener: { events.append($0) }) + + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")]) + poll("events.count >= 1") { events.count >= 1 } + + _ = sub // RTPO19d: returns a Subscription + #expect(events.count == 1) + let event = try #require(events.first) + #expect(event.object.path == "score") // RTPO19e1 + let message = try #require(event.message) // RTPO19e2 + #expect(message.serial == "99") + #expect(message.siteCode == "remote") + #expect(message.operation.action == .counterInc) + #expect(message.channel == "test") + } + + // UTS: objects/unit/RTPO19b/subscribe-precondition-detached-0 + @Test + func test_RTPO19b_subscribe_precondition_detached_throws_90001() async throws { + let (_, channel, root, _) = try await setupSyncedChannel("test") + + // RTO25b: on a DETACHED (or FAILED) channel the access API throws 90001. (The mock doesn't + // model DETACH responses; this documents the precondition — the setup traps at get() first.) + channel.detach() + awaitChannelState(channel, .detached) + + expectError(code: 90001, statusCode: 400) { + _ = try root.subscribe(listener: { _ in }) + } + } + + // UTS: objects/unit/RTPO19c1a/subscribe-non-positive-depth-throws-0 + @Test + func test_RTPO19c1a_subscribe_non_positive_depth_throws_40003() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + expectError(code: 40003) { + _ = try root.subscribe(options: PathObjectSubscriptionOptions(depth: 0), listener: { _ in }) + } + } + + // UTS: objects/unit/RTPO19c1a/subscribe-negative-depth-throws-0 + @Test + func test_RTPO19c1a_subscribe_negative_depth_throws_40003() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + expectError(code: 40003) { + _ = try root.subscribe(options: PathObjectSubscriptionOptions(depth: -1), listener: { _ in }) + } + } + + // MARK: - RTPO19c1 — depth filtering + + // UTS: objects/unit/RTPO19c1/subscribe-depth-1-self-only-0 + @Test + func test_RTPO19c1_depth_1_receives_self_only() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let events = Captured() + _ = try root.subscribe(options: PathObjectSubscriptionOptions(depth: 1), listener: { events.append($0) }) + // Quiescence control: unlimited-depth root listener that covers the out-of-scope child path. + let control = Captured() + _ = try root.subscribe(listener: { control.append($0) }) + + sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "name", value: StandardTestPool.data(string: "Bob"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")]) + poll("events.count >= 1") { events.count >= 1 } + + let controlBefore = control.count + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "100", siteCode: "remote")]) + poll("control fired") { control.count > controlBefore } + + #expect(events.count == 1) // depth 1: the out-of-scope child update did not fire + } + + // UTS: objects/unit/RTPO19c1/subscribe-depth-2-children-0 + @Test + func test_RTPO19c1_depth_2_receives_self_and_children() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let events = Captured() + _ = try root.subscribe(options: PathObjectSubscriptionOptions(depth: 2), listener: { events.append($0) }) + let control = Captured() + _ = try root.subscribe(listener: { control.append($0) }) + + // Self event (root map update) — covered at depth 2. + sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "name", value: StandardTestPool.data(string: "Bob"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")]) + poll("events.count >= 1") { events.count >= 1 } + + // Child event (root["score"]) — relativeDepth 2 <= 2, covered. + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "100", siteCode: "remote")]) + poll("events.count >= 2") { events.count >= 2 } + + // Grandchild event (root["profile"]["nested_counter"]) — relativeDepth 3 > 2, NOT covered. + let controlBefore = control.count + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:nested@1000", number: 1, serial: "101", siteCode: "remote")]) + poll("control fired") { control.count > controlBefore } + + #expect(events.count == 2) + } + + // UTS: objects/unit/RTPO19c1/subscribe-unlimited-depth-0 + @Test + func test_RTPO19c1_no_depth_receives_all_descendants() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let events = Captured() + _ = try root.subscribe(listener: { events.append($0) }) + + sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "name", value: StandardTestPool.data(string: "Bob"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")]) + poll("events.count >= 1") { events.count >= 1 } + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "100", siteCode: "remote")]) + poll("events.count >= 2") { events.count >= 2 } + sendToClient(ws, [StandardTestPool.mapSet(objectId: "map:prefs@1000", key: "theme", value: StandardTestPool.data(string: "light"), serial: StandardTestPool.remoteSerial(1), siteCode: "remote")]) + poll("events.count >= 3") { events.count >= 3 } + + #expect(events.count >= 3) + } + + // MARK: - RTPO19d — unsubscribe + + // UTS: objects/unit/RTPO19d/subscribe-returns-subscription-0 + @Test + func test_RTPO19d_unsubscribe_deregisters_listener() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let events = Captured() + let sub = try root.get(key: "score").subscribe(listener: { events.append($0) }) + // Quiescence control: a separate, still-subscribed listener that WILL fire. + let control = Captured() + _ = try root.get(key: "score").subscribe(listener: { control.append($0) }) + + sub.unsubscribe() + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")]) + poll("control fired") { control.count >= 1 } + + #expect(events.count == 0) + } + + // MARK: - RTPO19e — event contents + + // UTS: objects/unit/RTPO19e1/event-path-object-correct-0 + @Test + func test_RTPO19e1_event_provides_correct_PathObject() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let events = Captured() + _ = try root.subscribe(listener: { events.append($0) }) + + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")]) + poll("events.count >= 1") { events.count >= 1 } + + let event = try #require(events.first) + #expect(event.object.path == "score") + #expect(try event.object.asLiveCounter().value() == 107) + } + + // UTS: objects/unit/RTPO19e2/event-message-delivery-0 + @Test + func test_RTPO19e2_event_delivers_public_object_message() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let events = Captured() + _ = try root.get(key: "score").subscribe(listener: { events.append($0) }) + + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 42, serial: "serial-1", siteCode: "site-a")]) + poll("events.count >= 1") { events.count >= 1 } + + let message = try #require(events.first?.message) + #expect(message.channel == "test") + #expect(message.serial == "serial-1") + #expect(message.siteCode == "site-a") + #expect(message.operation.action == .counterInc) + #expect(message.operation.objectId == "counter:score@1000") + #expect(message.operation.counterInc?.number == 42) + } + + // UTS: objects/unit/RTPO19e2/event-message-omitted-no-operation-0 + @Test + func test_RTPO19e2_event_omits_message_when_no_operation() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let events = Captured() + _ = try root.subscribe(listener: { events.append($0) }) + + // A sync-triggered update (replaceData, RTLC6) has no `operation`, so its event has no message. + ws.activeConnection?.sendToClient(.objectSync(channel: "test", channelSerial: "sync2:", state: [ + StandardTestPool.objectStateMessage( + objectId: "counter:score@1000", + siteTimeserials: ["aaa": "t:1"], + counter: WireObjectsCounter(count: NSNumber(value: 0)), + createOp: StandardTestPool.counterCreateOp(objectId: "counter:score@1000", count: 200), + ), + ])) + poll("events.count >= 1") { events.count >= 1 } + + for event in events.all { + #expect(event.message == nil) + } + } + + // MARK: - RTPO19f — follows path not identity + + // UTS: objects/unit/RTPO19f/subscribe-follows-path-0 + @Test + func test_RTPO19f_subscribe_follows_path_not_identity() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let events = Captured() + _ = try root.get(key: "score").subscribe(listener: { events.append($0) }) + + // Replace the counter at "score" with a new object, then mutate the new one. + sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "score", value: StandardTestPool.data(objectId: "counter:new@2000"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")]) + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:new@2000", number: 10, serial: "100", siteCode: "remote")]) + poll("events.count >= 1") { events.count >= 1 } + + // Subscription follows the path, so it delivers events for the new object at "score". + #expect(events.all.contains { $0.object.path == "score" }) + } + + // MARK: - RTPO19g — no side effects + + // UTS: objects/unit/RTPO19g/subscribe-no-side-effects-0 + @Test + func test_RTPO19g_subscribe_has_no_side_effects() async throws { + let (_, channel, root, _) = try await setupSyncedChannel("test") + let stateBefore = channel.state + + _ = try root.get(key: "score").subscribe(listener: { _ in }) + + #expect(channel.state == stateBefore) + } + + // MARK: - RTPO19 — primitive path, MAP_CLEAR, bubbling + + // UTS: objects/unit/RTPO19/subscribe-primitive-path-0 + @Test + func test_RTPO19_subscribe_on_primitive_path_receives_change_events() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let events = Captured() + _ = try root.get(key: "name").subscribe(listener: { events.append($0) }) + + sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "name", value: StandardTestPool.data(string: "Bob"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")]) + poll("events.count >= 1") { events.count >= 1 } + + #expect(events.count == 1) + #expect(events.first?.object.path == "name") + } + + // UTS: objects/unit/RTPO19/map-clear-triggers-child-events-0 + @Test + func test_RTPO19_map_clear_triggers_child_events() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let events = Captured() + _ = try root.subscribe(listener: { events.append($0) }) + + sendToClient(ws, [StandardTestPool.mapClear(objectId: "root", serial: "99", siteCode: "remote")]) + poll("events.count >= 1") { events.count >= 1 } + + #expect(events.count >= 1) + } + + // UTS: objects/unit/RTPO19/child-events-bubble-0 + @Test + func test_RTPO19_child_events_bubble_up_to_parent() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let events = Captured() + _ = try root.get(key: "profile").subscribe(listener: { events.append($0) }) + + sendToClient(ws, [StandardTestPool.mapSet(objectId: "map:profile@1000", key: "email", value: StandardTestPool.data(string: "bob@example.com"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")]) + poll("events.count >= 1") { events.count >= 1 } + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:nested@1000", number: 3, serial: "100", siteCode: "remote")]) + poll("events.count >= 2") { events.count >= 2 } + + #expect(events.count >= 2) + } + + // MARK: - RTO24 — dispatch rules + + // UTS: objects/unit/RTO24c1/depth-filtering-formula-0 + @Test + func test_RTO24c1_depth_filtering_formula() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + + // Seed a grandchild under profile.prefs so the grandchild stimulus can be a single-candidate + // COUNTER_INC (RTO6 zero-value-creates counter:deep@3000). Sent before subscribing. + sendToClient(ws, [StandardTestPool.mapSet(objectId: "map:prefs@1000", key: "deep", value: StandardTestPool.data(objectId: "counter:deep@3000"), serial: "50", siteCode: "remote")]) + + let events = Captured() + _ = try root.get(key: "profile").subscribe(options: PathObjectSubscriptionOptions(depth: 2), listener: { events.append($0) }) + let control = Captured() + _ = try root.subscribe(listener: { control.append($0) }) + + // Self (["profile"], relativeDepth 1) — covered. + sendToClient(ws, [StandardTestPool.mapSet(objectId: "map:profile@1000", key: "email", value: StandardTestPool.data(string: "bob@example.com"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")]) + poll("events.count >= 1") { events.count >= 1 } + // Child (["profile","nested_counter"], relativeDepth 2) — covered. + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:nested@1000", number: 3, serial: "100", siteCode: "remote")]) + poll("events.count >= 2") { events.count >= 2 } + // Grandchild (["profile","prefs","deep"], relativeDepth 3) — NOT covered. + let controlBefore = control.count + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:deep@3000", number: 1, serial: "101", siteCode: "remote")]) + poll("control fired") { control.count > controlBefore } + + #expect(events.count == 2) + } + + // UTS: objects/unit/RTO24c1/prefix-mismatch-0 + @Test + func test_RTO24c1_prefix_mismatch_does_not_trigger() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let profileEvents = Captured() + _ = try root.get(key: "profile").subscribe(listener: { profileEvents.append($0) }) + let control = Captured() + _ = try root.subscribe(listener: { control.append($0) }) + + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")]) + sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "name", value: StandardTestPool.data(string: "Bob"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")]) + poll("control fired for both") { control.count >= 2 } + + #expect(profileEvents.count == 0) // "profile" is not a prefix of "score"/"name" + } + + // UTS: objects/unit/RTO24b2a/candidate-paths-map-keys-0 + @Test + func test_RTO24b2a_candidate_paths_include_map_update_keys() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let scoreEvents = Captured() + let rootEvents = Captured() + _ = try root.get(key: "score").subscribe(listener: { scoreEvents.append($0) }) + _ = try root.subscribe(listener: { rootEvents.append($0) }) + + // MAP_SET on root key "score" -> candidates [] and ["score"]; both subscriptions fire. + sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "score", value: StandardTestPool.data(objectId: "counter:new@2000"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")]) + poll("scoreEvents fired") { scoreEvents.count >= 1 } + poll("rootEvents fired") { rootEvents.count >= 1 } + + #expect(scoreEvents.count == 1) + #expect(scoreEvents.first?.object.path == "score") + #expect(rootEvents.count == 1) + } + + // UTS: objects/unit/RTO24b2c/listener-exception-caught-0 + @Test + func test_RTO24b2c_listener_exception_does_not_affect_others() async throws { + // DEVIATION (RTO24b2c): the spec's first listener THROWS; Swift's `PathObjectSubscriptionCallback` + // is non-throwing (`-> Void`), so a throwing listener isn't expressible. We keep a no-op first + // listener and assert the second still fires (the observable intent). See deviations.md. + let (_, _, root, ws) = try await setupSyncedChannel("test") + let events = Captured() + _ = try root.subscribe(listener: { _ in }) + _ = try root.subscribe(listener: { events.append($0) }) + + sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "name", value: StandardTestPool.data(string: "Bob"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")]) + poll("events.count >= 1") { events.count >= 1 } + + #expect(events.count == 1) + } + + // UTS: objects/unit/RTO24b1/multi-path-dispatch-0 + @Test + func test_RTO24b1_dispatch_via_getFullPaths_for_multi_path_objects() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let eventsScore = Captured() + let eventsAlias = Captured() + + // Add a second reference "alias" -> counter:score@1000, so it has two paths. + sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "alias", value: StandardTestPool.data(objectId: "counter:score@1000"), serial: "98", siteCode: "remote")]) + _ = try root.get(key: "score").subscribe(listener: { eventsScore.append($0) }) + _ = try root.get(key: "alias").subscribe(listener: { eventsAlias.append($0) }) + + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 5, serial: "99", siteCode: "remote")]) + poll("score fired") { eventsScore.count >= 1 } + poll("alias fired") { eventsAlias.count >= 1 } + + #expect(eventsScore.count == 1) + #expect(eventsScore.first?.object.path == "score") + #expect(eventsAlias.count == 1) + #expect(eventsAlias.first?.object.path == "alias") + } + + // UTS: objects/unit/RTO24b2b/fires-once-per-dispatch-0 + @Test + func test_RTO24b2b_subscription_fires_exactly_once_per_dispatch() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let events = Captured() + // Root (unlimited depth) covers both [] and ["score"], but must fire once per dispatch. + _ = try root.subscribe(listener: { events.append($0) }) + + sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "score", value: StandardTestPool.data(objectId: "counter:new@2000"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")]) + poll("events.count >= 1") { events.count >= 1 } + // Control: a second single-candidate dispatch; awaiting it flushes any spurious extra callback. + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:new@2000", number: 1, serial: "100", siteCode: "remote")]) + poll("events.count >= 2") { events.count >= 2 } + + #expect(events.count == 2) // exactly one per dispatch + } +} + +private extension PathObjectSubscribeTests { + + /// Asserts `operation` throws an `ARTErrorInfo` with the given `code` (and optional `statusCode`). + func expectError(code: Int, statusCode: Int? = nil, sourceLocation: SourceLocation = #_sourceLocation, _ operation: () throws -> Void) { + do { + try operation() + Issue.record("expected operation to throw ARTErrorInfo with code \(code)", sourceLocation: sourceLocation) + } catch let error as ARTErrorInfo { + #expect(error.code == code, sourceLocation: sourceLocation) + if let statusCode { + #expect(error.statusCode == statusCode, sourceLocation: sourceLocation) + } + } catch { + Issue.record("expected ARTErrorInfo, got \(error)", sourceLocation: sourceLocation) + } + } +} diff --git a/Tests/UTS/Tests/Path-Based/PathObjectTests.swift b/Tests/UTS/Tests/Path-Based/PathObjectTests.swift new file mode 100644 index 00000000..7113405a --- /dev/null +++ b/Tests/UTS/Tests/Path-Based/PathObjectTests.swift @@ -0,0 +1,319 @@ +import Ably +import Foundation +import Testing +@testable import AblyLiveObjects + +/// PathObject read operations (`RTPO1`–`RTPO14`). +/// Derived from https://github.com/ably/specification/blob/0a531c79adfc072c6d1441591f2dd838913dfe73/uts/objects/unit/path_object.md +/// +/// Binding conventions (the spec's loosely-typed API maps onto Swift's type-refined one): +/// - Chained navigation: the spec's `root.get("a").get("b")` becomes +/// `root.get(key: "a").asLiveMap().get(key: "b")` (`get`/`at` live on `LiveMapPathObject`, and +/// return the loosely-typed `any PathObject`). +/// - `value()`: the spec's single `value()` maps to the type-refined accessor — +/// `asLiveCounter().value()` (`Double?`) for counters and `asPrimitive().value()` (`Primitive?`) +/// for primitives; a map / unresolvable path yields `nil` from `asPrimitive().value()`. +/// - `path` is a property (per review), not `path()`. +@Suite(.serialized) +final class PathObjectTests: UTSTestCase { + // MARK: - RTPO4 — path + + // UTS: objects/unit/RTPO4/path-string-representation-0 + @Test + func test_RTPO4_path_returns_dot_delimited_string() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + + #expect(root.path == "") // RTPO4c: empty path (root) + #expect(root.get(key: "profile").path == "profile") + #expect(root.get(key: "profile").asLiveMap().get(key: "email").path == "profile.email") + } + + // UTS: objects/unit/RTPO4b/path-escapes-dots-0 + @Test + func test_RTPO4b_path_escapes_dots_in_segments() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + + // Dots within a segment are escaped with a backslash. + let po = root.get(key: "a.b").asLiveMap().get(key: "c") + #expect(po.path == #"a\.b.c"#) + } + + // MARK: - RTPO5 — get + + // UTS: objects/unit/RTPO5/get-appends-key-0 + @Test + func test_RTPO5_get_returns_new_PathObject_with_appended_key() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + + let child = root.get(key: "profile") + let grandchild = child.asLiveMap().get(key: "email") + #expect(child.path == "profile") + #expect(grandchild.path == "profile.email") + // RTPO5d: purely navigational (child is a distinct object from root). + } + + // UTS: objects/unit/RTPO5b/get-non-string-throws-0 + @Test + func test_RTPO5b_get_throws_on_non_string_key() throws { + // DEVIATION (RTPO5b): spec passes a non-String key expecting 40003. Swift's `get(key: String)` + // rejects a non-String at compile time. Not expressible. See deviations.md. + } + + // MARK: - RTPO6 — at + + // UTS: objects/unit/RTPO6/at-parses-path-0 + @Test + func test_RTPO6_at_parses_dot_delimited_path() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + + let po = root.at(path: "profile.email") + #expect(po.path == "profile.email") + #expect(try po.asPrimitive().stringValue == "alice@example.com") + } + + // UTS: objects/unit/RTPO6/at-escaped-dots-0 + @Test + func test_RTPO6_at_respects_escaped_dots() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + + let po = root.at(path: #"a\.b.c"#) + #expect(po.path == #"a\.b.c"#) + } + + // UTS: objects/unit/RTPO6b/at-non-string-throws-0 + @Test + func test_RTPO6b_at_throws_for_non_string_input() throws { + // DEVIATION (RTPO6b): spec passes a non-String path expecting 40003. Swift's `at(path: String)` + // rejects a non-String at compile time. Not expressible. See deviations.md. + } + + // MARK: - RTPO7 — value + + // UTS: objects/unit/RTPO7/value-counter-0 + @Test + func test_RTPO7_value_returns_counter_numeric_value() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + #expect(try root.get(key: "score").asLiveCounter().value() == 100) // RTPO7c + } + + // UTS: objects/unit/RTPO7/value-primitive-0 + @Test + func test_RTPO7_value_returns_primitive_value() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + #expect(try root.get(key: "name").asPrimitive().stringValue == "Alice") // RTPO7d + #expect(try root.get(key: "age").asPrimitive().numberValue == 30) + #expect(try root.get(key: "active").asPrimitive().boolValue == true) + } + + // UTS: objects/unit/RTPO7/value-bytes-0 + @Test + func test_RTPO7_value_returns_bytes_for_binary_entry() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + #expect(try root.get(key: "avatar").asPrimitive().dataValue == Data([1, 2, 3])) // RTPO7d (binary) + } + + // UTS: objects/unit/RTPO7d/value-livemap-null-0 + @Test + func test_RTPO7d_value_returns_null_for_InternalLiveMap() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + #expect(try root.get(key: "profile").asPrimitive().value() == nil) // RTPO7e + } + + // UTS: objects/unit/RTPO7e/value-unresolvable-null-0 + @Test + func test_RTPO7e_value_returns_null_on_resolution_failure() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + #expect(try root.get(key: "nonexistent").asLiveMap().get(key: "deep").asPrimitive().value() == nil) // RTPO7f / RTPO3c1 + } + + // MARK: - RTPO8 — instance + + // UTS: objects/unit/RTPO8/instance-live-object-0 + @Test + func test_RTPO8_instance_returns_Instance_for_LiveObject() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + + let counterInstance = try root.get(key: "score").instance() + #expect(counterInstance?.id() == "counter:score@1000") // RTPO8c + + let mapInstance = try root.get(key: "profile").instance() + #expect(mapInstance?.id() == "map:profile@1000") + } + + // UTS: objects/unit/RTPO8c/instance-primitive-null-0 + @Test + func test_RTPO8c_instance_returns_null_for_primitive() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + #expect(try root.get(key: "name").instance() == nil) // RTPO8d + } + + // MARK: - RTPO9 — entries + + // UTS: objects/unit/RTPO9/entries-yields-pairs-0 + @Test + func test_RTPO9_entries_returns_array_of_key_pathObject_pairs() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + + var entries: [String: String] = [:] + for (key, pathObject) in try root.entries() { + entries[key] = pathObject.path + } + #expect(entries["name"] == "name") + #expect(entries["profile"] == "profile") + #expect(entries.count == 7) + } + + // UTS: objects/unit/RTPO9d/entries-non-map-empty-0 + @Test + func test_RTPO9d_entries_returns_empty_array_for_non_map() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + #expect(try root.get(key: "score").asLiveMap().entries().isEmpty) + } + + // MARK: - RTPO10 — keys + + // UTS: objects/unit/RTPO10/keys-returns-array-0 + @Test + func test_RTPO10_keys_returns_array_of_key_strings() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + + let keys = try root.keys() + #expect(keys.count == 7) + #expect(keys.contains("name")) + #expect(keys.contains("profile")) + #expect(keys.contains("score")) + } + + // UTS: objects/unit/RTPO10d/keys-non-map-empty-0 + @Test + func test_RTPO10d_keys_returns_empty_array_for_non_map() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + #expect(try root.get(key: "score").asLiveMap().keys().isEmpty) + } + + // MARK: - RTPO11 — values + + // UTS: objects/unit/RTPO11/values-returns-array-0 + @Test + func test_RTPO11_values_returns_array_of_pathObjects() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + + let values = try root.values() + #expect(values.count == 7) + let paths = Set(values.map(\.path)) + #expect(paths.contains("name")) + #expect(paths.contains("profile")) + #expect(paths.contains("score")) + } + + // UTS: objects/unit/RTPO11d/values-non-map-empty-0 + @Test + func test_RTPO11d_values_returns_empty_array_for_non_map() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + #expect(try root.get(key: "score").asLiveMap().values().isEmpty) + } + + // MARK: - RTPO12 — size + + // UTS: objects/unit/RTPO12/size-count-0 + @Test + func test_RTPO12_size_returns_non_tombstoned_count() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + #expect(try root.size() == 7) + #expect(try root.get(key: "profile").asLiveMap().size() == 3) + } + + // UTS: objects/unit/RTPO12c/size-non-map-null-0 + @Test + func test_RTPO12c_size_returns_null_for_non_map() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + #expect(try root.get(key: "score").asLiveMap().size() == nil) + #expect(try root.get(key: "name").asLiveMap().size() == nil) + } + + // MARK: - RTPO13 — compact (DEVIATION: asserted via compactJson) + + // UTS: objects/unit/RTPO13/compact-recursive-0 + @Test + func test_RTPO13_compact_recursively_compacts_map_tree() async throws { + // DEVIATION (RTPO13): only `compactJson()` is exposed publicly, so this asserts on the JSON + // form (binary appears as base64, not raw bytes). See deviations.md. + let (_, _, root, _) = try await setupSyncedChannel("test") + + let result = try root.compactJson()?.objectValue + #expect(result?["name"]?.stringValue == "Alice") // RTPO13c4 + #expect(result?["age"]?.numberValue == 30) + #expect(result?["active"]?.boolValue == true) + #expect(result?["score"]?.numberValue == 100) // RTPO13c3 (counter -> number) + #expect(result?["profile"]?.objectValue?["email"]?.stringValue == "alice@example.com") // RTPO13c2 (nested map) + #expect(result?["profile"]?.objectValue?["nested_counter"]?.numberValue == 5) + #expect(result?["profile"]?.objectValue?["prefs"]?.objectValue?["theme"]?.stringValue == "dark") + } + + // UTS: objects/unit/RTPO13c/compact-counter-0 + @Test + func test_RTPO13c_compact_returns_number_for_counter() async throws { + // DEVIATION (RTPO13): asserted via compactJson(). See deviations.md. + let (_, _, root, _) = try await setupSyncedChannel("test") + #expect(try root.get(key: "score").compactJson()?.numberValue == 100) // RTPO13d + } + + // UTS: objects/unit/RTPO14/compact-json-bytes-0 + @Test + func test_RTPO14_compactJson_encodes_bytes_as_base64_string() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + let result = try root.compactJson()?.objectValue + #expect(result?["avatar"]?.stringValue == "AQID") // RTPO14b1 (base64 of [1,2,3]) + } + + // UTS: objects/unit/RTPO14/compact-json-0 + @Test + func test_RTPO14_compactJson_encodes_cycles_as_objectId() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + + // Introduce a cycle: prefs.back_ref -> profile. + ws.activeConnection?.sendToClient(.object(channel: "test", state: [ + StandardTestPool.mapSet( + objectId: "map:prefs@1000", + key: "back_ref", + value: StandardTestPool.data(objectId: "map:profile@1000"), + serial: StandardTestPool.remoteSerial(0), + siteCode: "remote", + ), + ])) + + let result = try root.get(key: "profile").compactJson()?.objectValue + // RTPO14b2: cycles are encoded as { "objectId": ... }. + let backRef = result?["prefs"]?.objectValue?["back_ref"]?.objectValue + #expect(backRef?["objectId"]?.stringValue == "map:profile@1000") + } + + // MARK: - RTPO3 — path resolution + + // UTS: objects/unit/RTPO3/path-resolution-walk-0 + @Test + func test_RTPO3_path_resolution_walks_through_maps() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + + #expect(try root.asPrimitive().value() == nil) // RTPO3b: empty path resolves to root (a map) + #expect(try root.get(key: "profile").asLiveMap().get(key: "prefs").asLiveMap().get(key: "theme").asPrimitive().stringValue == "dark") + } + + // UTS: objects/unit/RTPO3a1/intermediate-not-map-0 + @Test + func test_RTPO3a1_resolution_fails_if_intermediate_not_map() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + // "score" is a counter, so navigating through it fails to resolve -> null. + #expect(try root.get(key: "score").asLiveMap().get(key: "something").asPrimitive().value() == nil) + } + + // UTS: objects/unit/RTPO3c1/read-null-on-failure-0 + @Test + func test_RTPO3c1_read_operation_returns_null_on_resolution_failure() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + #expect(try root.get(key: "nonexistent").asPrimitive().value() == nil) + #expect(try root.get(key: "nonexistent").instance() == nil) + #expect(try root.get(key: "nonexistent").asLiveMap().size() == nil) + #expect(try root.get(key: "nonexistent").compactJson() == nil) + } +} diff --git a/Tests/UTS/Tests/Path-Based/RealtimeObjectTests.swift b/Tests/UTS/Tests/Path-Based/RealtimeObjectTests.swift new file mode 100644 index 00000000..aec53387 --- /dev/null +++ b/Tests/UTS/Tests/Path-Based/RealtimeObjectTests.swift @@ -0,0 +1,499 @@ +import Ably +import Foundation +import Testing +@testable import AblyLiveObjects + +/// RealtimeObject orchestration (`RTO2`, `RTO10`, `RTO15`, `RTO17`–`RTO20`, `RTO22`–`RTO26`). +/// Derived from https://github.com/ably/specification/blob/0a531c79adfc072c6d1441591f2dd838913dfe73/uts/objects/unit/realtime_object.md +/// +/// Uses `setupSyncedChannel` / `objectsChannel` from the harness. Publish/apply behaviour is exercised +/// through the public path-object mutations (`increment`/`set`), which internally publishAndApply, and +/// sync events through `channel.object.on(...)`. +@Suite(.serialized) +final class RealtimeObjectTests: UTSTestCase { + // MARK: - RTO23 — get() + + // UTS: objects/unit/RTO23/get-returns-path-object-0 + @Test + func test_RTO23_get_returns_path_object_wrapping_root() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + #expect(root.path == "") // RTO23d: root path is the empty path + } + + // UTS: objects/unit/RTO23/get-implicit-attach-0 + @Test + func test_RTO23_get_implicitly_attaches_channel() async throws { + let objects = objectsChannel("test") + #expect(objects.channel.state == .initialized) + _ = try await objects.channel.object.get() + #expect(objects.channel.state == .attached) + } + + // UTS: objects/unit/RTO23d/get-resolves-immediately-synced-0 + @Test + func test_RTO23d_get_resolves_immediately_when_already_synced() async throws { + let (_, channel, _, _) = try await setupSyncedChannel("test") + let root2 = try await channel.object.get() + #expect(root2.path == "") + } + + // UTS: objects/unit/RTO23c/get-waits-for-synced-0 + @Test + func test_RTO23c_get_waits_for_synced_state() async throws { + // ATTACHED arrives but the OBJECT_SYNC is deferred; get() must wait for it. + let objects = objectsChannel("test", sync: false) + async let pendingRoot = objects.channel.object.get() + // Deliver the sync so get() can resolve. + objects.ws.activeConnection?.sendToClient(.objectSync(channel: "test", channelSerial: "sync1:", state: StandardTestPool.objects)) + let root = try await pendingRoot + #expect(root.path == "") + } + + // UTS: objects/unit/RTO23a/get-requires-subscribe-mode-0 + // UTS: objects/unit/RTO25a/access-requires-subscribe-mode-0 + @Test + func test_RTO23a_get_requires_OBJECT_SUBSCRIBE_mode() async throws { + // Without OBJECT_SUBSCRIBE, get()/access throws 40024 (RTO2a2 / RTO25a). + let objects = objectsChannel("test", modes: [.objectPublish], sync: false) + await expectError(code: 40024, statusCode: 400) { + _ = try await objects.channel.object.get() + } + } + + // UTS: objects/unit/RTO23e/get-reattaches-detached-0 + @Test + func test_RTO23e_get_reattaches_detached_channel() async throws { + let (_, channel, _, ws) = try await setupSyncedChannel("test") + + channel.detach() + ws.activeConnection?.sendToClient(.detached(channel: "test")) + awaitChannelState(channel, .detached) + + // get() on a DETACHED channel runs ensure-active-channel (RTL33b) -> implicit re-attach. + let root = try await channel.object.get() + #expect(root.path == "") + #expect(channel.state == .attached) + } + + // UTS: objects/unit/RTO23e/get-rejects-failed-0 + @Test + func test_RTO23e_get_on_failed_channel_rejects_90001() async throws { + let objects = objectsChannel("test", modes: [.objectSubscribe], sync: false) + objects.channel.attach() + objects.ws.activeConnection?.sendToClient(.channelError(channel: "test", code: 90000, statusCode: 400, message: "Channel error")) + awaitChannelState(objects.channel, .failed) + + await expectError(code: 90001, statusCode: 400) { + _ = try await objects.channel.object.get() + } + } + + // MARK: - RTO15 — publish + + // UTS: objects/unit/RTO15/publish-sends-object-pm-0 + @Test + func test_RTO15_publish_sends_object_protocol_message() throws { + // DEVIATION (RTO15): `publish` (and `PublishResult`) is an internal RealtimeObject method, not + // part of the public `RealtimeObject` protocol (which exposes only `get()` / `on(...)`). The + // publish path is exercised indirectly through the path-object mutations (see the RTO20 tests). + // See deviations.md. + } + + // MARK: - RTO20 — publishAndApply (apply-on-ACK) + + // UTS: objects/unit/RTO20/publish-and-apply-local-0 + @Test + func test_RTO20_publish_and_apply_local_on_ack() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + try await root.get(key: "score").asLiveCounter().increment(amount: 10) + #expect(try root.get(key: "score").asLiveCounter().value() == 110) + } + + // UTS: objects/unit/RTO20c/missing-site-code-0 + @Test + func test_RTO20c_publish_and_apply_without_site_code_does_not_apply() async throws { + // No siteCode in ConnectionDetails -> the synthetic message can't be built, so no local apply. + let objects = objectsChannel("test", siteCode: nil) + let root = try await objects.channel.object.get() + try await root.get(key: "score").asLiveCounter().increment(amount: 10) + #expect(try root.get(key: "score").asLiveCounter().value() == 100) + } + + // UTS: objects/unit/RTO20d1/null-serial-skipped-0 + @Test + func test_RTO20d1_null_serial_in_publish_result_is_skipped() async throws { + // ACK with a null serial -> that synthetic message is skipped, so no local apply. + let objects = objectsChannel("test", autoAck: false) + let root = try await objects.channel.object.get() + // Respond to the OBJECT publish with a single null serial. + // (autoAck is off; a bespoke ACK is not wired here since the API traps first — documents intent.) + try await root.get(key: "score").asLiveCounter().increment(amount: 10) + objects.ws.activeConnection?.sendToClient(.ack(msgSerial: 0, count: 1, serials: [nil])) + #expect(try root.get(key: "score").asLiveCounter().value() == 100) + } + + // UTS: objects/unit/RTO20e/waits-for-synced-0 + @Test + func test_RTO20e_publish_and_apply_waits_for_synced_during_syncing() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + + // Begin a new sync (SYNCING) without completing it. + ws.activeConnection?.sendToClient(.attached(channel: "test", channelSerial: "sync2:cursor", hasObjects: true)) + + async let pending: Void = root.get(key: "score").asLiveCounter().increment(amount: 10) + // While SYNCING the increment must not have applied yet. + #expect(try root.get(key: "score").asLiveCounter().value() == 100) + + // Complete the sync; the write then applies. + ws.activeConnection?.sendToClient(.objectSync(channel: "test", channelSerial: "sync2:", state: StandardTestPool.objects)) + try await pending + #expect(try root.get(key: "score").asLiveCounter().value() == 110) + } + + // UTS: objects/unit/RTO20e1/fails-on-channel-detached-0 + @Test + func test_RTO20e1_publish_and_apply_fails_when_channel_detached_during_sync_wait() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + ws.activeConnection?.sendToClient(.attached(channel: "test", channelSerial: "sync2:cursor", hasObjects: true)) + + await expectError(code: 92008) { + async let pending: Void = root.get(key: "score").asLiveCounter().increment(amount: 10) + ws.activeConnection?.sendToClient(.detached(channel: "test")) + try await pending + } + } + + // UTS: objects/unit/RTO20/echo-dedup-0 + @Test + func test_RTO20_echo_deduplication_via_appliedOnAckSerials() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + try await root.get(key: "score").asLiveCounter().increment(amount: 10) + #expect(try root.get(key: "score").asLiveCounter().value() == 110) + + // Echo with the same serial as the apply-on-ACK -> deduplicated (RTO9a3). + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 10, serial: StandardTestPool.ackSerial(0, 0), siteCode: StandardTestPool.siteCode)]) + #expect(try root.get(key: "score").asLiveCounter().value() == 110) + } + + // UTS: objects/unit/RTO20f/ack-no-site-timeserials-update-0 + @Test + func test_RTO20f_apply_on_ack_does_not_update_site_timeserials() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + try await root.get(key: "score").asLiveCounter().increment(amount: 10) + #expect(try root.get(key: "score").asLiveCounter().value() == 110) + + // Inbound from SITE_CODE with a serial that is NOT the ACK serial but sorts below it. It + // applies only if the LOCAL apply-on-ACK left siteTimeserials[SITE_CODE] untouched (RTLC7c). + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 10, serial: StandardTestPool.belowAckSerial(9), siteCode: StandardTestPool.siteCode)]) + poll("value == 120") { (try? root.get(key: "score").asLiveCounter().value()) == 120 } + #expect(try root.get(key: "score").asLiveCounter().value() == 120) + } + + // UTS: objects/unit/RTO20/ack-after-echo-no-double-apply-0 + @Test + func test_RTO20_ack_after_echo_does_not_double_apply() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test", autoAck: false) + + async let pending: Void = root.get(key: "score").asLiveCounter().increment(amount: 10) + // Echo arrives BEFORE the ACK. + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 10, serial: StandardTestPool.ackSerial(0, 0), siteCode: StandardTestPool.siteCode)]) + // Then the ACK. + ws.activeConnection?.sendToClient(.ack(msgSerial: 0, count: 1, serials: [StandardTestPool.ackSerial(0, 0)])) + try await pending + + #expect(try root.get(key: "score").asLiveCounter().value() == 110) + } + + // UTS: objects/unit/RTO5c9-RTO20/ack-serials-cleared-on-resync-0 + @Test + func test_RTO5c9_applied_on_ack_serials_cleared_on_resync() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + try await root.get(key: "score").asLiveCounter().increment(amount: 10) + #expect(try root.get(key: "score").asLiveCounter().value() == 110) + + // Re-sync resets the counter to its pool value and clears appliedOnAckSerials (RTO5c9). + ws.activeConnection?.sendToClient(.attached(channel: "test", channelSerial: "sync2:cursor", hasObjects: true)) + ws.activeConnection?.sendToClient(.objectSync(channel: "test", channelSerial: "sync2:", state: StandardTestPool.objects)) + #expect(try root.get(key: "score").asLiveCounter().value() == 100) + + // The previously-applied serial now applies normally (not deduped). + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 10, serial: StandardTestPool.ackSerial(0, 0), siteCode: StandardTestPool.siteCode)]) + poll("value == 110") { (try? root.get(key: "score").asLiveCounter().value()) == 110 } + #expect(try root.get(key: "score").asLiveCounter().value() == 110) + } + + // UTS: objects/unit/RTO20/subscription-fires-on-ack-apply-0 + @Test + func test_RTO20_subscription_fires_on_apply_on_ack() async throws { + let (_, _, root, _) = try await setupSyncedChannel("test") + let events = Captured() + _ = try root.get(key: "score").subscribe(listener: { events.append($0) }) + + try await root.get(key: "score").asLiveCounter().increment(amount: 10) + #expect(events.count >= 1) + #expect(try root.get(key: "score").asLiveCounter().value() == 110) + } + + // MARK: - RTO17 / RTO18 / RTO19 — sync events + + // UTS: objects/unit/RTO17/sync-state-events-0 + @Test + func test_RTO17_sync_state_events_in_order() async throws { + let objects = objectsChannel("test", sync: false) + let events = Captured() + _ = objects.channel.object.on(event: .syncing) { events.append("SYNCING") } + _ = objects.channel.object.on(event: .synced) { events.append("SYNCED") } + + async let pendingRoot = objects.channel.object.get() + poll("events.count >= 1") { events.count >= 1 } + objects.ws.activeConnection?.sendToClient(.objectSync(channel: "test", channelSerial: "sync1:", state: StandardTestPool.objects)) + _ = try await pendingRoot + + #expect(events.all == ["SYNCING", "SYNCED"]) + } + + // UTS: objects/unit/RTO18d/duplicate-listener-0 + @Test + func test_RTO18d_duplicate_listener_fires_twice() async throws { + let (_, channel, _, ws) = try await setupSyncedChannel("test") + let calls = Captured() + let listener: @Sendable () -> Void = { calls.append(()) } + _ = channel.object.on(event: .synced, callback: listener) + _ = channel.object.on(event: .synced, callback: listener) + + ws.activeConnection?.sendToClient(.attached(channel: "test", channelSerial: "sync2:cursor", hasObjects: true)) + ws.activeConnection?.sendToClient(.objectSync(channel: "test", channelSerial: "sync2:", state: StandardTestPool.objects)) + poll("calls.count >= 2") { calls.count >= 2 } + + #expect(calls.count == 2) + } + + // UTS: objects/unit/RTO19/off-deregisters-0 + @Test + func test_RTO19_off_deregisters_listener() async throws { + let (_, channel, _, ws) = try await setupSyncedChannel("test") + let calls = Captured() + let sub = channel.object.on(event: .synced) { calls.append(()) } + sub.off() + + ws.activeConnection?.sendToClient(.attached(channel: "test", channelSerial: "sync2:cursor", hasObjects: true)) + ws.activeConnection?.sendToClient(.objectSync(channel: "test", channelSerial: "sync2:", state: StandardTestPool.objects)) + + #expect(calls.count == 0) + } + + // MARK: - RTO2 / RTO25 / RTO26 — mode & state preconditions + + // UTS: objects/unit/RTO2/mode-enforcement-0 + // UTS: objects/unit/RTO26a/write-requires-publish-mode-0 + @Test + func test_RTO26a_write_requires_OBJECT_PUBLISH_mode() async throws { + // Channel granted only OBJECT_SUBSCRIBE -> a write throws 40024. + let objects = objectsChannel("test", modes: [.objectSubscribe]) + let root = try await objects.channel.object.get() + await expectError(code: 40024, statusCode: 400) { + try await root.set(key: "name", value: "Bob") + } + } + + // UTS: objects/unit/RTO25b/access-throws-detached-0 + @Test + func test_RTO25b_access_throws_on_detached_channel() async throws { + let (_, channel, root, ws) = try await setupSyncedChannel("test") + ws.activeConnection?.sendToClient(.detached(channel: "test")) + awaitChannelState(channel, .detached) + + expectErrorSync(code: 90001, statusCode: 400) { + _ = try root.keys() + } + } + + // UTS: objects/unit/RTO25b/access-throws-failed-0 + @Test + func test_RTO25b_access_throws_on_failed_channel() async throws { + let (_, channel, root, ws) = try await setupSyncedChannel("test") + ws.activeConnection?.sendToClient(.channelError(channel: "test", code: 90000, statusCode: 400, message: "Channel error")) + awaitChannelState(channel, .failed) + + expectErrorSync(code: 90001, statusCode: 400) { + _ = try root.keys() + } + } + + // UTS: objects/unit/RTO26b/write-throws-detached-0 + @Test + func test_RTO26b_write_throws_on_detached_channel() async throws { + let (_, channel, root, ws) = try await setupSyncedChannel("test") + ws.activeConnection?.sendToClient(.detached(channel: "test")) + awaitChannelState(channel, .detached) + + await expectError(code: 90001, statusCode: 400) { + try await root.set(key: "name", value: "Bob") + } + } + + // UTS: objects/unit/RTO26b/write-throws-failed-0 + @Test + func test_RTO26b_write_throws_on_failed_channel() async throws { + let (_, channel, root, ws) = try await setupSyncedChannel("test") + ws.activeConnection?.sendToClient(.channelError(channel: "test", code: 90000, statusCode: 400, message: "Channel error")) + awaitChannelState(channel, .failed) + + await expectError(code: 90001, statusCode: 400) { + try await root.set(key: "name", value: "Bob") + } + } + + // UTS: objects/unit/RTO26c/write-throws-echo-disabled-0 + @Test + func test_RTO26c_write_throws_when_echo_disabled() async throws { + let objects = objectsChannel("test", echoMessages: false) + let root = try await objects.channel.object.get() + await expectError(code: 40000, statusCode: 400) { + try await root.set(key: "name", value: "Bob") + } + } + + // MARK: - RTO24 — dispatch register + + // UTS: objects/unit/RTO24a/single-register-instance-0 + @Test + func test_RTO24a_single_subscription_register_per_channel() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let rootEvents = Captured() + let scoreEvents = Captured() + _ = try root.subscribe(listener: { rootEvents.append($0) }) + _ = try root.get(key: "score").subscribe(listener: { scoreEvents.append($0) }) + + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 5, serial: "t:1", siteCode: "remote")]) + poll("score fired") { scoreEvents.count >= 1 } + + #expect(rootEvents.count >= 1) + #expect(scoreEvents.count >= 1) + } + + // UTS: objects/unit/RTO24c1/coverage-prefix-depth-0 + @Test + func test_RTO24c1_coverage_prefix_with_depth_constraint() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test") + let shallowEvents = Captured() + let deepEvents = Captured() + _ = try root.subscribe(options: PathObjectSubscriptionOptions(depth: 1), listener: { shallowEvents.append($0) }) + _ = try root.subscribe(listener: { deepEvents.append($0) }) + + // Root self-update ([], covered by depth 1). + sendToClient(ws, [StandardTestPool.mapSet(objectId: "root", key: "name", value: StandardTestPool.data(string: "Bob"), serial: StandardTestPool.remoteSerial(0), siteCode: "remote")]) + poll("deep >= 1") { deepEvents.count >= 1 } + // Child update (["score"], relativeDepth 2 > 1) — only deep covers it. + sendToClient(ws, [StandardTestPool.counterInc(objectId: "counter:score@1000", number: 5, serial: "t:2", siteCode: "remote")]) + poll("deep >= 2") { deepEvents.count >= 2 } + poll("shallow >= 1") { shallowEvents.count >= 1 } + + #expect(shallowEvents.count == 1) + #expect(deepEvents.count >= 2) + } + + // MARK: - RTO10 — garbage collection + + // UTS: objects/unit/RTO10/gc-tombstoned-objects-0 + @Test + func test_RTO10_gc_removes_tombstoned_objects_past_grace_period() async throws { + enableFakeTimers() + let (_, _, root, ws) = try await setupSyncedChannel("test") + + sendToClient(ws, [StandardTestPool.objectDelete(objectId: "counter:score@1000", serial: "99", siteCode: "site1", serialTimestamp: Date(timeIntervalSince1970: 1))]) + + advanceTime(byMilliseconds: 86_400_000 + 300_000) + + #expect(try root.get(key: "score").asLiveCounter().value() == nil) + } + + // UTS: objects/unit/RTO10b1/gc-grace-period-source-0 + @Test + func test_RTO10b1_gc_grace_period_from_connection_details() async throws { + enableFakeTimers() + // Short grace period (5000ms) from ConnectionDetails. + let objects = objectsChannel("test", gcGracePeriod: 5000) + let root = try await objects.channel.object.get() + + sendToClient(objects.ws, [StandardTestPool.objectDelete(objectId: "counter:score@1000", serial: "99", siteCode: "site1", serialTimestamp: Date(timeIntervalSince1970: 1))]) + advanceTime(byMilliseconds: 5000 + 1000) + + #expect(try root.get(key: "score").asLiveCounter().value() == nil) + } + + // MARK: - RTO17 / RTO18 — sync event sequences + + // UTS: objects/unit/RTO17-RTO18/sync-event-sequences-0 + @Test + func test_RTO17_RTO18_sync_event_sequence_initial_attach() async throws { + // A genuine first attach: register listeners on a fresh (non-synced) channel BEFORE attach. + let objects = objectsChannel("test", sync: false) + let events = Captured() + _ = objects.channel.object.on(event: .syncing) { events.append("SYNCING") } + _ = objects.channel.object.on(event: .synced) { events.append("SYNCED") } + + objects.channel.attach() + objects.ws.activeConnection?.sendToClient(.objectSync(channel: "test", channelSerial: "sync1:", state: StandardTestPool.objects)) + poll("events.count >= 2") { events.count >= 2 } + + #expect(events.all == ["SYNCING", "SYNCED"]) + } + + // UTS: objects/unit/RTO17-RTO18/sync-event-sequences-0 (re-sync on new ATTACHED) + @Test + func test_RTO17_RTO18_sync_event_sequence_resync_on_new_attached() async throws { + let (_, channel, _, ws) = try await setupSyncedChannel("test") + let events = Captured() + _ = channel.object.on(event: .syncing) { events.append("SYNCING") } + _ = channel.object.on(event: .synced) { events.append("SYNCED") } + + ws.activeConnection?.sendToClient(.attached(channel: "test", channelSerial: "sync3:cursor", hasObjects: true)) + ws.activeConnection?.sendToClient(.objectSync(channel: "test", channelSerial: "sync3:", state: StandardTestPool.objects)) + poll("events.count >= 2") { events.count >= 2 } + + #expect(events.all == ["SYNCING", "SYNCED"]) + } + + // UTS: objects/unit/RTO17-RTO18/sync-event-sequences-0 (ATTACHED without HAS_OBJECTS) + @Test + func test_RTO17_RTO18_sync_event_sequence_attached_without_has_objects() async throws { + let (_, channel, _, ws) = try await setupSyncedChannel("test") + let events = Captured() + _ = channel.object.on(event: .syncing) { events.append("SYNCING") } + _ = channel.object.on(event: .synced) { events.append("SYNCED") } + + // ATTACHED without HAS_OBJECTS: RTO4c -> SYNCING, then RTO4b4 completes immediately -> SYNCED. + ws.activeConnection?.sendToClient(.attached(channel: "test", channelSerial: "sync4:", hasObjects: false)) + poll("events.count >= 2") { events.count >= 2 } + + #expect(events.all == ["SYNCING", "SYNCED"]) + } +} + +private extension RealtimeObjectTests { + + func expectError(code: Int, statusCode: Int? = nil, sourceLocation: SourceLocation = #_sourceLocation, _ operation: () async throws -> Void) async { + do { + try await operation() + Issue.record("expected operation to throw ARTErrorInfo with code \(code)", sourceLocation: sourceLocation) + } catch let error as ARTErrorInfo { + #expect(error.code == code, sourceLocation: sourceLocation) + if let statusCode { #expect(error.statusCode == statusCode, sourceLocation: sourceLocation) } + } catch { + Issue.record("expected ARTErrorInfo, got \(error)", sourceLocation: sourceLocation) + } + } + + func expectErrorSync(code: Int, statusCode: Int? = nil, sourceLocation: SourceLocation = #_sourceLocation, _ operation: () throws -> Void) { + do { + try operation() + Issue.record("expected operation to throw ARTErrorInfo with code \(code)", sourceLocation: sourceLocation) + } catch let error as ARTErrorInfo { + #expect(error.code == code, sourceLocation: sourceLocation) + if let statusCode { #expect(error.statusCode == statusCode, sourceLocation: sourceLocation) } + } catch { + Issue.record("expected ARTErrorInfo, got \(error)", sourceLocation: sourceLocation) + } + } +} diff --git a/Tests/UTS/Tests/Path-Based/ValueTypesTests.swift b/Tests/UTS/Tests/Path-Based/ValueTypesTests.swift new file mode 100644 index 00000000..9fcf07af --- /dev/null +++ b/Tests/UTS/Tests/Path-Based/ValueTypesTests.swift @@ -0,0 +1,317 @@ +import Ably +import Foundation +import Testing +@testable import AblyLiveObjects + +/// Value Types (`RTLCV1`–`RTLCV4`, `RTLMV1`–`RTLMV4`). +/// Derived from https://github.com/ably/specification/blob/0a531c79adfc072c6d1441591f2dd838913dfe73/uts/objects/unit/value_types.md +/// +/// `LiveCounter` / `LiveMap` are immutable blueprints from the static `create()` factories. The +/// spec's `evaluate(vt)` has no public counterpart; consumption happens inside `set()`, so — as in +/// the ably-js translation — evaluation is exercised by publishing via `set()` and inspecting the +/// generated `OBJECT` wire operations (decoded into the internal ``WireObjectOperation``, which +/// retains the outbound-only `*WithObjectId` fields). The `create()`/`count`/`entries` assertions +/// use `@testable` internal access. +@Suite(.serialized) +final class ValueTypesTests: UTSTestCase { + // MARK: - RTLCV3 — LiveCounter.create + + // UTS: objects/unit/RTLCV3/create-with-count-0 + @Test + func test_RTLCV3_LiveCounter_create_with_initial_count() throws { + let vt = LiveCounter.create(initialCount: 42) + + // RTLCV3b: returns a LiveCounter with the internal count. + #expect(vt.count == 42) + // RTLCV3d: the returned value is immutable (a Swift value type — immutable by construction). + } + + // UTS: objects/unit/RTLCV3/create-default-zero-0 + @Test + func test_RTLCV3_LiveCounter_create_defaults_to_0() throws { + // If initialCount omitted, defaults to 0. + let vt = LiveCounter.create() + #expect(vt.count == 0) + } + + // UTS: objects/unit/RTLCV3c/no-validation-at-create-0 + @Test + func test_RTLCV3c_no_validation_at_creation_time() throws { + // DEVIATION (RTLCV3c): spec passes a non-number (`LiveCounter.create("not_a_number")`) to show + // creation performs no validation. Swift's `create(initialCount: Double)` rejects a String at + // compile time, so the non-number input is not expressible. Only the (valid) construction is + // exercised. See deviations.md. + let vt = LiveCounter.create(initialCount: 0) + #expect(vt.count == 0) + } + + // MARK: - RTLCV4 — Consumption generates COUNTER_CREATE ObjectMessage + + // UTS: objects/unit/RTLCV4/evaluate-generates-message-0 + @Test + func test_RTLCV4_consumption_generates_COUNTER_CREATE_ObjectMessage() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test-RTLCV4-consume") + + try await root.asLiveMap().set(key: "name", value: .liveCounter(LiveCounter.create(initialCount: 42))) + + let operations = try sentObjectOperations(ws) + let counterCreateOps = operations.filter { $0.action == .known(.counterCreate) } + #expect(counterCreateOps.count == 1) + + let operation = try #require(counterCreateOps.first) + #expect(operation.action == .known(.counterCreate)) + // objectId starts with "counter:" and contains "@" (RTLCV4f, RTO14). + #expect(operation.objectId.hasPrefix("counter:")) + #expect(operation.objectId.contains("@")) + // counterCreateWithObjectId is set with a nonce (16+ chars) and an initialValue (RTLCV4g3/g4/d). + let withObjectId = try #require(operation.counterCreateWithObjectId) + #expect(withObjectId.nonce.count >= 16) + #expect(!withObjectId.initialValue.isEmpty) + } + + // UTS: objects/unit/RTLCV4g5/retains-local-counter-create-0 + @Test + func test_RTLCV4g5_consumption_retains_local_CounterCreate() async throws { + // DEVIATION (RTLCV4g5): the local CounterCreate (`derivedFrom`) is stripped from the wire + // message, so — as in ably-js — we verify the retained count via the `initialValue` JSON + // string in counterCreateWithObjectId, which encodes the CounterCreate payload. See deviations.md. + let (_, _, root, ws) = try await setupSyncedChannel("test-RTLCV4g5") + + try await root.asLiveMap().set(key: "name", value: .liveCounter(LiveCounter.create(initialCount: 42))) + + let operations = try sentObjectOperations(ws) + let operation = try #require(operations.first { $0.action == .known(.counterCreate) }) + let initialValue = try initialValueJSON(try #require(operation.counterCreateWithObjectId).initialValue) + #expect((initialValue["count"] as? Double) == 42) + } + + // UTS: objects/unit/RTLCV4a/evaluate-validates-count-0 + @Test + func test_RTLCV4a_consumption_validates_count_type() throws { + // DEVIATION (RTLCV4a): spec passes a non-number and expects evaluation to fail with 40003. + // Swift's `create(initialCount: Double)` rejects non-numbers at compile time, so the runtime + // 40003 assertion is not expressible. See deviations.md. + } + + // UTS: objects/unit/RTLCV4/evaluate-zero-count-0 + @Test + func test_RTLCV4_consumption_with_count_0_is_valid() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test-RTLCV4-zero") + + try await root.asLiveMap().set(key: "name", value: .liveCounter(LiveCounter.create(initialCount: 0))) + + let operations = try sentObjectOperations(ws) + let operation = try #require(operations.first { $0.action == .known(.counterCreate) }) + let initialValue = try initialValueJSON(try #require(operation.counterCreateWithObjectId).initialValue) + #expect((initialValue["count"] as? Double) == 0) + } + + // MARK: - RTLMV3 — LiveMap.create + + // UTS: objects/unit/RTLMV3/create-with-entries-0 + @Test + func test_RTLMV3_LiveMap_create_with_entries() throws { + let vt = LiveMap.create(entries: [ + "name": "Alice", + "age": 30, + ]) + + // RTLMV3b: returns a LiveMap with internal entries. + #expect(vt.entries?["name"]?.stringValue == "Alice") + #expect(vt.entries?["age"]?.numberValue == 30) + // RTLMV3d: the returned value is immutable (a Swift value type — immutable by construction). + } + + // UTS: objects/unit/RTLMV3/create-no-entries-0 + @Test + func test_RTLMV3_LiveMap_create_with_no_entries() throws { + // If entries omitted, internal entries is nil. + let vt = LiveMap.create() + #expect(vt.entries == nil) + } + + // MARK: - RTLMV4 — Consumption generates MAP_CREATE ObjectMessage + + // UTS: objects/unit/RTLMV4/evaluate-generates-message-0 + @Test + func test_RTLMV4_consumption_generates_MAP_CREATE_ObjectMessage() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test-RTLMV4-consume") + + try await root.asLiveMap().set(key: "name", value: .liveMap(LiveMap.create(entries: ["name": "Alice"]))) + + let operations = try sentObjectOperations(ws) + let mapCreateOps = operations.filter { $0.action == .known(.mapCreate) } + #expect(mapCreateOps.count == 1) + + let operation = try #require(mapCreateOps.first) + #expect(operation.action == .known(.mapCreate)) + #expect(operation.objectId.hasPrefix("map:")) + let withObjectId = try #require(operation.mapCreateWithObjectId) + #expect(withObjectId.nonce.count >= 16) + #expect(!withObjectId.initialValue.isEmpty) + } + + // UTS: objects/unit/RTLMV4j5/retains-local-map-create-0 + @Test + func test_RTLMV4j5_consumption_retains_local_MapCreate() async throws { + // DEVIATION (RTLMV4j5): the local MapCreate is stripped from the wire message, so — as in + // ably-js — we verify it via the `initialValue` JSON string in mapCreateWithObjectId. See deviations.md. + let (_, _, root, ws) = try await setupSyncedChannel("test-RTLMV4j5") + + try await root.asLiveMap().set(key: "name", value: .liveMap(LiveMap.create(entries: ["name": "Alice"]))) + + let operations = try sentObjectOperations(ws) + let operation = try #require(operations.first { $0.action == .known(.mapCreate) }) + let initialValue = try initialValueJSON(try #require(operation.mapCreateWithObjectId).initialValue) + #expect((initialValue["semantics"] as? Int) == WireMapSemantics.lww) + let entries = try #require(initialValue["entries"] as? [String: Any]) + let nameData = try #require((entries["name"] as? [String: Any])?["data"] as? [String: Any]) + #expect((nameData["string"] as? String) == "Alice") + } + + // UTS: objects/unit/RTLMV4d/entry-value-types-0 + @Test + func test_RTLMV4d_entry_value_type_mapping() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test-RTLMV4d") + + try await root.asLiveMap().set(key: "name", value: .liveMap(LiveMap.create(entries: [ + "str": "hello", + "num": 42, + "bool": true, + "json_arr": [1, 2, 3], + "json_obj": ["key": "value"], + ]))) + + let operations = try sentObjectOperations(ws) + let operation = try #require(operations.first { $0.action == .known(.mapCreate) }) + let initialValue = try initialValueJSON(try #require(operation.mapCreateWithObjectId).initialValue) + let entries = try #require(initialValue["entries"] as? [String: Any]) + + func data(_ key: String) throws -> [String: Any] { + try #require((entries[key] as? [String: Any])?["data"] as? [String: Any]) + } + #expect((try data("str")["string"] as? String) == "hello") // RTLMV4d4 + #expect((try data("num")["number"] as? Double) == 42) // RTLMV4d5 + #expect((try data("bool")["boolean"] as? Bool) == true) // RTLMV4d6 + // JSON values on the wire are JSON-stringified strings (RTLMV4d3). + let jsonArr = try JSONSerialization.jsonObject(with: Data((try data("json_arr")["json"] as? String ?? "").utf8)) as? [Any] + #expect(jsonArr?.count == 3) + let jsonObj = try JSONSerialization.jsonObject(with: Data((try data("json_obj")["json"] as? String ?? "").utf8)) as? [String: Any] + #expect((jsonObj?["key"] as? String) == "value") + } + + // UTS: objects/unit/RTLMV4d1/nested-value-types-0 + @Test + func test_RTLMV4d1_nested_value_types_produce_depth_first_ObjectMessages() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test-RTLMV4d1") + + let innerCounter = LiveCounter.create(initialCount: 10) + let innerMap = LiveMap.create(entries: ["nested_count": .liveCounter(innerCounter)]) + let outer = LiveMap.create(entries: ["child": .liveMap(innerMap)]) + + try await root.asLiveMap().set(key: "name", value: .liveMap(outer)) + + let operations = try sentObjectOperations(ws) + // Only the CREATE operations (there is also a MAP_SET linking `name`). + let createOps = operations.filter { $0.action == .known(.counterCreate) || $0.action == .known(.mapCreate) } + // Depth-first: inner counter, inner map, outer map (RTLMV4k). + #expect(createOps.count == 3) + #expect(createOps[0].action == .known(.counterCreate)) + #expect(createOps[0].objectId.hasPrefix("counter:")) + #expect(createOps[1].action == .known(.mapCreate)) + #expect(createOps[1].objectId.hasPrefix("map:")) + #expect(createOps[2].action == .known(.mapCreate)) + #expect(createOps[2].objectId.hasPrefix("map:")) + + let innerCounterId = createOps[0].objectId + let innerMapId = createOps[1].objectId + + // Inner map's entries reference the inner counter; outer map's entries reference the inner map. + func initialValueEntries(_ operation: WireObjectOperation) throws -> [String: Any] { + let json = try initialValueJSON(try #require(operation.mapCreateWithObjectId).initialValue) + return try #require(json["entries"] as? [String: Any]) + } + let innerMapEntries = try initialValueEntries(createOps[1]) + let nestedCountData = try #require((innerMapEntries["nested_count"] as? [String: Any])?["data"] as? [String: Any]) + #expect((nestedCountData["objectId"] as? String) == innerCounterId) + + let outerMapEntries = try initialValueEntries(createOps[2]) + let childData = try #require((outerMapEntries["child"] as? [String: Any])?["data"] as? [String: Any]) + #expect((childData["objectId"] as? String) == innerMapId) + } + + // UTS: objects/unit/RTLMV4a/evaluate-validates-entries-0 + @Test + func test_RTLMV4a_consumption_validates_entries_type() throws { + // DEVIATION (RTLMV4a): spec passes `LiveMap.create(null)` expecting 40003. Swift's + // `create(entries: [String: LiveMapValue])` rejects null at compile time. Not expressible. See deviations.md. + } + + // UTS: objects/unit/RTLMV4b/evaluate-validates-keys-0 + @Test + func test_RTLMV4b_consumption_validates_key_types() throws { + // DEVIATION (RTLMV4b): spec passes a non-String key (`{ 123: "value" }`) expecting 40003. + // Swift dictionary keys are typed `String`; a non-String key cannot be constructed. Not + // expressible. See deviations.md. + } + + // UTS: objects/unit/RTLMV4c/evaluate-validates-values-0 + @Test + func test_RTLMV4c_consumption_validates_value_types() throws { + // DEVIATION (RTLMV4c): spec passes an unsupported value (a function) expecting 40013. Swift's + // `LiveMapValue` union only constructs from supported types, so an unsupported value is + // rejected at compile time. Not expressible. See deviations.md. + } + + // UTS: objects/unit/RTLMV4e2/empty-entries-0 + @Test + func test_RTLMV4e2_empty_entries_produces_MapCreate_with_empty_entries() async throws { + let (_, _, root, ws) = try await setupSyncedChannel("test-RTLMV4e2") + + try await root.asLiveMap().set(key: "name", value: .liveMap(LiveMap.create())) + + let operations = try sentObjectOperations(ws) + let operation = try #require(operations.first { $0.action == .known(.mapCreate) }) + let initialValue = try initialValueJSON(try #require(operation.mapCreateWithObjectId).initialValue) + let entries = try #require(initialValue["entries"] as? [String: Any]) + #expect(entries.isEmpty) + } + + // UTS: objects/unit/RTLMV4d/map-set-all-types-table-0 + @Test + func test_RTLMV4d_table_driven_value_type_mapping() async throws { + // Table-driven (like ably-js's `scenarios` loop): every supported value type maps to the + // correct `data.*` field on the generated MapCreate entry. + let scenarios: [(input: LiveMapValue, field: String)] = [ + ("hello", "string"), + (42, "number"), + (3.14, "number"), + (0, "number"), + (-1, "number"), + (true, "boolean"), + (false, "boolean"), + ([1, "a"], "json"), + (["k": "v"], "json"), + (.primitive(.data(Data([1, 2, 3]))), "bytes"), + ] + + for (index, scenario) in scenarios.enumerated() { + let (_, _, root, ws) = try await setupSyncedChannel("test-RTLMV4d-table-\(index)") + try await root.asLiveMap().set(key: "test_key", value: .liveMap(LiveMap.create(entries: ["test_key": scenario.input]))) + + let operations = try sentObjectOperations(ws) + let operation = try #require(operations.first { $0.action == .known(.mapCreate) }) + let initialValue = try initialValueJSON(try #require(operation.mapCreateWithObjectId).initialValue) + let entries = try #require(initialValue["entries"] as? [String: Any]) + let data = try #require((entries["test_key"] as? [String: Any])?["data"] as? [String: Any]) + #expect(data[scenario.field] != nil, "expected \(scenario.field) field for scenario \(index)") + } + } +} + +private extension ValueTypesTests { + /// Parses the `initialValue` JSON string (the wire encoding of a `CounterCreate` / `MapCreate`). + func initialValueJSON(_ initialValue: String, sourceLocation: SourceLocation = #_sourceLocation) throws -> [String: Any] { + try #require(try JSONSerialization.jsonObject(with: Data(initialValue.utf8)) as? [String: Any], sourceLocation: sourceLocation) + } +} diff --git a/Tests/UTS/deviations.md b/Tests/UTS/deviations.md new file mode 100644 index 00000000..74dd3196 --- /dev/null +++ b/Tests/UTS/deviations.md @@ -0,0 +1,37 @@ +# UTS Deviations (Path-Based LiveObjects) + +Records where the Swift translations diverge from the UTS pseudocode. Per the `uts-to-swift` +workflow, this file is only for **SDK non-compliance** and **language / mock-capability gaps** — not +for harness-driving differences (those are explained in code comments). + +## Adapted Tests (typed-language gaps) + +These spec cases pass an intentionally wrong-typed argument and expect a runtime error. Swift's typed +`create(...)` signatures and the `LiveMapValue` union reject those inputs **at compile time**, so the +runtime error assertion cannot be written. The test bodies keep the spec point and exercise the valid +construction only, with an inline `// DEVIATION` note. (Same resolution as ably-java.) + +| Spec point | Spec says | SDK behaviour | Affected test | +|---|---|---|---| +| `RTLCV3c` | `LiveCounter.create("not_a_number")` succeeds (no validation at create) | `create(initialCount: Double)` rejects a `String` at compile time | `ValueTypesTests.test_RTLCV3c_no_validation_at_creation_time` | +| `RTLCV4a` | evaluating `LiveCounter.create("not_a_number")` throws `40003` | non-number rejected at compile time | `ValueTypesTests.test_RTLCV4a_consumption_validates_count_type` | +| `RTLMV4a` | `LiveMap.create(null)` → evaluation throws `40003` | `create(entries: [String: LiveMapValue])` rejects `null` at compile time | `ValueTypesTests.test_RTLMV4a_consumption_validates_entries_type` | +| `RTLMV4b` | non-String key `{ 123: "value" }` → `40003` | Swift dictionary keys are typed `String`; non-String key not constructible | `ValueTypesTests.test_RTLMV4b_consumption_validates_key_types` | +| `RTLMV4c` | unsupported value (a function) → `40013` | `LiveMapValue` only constructs from supported types; unsupported value rejected at compile time | `ValueTypesTests.test_RTLMV4c_consumption_validates_value_types` | + +## Adapted Assertions (wire format) + +| Spec point | Spec says | SDK / wire behaviour | Affected test | +|---|---|---|---| +| `RTLCV4g5` | assert `operation.counterCreate.count == 42` on the generated message | the local `CounterCreate` (`derivedFrom`) is stripped from the wire message; the count is verified via the `counterCreateWithObjectId.initialValue` JSON string instead (same as ably-js) | `ValueTypesTests.test_RTLCV4g5_consumption_retains_local_CounterCreate` | +| `RTLMV4j5` | assert `operation.mapCreate.semantics/entries` on the generated message | local `MapCreate` stripped from wire; verified via `mapCreateWithObjectId.initialValue` JSON string | `ValueTypesTests.test_RTLMV4j5_consumption_retains_local_MapCreate` | +| `RTLMV4d` | assert on generated `mapCreate.entries[k].data.` | verified via the `mapCreateWithObjectId.initialValue` JSON string (the retained `mapCreate` is stripped from wire) | `ValueTypesTests.test_RTLMV4d_*` | +| `RTO24b2c` | a subscription listener that **throws** must not affect other listeners | Swift's `PathObjectSubscriptionCallback` is `@Sendable (…) -> Void` (non-throwing), so a throwing listener isn't expressible; the test keeps a no-op first listener and asserts the second still fires | `PathObjectSubscribeTests.test_RTO24b2c_listener_exception_does_not_affect_others` | +| `RTINS12d` / `RTINS14d` / `RTINS15d` / `RTINS16c` | calling a wrong-type operation on an `Instance` (e.g. `set` on a counter, `increment`/`subscribe` on a map/primitive) throws 92007 | Swift models `Instance` as an enum; `set`/`increment`/`decrement`/`subscribe` exist only on the relevant payload protocol, so a wrong-type call can't be written at all — the runtime 92007 path is compile-time-unreachable | `InstanceTests.test_RTINS12d_*` / `test_RTINS14d_*` / `test_RTINS16c_*` (empty, documented) | +| `RTINS10` | `Instance.compact()` recursive compaction | only `compactJson()` is exposed publicly; asserted on the JSON form | `InstanceTests.test_RTINS10_compact_recursively_compacts` | +| `RTINS4d` / `RTINS9c` | `value()`/`size()` return null for the wrong wrapped type | Swift exposes `value`/`size` only on the relevant payload; "returns null" is represented as "not that payload type" | `InstanceTests.test_RTINS4_*` / `test_RTINS9_*` | +| `RTO15` | `channel.object.publish([...])` sends an `OBJECT` PM and returns a `PublishResult` | `publish` / `PublishResult` are internal RealtimeObject members, not on the public `RealtimeObject` protocol (which exposes only `get()` / `on(...)`); the publish path is covered indirectly via the path-object mutation tests (RTO20) | `RealtimeObjectTests.test_RTO15_publish_sends_object_protocol_message` (empty, documented) | + +## Mock Infrastructure Limitations + +_None yet._