diff --git a/.cursor/rules/swift.mdc b/.cursor/rules/swift.mdc index 0af35c32..0ebf7370 100644 --- a/.cursor/rules/swift.mdc +++ b/.cursor/rules/swift.mdc @@ -11,3 +11,17 @@ When writing Swift: - When writing initializer expressions, when the type that is being initialized can be inferred, favour using the implicit `.init(…)` form instead of explicitly writing the type name. - When writing enum value expressions, when the type that is being initialized can be inferred, favour using the implicit `.caseName` form instead of explicitly writing the type name. - When writing JSONValue or WireValue types, favour using the literal syntax enabled by their conformance to the `ExpressibleBy*Literal` protocols where possible. +- When you need to import the following modules inside the AblyLiveObjects library code (that is, in non-test code), do so in the following way: + - Ably: use `import Ably` + - AblyPlugin: use `internal import AblyPlugin` +- When writing an array literal that starts with an initializer expression, start the initializer expression on the line after the opening square bracket of the array literal. That is, instead of writing: + ```swift + objectMessages: [InboundObjectMessage( + id: nil, + ``` + write: + ```swift + objectMessages: [ + InboundObjectMessage( + id: nil, + ``` diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc index 6497a5a9..07362939 100644 --- a/.cursor/rules/testing.mdc +++ b/.cursor/rules/testing.mdc @@ -8,4 +8,13 @@ When writing tests: - Use the Swift Testing framework (`import Testing`), not XCTest. - Do not use `fatalError` in response to a test expectation failure. Favour the usage of Swift Testing's `#require` macro. - Only add labels to test cases or suites when the label is different to the name of the suite `struct` or test method. -- When writing tests, follow the guidelines given under "Attributing tests to a spec point" in the file `CONTRIBUTING.md` in order to tag the unit tests with the relevant specification points. Pay particular attention to the difference between the meaning of `@spec` and `@specPartial` and be sure not to write `@spec` multiple times for the same specification point. +- When writing tests, follow the guidelines given under "Attributing tests to a spec point" in the file `CONTRIBUTING.md` in order to tag the unit tests with the relevant specification points. Make sure to follow the exact format of the comments as described in that file. Pay particular attention to the difference between the meaning of `@spec` and `@specPartial` and be sure not to write `@spec` multiple times for the same specification point. +- When writing tests, make sure to add comments that explain when some piece of test data is not important for the scenario being tested. +- When writing tests, run the tests to check they pass. +- When you need to import the following modules in the tests, do so in the following way: + - Ably: use `import Ably` + - AblyLiveObjects: use `@testable import AblyLiveObjects` + - AblyPlugin: use `import AblyPlugin`; _do not_ do `internal import` +- When you need to pass a logger to internal components in the tests, pass `TestLogger()`. +- When you need to unwrap an optional value in the tests, favour using `#require` instead of `guard let`. +- When creating `testsOnly_` property declarations, do not write generic comments of the form "Test-only access to the private createOperationIsMerged property"; the meaning of these properties is already well understood. diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index 68ded6a6..fa12ed9b 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -2,10 +2,12 @@ import Ably internal import AblyPlugin /// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. -internal final class DefaultRealtimeObjects: RealtimeObjects { +internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolDelegate { // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3. private let mutex = NSLock() + private nonisolated(unsafe) var mutableState: MutableState! + private let coreSDK: CoreSDK private let logger: AblyPlugin.Logger @@ -15,17 +17,98 @@ internal final class DefaultRealtimeObjects: RealtimeObjects { private let receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> private let receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation + internal var testsOnly_objectsPool: ObjectsPool { + mutex.withLock { + mutableState.objectsPool + } + } + + /// If this returns false, it means that there is currently no stored sync sequence ID or SyncObjectsPool + internal var testsOnly_hasSyncSequence: Bool { + mutex.withLock { + mutableState.syncSequence != nil + } + } + + // These drive the testsOnly_waitingForSyncEvents property that informs the test suite when `getRoot()` is waiting for the object sync sequence to complete per RTO1c. + private let waitingForSyncEvents: AsyncStream + private let waitingForSyncEventsContinuation: AsyncStream.Continuation + /// Emits an element whenever `getRoot()` starts waiting for the object sync sequence to complete per RTO1c. + internal var testsOnly_waitingForSyncEvents: AsyncStream { + waitingForSyncEvents + } + + /// Contains the data gathered during an `OBJECT_SYNC` sequence. + private struct SyncSequence { + /// The sync sequence ID, per RTO5a1. + internal var id: String + + /// The `ObjectMessage`s gathered during this sync sequence. + internal var syncObjectsPool: [ObjectState] + } + + /// Tracks whether an object sync sequence has happened yet. This allows us to wait for a sync before returning from `getRoot()`, per RTO1c. + private struct SyncStatus { + private(set) var isSyncComplete = false + private let syncCompletionEvents: AsyncStream + private let syncCompletionContinuation: AsyncStream.Continuation + + internal init() { + (syncCompletionEvents, syncCompletionContinuation) = AsyncStream.makeStream() + } + + internal mutating func signalSyncComplete() { + isSyncComplete = true + syncCompletionContinuation.yield() + } + + internal func waitForSyncCompletion() async { + await syncCompletionEvents.first { _ in true } + } + } + internal init(coreSDK: CoreSDK, logger: AblyPlugin.Logger) { self.coreSDK = coreSDK self.logger = logger (receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream() (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() + (waitingForSyncEvents, waitingForSyncEventsContinuation) = AsyncStream.makeStream() + mutableState = .init(objectsPool: .init(rootDelegate: self, rootCoreSDK: coreSDK)) + } + + // MARK: - LiveMapObjectPoolDelegate + + internal func getObjectFromPool(id: String) -> ObjectsPool.Entry? { + mutex.withLock { + mutableState.objectsPool.entries[id] + } } // MARK: `RealtimeObjects` protocol internal func getRoot() async throws(ARTErrorInfo) -> any LiveMap { - notYetImplemented() + // RTO1b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 + let currentChannelState = coreSDK.channelState + if currentChannelState == .detached || currentChannelState == .failed { + throw ARTErrorInfo.create(withCode: Int(ARTErrorCode.channelOperationFailedInvalidState.rawValue), message: "getRoot operation failed (invalid channel state: \(currentChannelState))") + } + + let syncStatus = mutex.withLock { + mutableState.syncStatus + } + + if !syncStatus.isSyncComplete { + // RTO1c + waitingForSyncEventsContinuation.yield() + logger.log("getRoot started waiting for sync sequence to complete", level: .debug) + await syncStatus.waitForSyncCompletion() + logger.log("getRoot completed waiting for sync sequence to complete", level: .debug) + } + + return mutex.withLock { + // RTO1d + mutableState.objectsPool.root + } } internal func createMap(entries _: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap { @@ -58,16 +141,20 @@ internal final class DefaultRealtimeObjects: RealtimeObjects { // MARK: Handling channel events - private nonisolated(unsafe) var onChannelAttachedHasObjects: Bool? internal var testsOnly_onChannelAttachedHasObjects: Bool? { mutex.withLock { - onChannelAttachedHasObjects + mutableState.onChannelAttachedHasObjects } } internal func onChannelAttached(hasObjects: Bool) { mutex.withLock { - onChannelAttachedHasObjects = hasObjects + mutableState.onChannelAttached( + hasObjects: hasObjects, + logger: logger, + mapDelegate: self, + coreSDK: coreSDK, + ) } } @@ -83,8 +170,27 @@ internal final class DefaultRealtimeObjects: RealtimeObjects { receivedObjectSyncProtocolMessages } - internal func handleObjectSyncProtocolMessage(objectMessages: [InboundObjectMessage], protocolMessageChannelSerial _: String?) { - receivedObjectSyncProtocolMessagesContinuation.yield(objectMessages) + /// Implements the `OBJECT_SYNC` handling of RTO5. + internal func handleObjectSyncProtocolMessage(objectMessages: [InboundObjectMessage], protocolMessageChannelSerial: String?) { + mutex.withLock { + mutableState.handleObjectSyncProtocolMessage( + objectMessages: objectMessages, + protocolMessageChannelSerial: protocolMessageChannelSerial, + logger: logger, + receivedObjectSyncProtocolMessagesContinuation: receivedObjectSyncProtocolMessagesContinuation, + mapDelegate: self, + coreSDK: coreSDK, + ) + } + } + + /// Creates a zero-value LiveObject in the object pool for this object ID. + /// + /// Intended as a way for tests to populate the object pool. + internal func testsOnly_createZeroValueLiveObject(forObjectID objectID: String, coreSDK: CoreSDK) -> ObjectsPool.Entry? { + mutex.withLock { + mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, mapDelegate: self, coreSDK: coreSDK) + } } // MARK: - Sending `OBJECT` ProtocolMessage @@ -93,4 +199,122 @@ internal final class DefaultRealtimeObjects: RealtimeObjects { internal func testsOnly_sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { try await coreSDK.sendObject(objectMessages: objectMessages) } + + // MARK: - Testing + + /// Finishes the following streams, to allow a test to perform assertions about which elements the streams have emitted to this moment: + /// + /// - testsOnly_receivedObjectProtocolMessages + /// - testsOnly_receivedObjectStateProtocolMessages + /// - testsOnly_waitingForSyncEvents + internal func testsOnly_finishAllTestHelperStreams() { + receivedObjectProtocolMessagesContinuation.finish() + receivedObjectSyncProtocolMessagesContinuation.finish() + waitingForSyncEventsContinuation.finish() + } + + // MARK: - Mutable state and the operations that affect it + + private struct MutableState { + internal var objectsPool: ObjectsPool + internal var syncSequence: SyncSequence? + internal var syncStatus = SyncStatus() + internal var onChannelAttachedHasObjects: Bool? + + internal mutating func onChannelAttached( + hasObjects: Bool, + logger: Logger, + mapDelegate: LiveMapObjectPoolDelegate, + coreSDK: CoreSDK, + ) { + logger.log("onChannelAttached(hasObjects: \(hasObjects)", level: .debug) + + onChannelAttachedHasObjects = hasObjects + + // We only care about the case where HAS_OBJECTS is not set (RTO4b); if it is set then we're going to shortly receive an OBJECT_SYNC instead (RTO4a) + guard !hasObjects else { + return + } + + // RTO4b1, RTO4b2: Reset the ObjectsPool to have a single empty root object + // TODO: this one is unclear (are we meant to replace the root or just clear its data?) https://github.com/ably/specification/pull/333/files#r2183493458 + objectsPool = .init(rootDelegate: mapDelegate, rootCoreSDK: coreSDK) + + // I have, for now, not directly implemented the "perform the actions for object sync completion" of RTO4b4 since my implementation doesn't quite match the model given there; here you only have a SyncObjectsPool if you have an OBJECT_SYNC in progress, which you might not have upon receiving an ATTACHED. Instead I've just implemented what seem like the relevant side effects. Can revisit this if "the actions for object sync completion" get more complex. + + // RTO4b3, RTO4b4, RTO5c3, RTO5c4 + syncSequence = nil + syncStatus.signalSyncComplete() + } + + /// Implements the `OBJECT_SYNC` handling of RTO5. + internal mutating func handleObjectSyncProtocolMessage( + objectMessages: [InboundObjectMessage], + protocolMessageChannelSerial: String?, + logger: Logger, + receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, + mapDelegate: LiveMapObjectPoolDelegate, + coreSDK: CoreSDK, + ) { + logger.log("handleObjectSyncProtocolMessage(objectMessages: \(objectMessages), protocolMessageChannelSerial: \(String(describing: protocolMessageChannelSerial)))", level: .debug) + + receivedObjectSyncProtocolMessagesContinuation.yield(objectMessages) + + // If populated, this contains a full set of sync data for the channel, and should be applied to the ObjectsPool. + let completedSyncObjectsPool: [ObjectState]? + + if let protocolMessageChannelSerial { + let syncCursor: SyncCursor + do { + // RTO5a + syncCursor = try SyncCursor(channelSerial: protocolMessageChannelSerial) + } catch { + logger.log("Failed to parse sync cursor: \(error)", level: .error) + return + } + + // Figure out whether to continue any existing sync sequence or start a new one + var updatedSyncSequence: SyncSequence = if let syncSequence { + if syncCursor.sequenceID == syncSequence.id { + // RTO5a3: Continue existing sync sequence + syncSequence + } else { + // RTO5a2: new sequence started, discard previous + .init(id: syncCursor.sequenceID, syncObjectsPool: []) + } + } else { + // There's no current sync sequence; start one + .init(id: syncCursor.sequenceID, syncObjectsPool: []) + } + + // RTO5b + updatedSyncSequence.syncObjectsPool.append(contentsOf: objectMessages.compactMap(\.object)) + + syncSequence = updatedSyncSequence + + completedSyncObjectsPool = if syncCursor.isEndOfSequence { + updatedSyncSequence.syncObjectsPool + } else { + nil + } + } else { + // RTO5a5: The sync data is contained entirely within this single OBJECT_SYNC + completedSyncObjectsPool = objectMessages.compactMap(\.object) + } + + if let completedSyncObjectsPool { + // RTO5c + objectsPool.applySyncObjectsPool( + completedSyncObjectsPool, + mapDelegate: mapDelegate, + coreSDK: coreSDK, + logger: logger, + ) + // RTO5c3, RTO5c4 + syncSequence = nil + + syncStatus.signalSyncComplete() + } + } + } } diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index 8a63d3c0..e43410e4 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -6,6 +6,9 @@ internal import AblyPlugin /// This provides us with a mockable interface to ably-cocoa, and it also allows internal components and their tests not to need to worry about some of the boring details of how we bridge Swift types to AblyPlugin's Objective-C API (i.e. boxing). internal protocol CoreSDK: AnyObject, Sendable { func sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) + + /// Returns the current state of the Realtime channel that this wraps. + var channelState: ARTRealtimeChannelState { get } } internal final class DefaultCoreSDK: CoreSDK { @@ -41,4 +44,8 @@ internal final class DefaultCoreSDK: CoreSDK { pluginAPI: pluginAPI, ) } + + internal var channelState: ARTRealtimeChannelState { + channel.state + } } diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift new file mode 100644 index 00000000..1246d1b2 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift @@ -0,0 +1,152 @@ +import Ably +import Foundation + +/// Our default implementation of ``LiveCounter``. +internal final class DefaultLiveCounter: LiveCounter { + // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3. + private let mutex = NSLock() + + private nonisolated(unsafe) var mutableState: MutableState + + internal var testsOnly_siteTimeserials: [String: String]? { + mutex.withLock { + mutableState.siteTimeserials + } + } + + internal var testsOnly_createOperationIsMerged: Bool? { + mutex.withLock { + mutableState.createOperationIsMerged + } + } + + internal var testsOnly_objectID: String? { + mutex.withLock { + mutableState.objectID + } + } + + private let coreSDK: CoreSDK + + // MARK: - Initialization + + internal convenience init( + testsOnly_data data: Double, + objectID: String?, + coreSDK: CoreSDK + ) { + self.init(data: data, objectID: objectID, coreSDK: coreSDK) + } + + private init( + data: Double, + objectID: String?, + coreSDK: CoreSDK + ) { + mutableState = .init(data: data, objectID: objectID) + self.coreSDK = coreSDK + } + + /// Creates a "zero-value LiveCounter", per RTLC4. + /// + /// - Parameters: + /// - objectID: The value for the "private objectId field" of RTO5c1b1a. + internal static func createZeroValued( + objectID: String? = nil, + coreSDK: CoreSDK, + ) -> Self { + .init( + data: 0, + objectID: objectID, + coreSDK: coreSDK, + ) + } + + // MARK: - LiveCounter conformance + + internal var value: Double { + get throws(ARTErrorInfo) { + // RTLC5b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 + let currentChannelState = coreSDK.channelState + if currentChannelState == .detached || currentChannelState == .failed { + throw ARTErrorInfo.create(withCode: Int(ARTErrorCode.channelOperationFailedInvalidState.rawValue), message: "LiveCounter.value operation failed (invalid channel state: \(currentChannelState))") + } + + return mutex.withLock { + // RTLC5c + mutableState.data + } + } + } + + internal func increment(amount _: Double) async throws(ARTErrorInfo) { + notYetImplemented() + } + + internal func decrement(amount _: Double) async throws(ARTErrorInfo) { + notYetImplemented() + } + + internal func subscribe(listener _: (sending any LiveCounterUpdate) -> Void) -> any SubscribeResponse { + notYetImplemented() + } + + internal func unsubscribeAll() { + notYetImplemented() + } + + internal func on(event _: LiveObjectLifecycleEvent, callback _: () -> Void) -> any OnLiveObjectLifecycleEventResponse { + notYetImplemented() + } + + internal func offAll() { + notYetImplemented() + } + + // MARK: - Data manipulation + + /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. + internal func replaceData(using state: ObjectState) { + mutex.withLock { + mutableState.replaceData(using: state) + } + } + + // MARK: - Mutable state and the operations that affect it + + private struct MutableState { + /// The internal data that this map holds, per RTLC3. + internal var data: Double + + /// The site timeserials for this counter, per RTLC6a. + internal var siteTimeserials: [String: String]? + + /// Whether the create operation has been merged, per RTLC6b and RTLC6d2. + internal var createOperationIsMerged: Bool? + + /// The "private `objectId` field" of RTO5c1b1a. + internal var objectID: String? + + /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. + internal mutating func replaceData(using state: ObjectState) { + // RTLC6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials + siteTimeserials = state.siteTimeserials + + // RTLC6b: Set the private flag createOperationIsMerged to false + createOperationIsMerged = false + + // RTLC6c: Set data to the value of ObjectState.counter.count, or to 0 if it does not exist + data = state.counter?.count?.doubleValue ?? 0 + + // RTLC6d: If ObjectState.createOp is present + if let createOp = state.createOp { + // RTLC6d1: Add ObjectState.createOp.counter.count to data, if it exists + if let createOpCount = createOp.counter?.count?.doubleValue { + data += createOpCount + } + // RTLC6d2: Set the private flag createOperationIsMerged to true + createOperationIsMerged = true + } + } + } +} diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift new file mode 100644 index 00000000..2711663a --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift @@ -0,0 +1,444 @@ +import Ably + +/// Protocol for accessing objects from the ObjectsPool. This is used by a LiveMap when it needs to return an object given an object ID. +internal protocol LiveMapObjectPoolDelegate: AnyObject, Sendable { + /// Fetches an object from the pool by its ID + func getObjectFromPool(id: String) -> ObjectsPool.Entry? +} + +/// Our default implementation of ``LiveMap``. +internal final class DefaultLiveMap: LiveMap { + // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3. + private let mutex = NSLock() + + private nonisolated(unsafe) var mutableState: MutableState + + internal var testsOnly_data: [String: ObjectsMapEntry] { + mutex.withLock { + mutableState.data + } + } + + internal var testsOnly_objectID: String? { + mutex.withLock { + mutableState.objectID + } + } + + internal var testsOnly_semantics: WireEnum? { + mutex.withLock { + mutableState.semantics + } + } + + internal var testsOnly_siteTimeserials: [String: String]? { + mutex.withLock { + mutableState.siteTimeserials + } + } + + internal var testsOnly_createOperationIsMerged: Bool? { + mutex.withLock { + mutableState.createOperationIsMerged + } + } + + /// Delegate for accessing objects from the pool + private let delegate: WeakLiveMapObjectPoolDelegateRef + internal var testsOnly_delegate: LiveMapObjectPoolDelegate? { + delegate.referenced + } + + private let coreSDK: CoreSDK + + // MARK: - Initialization + + internal convenience init( + testsOnly_data data: [String: ObjectsMapEntry], + objectID: String? = nil, + testsOnly_semantics semantics: WireEnum? = nil, + delegate: LiveMapObjectPoolDelegate?, + coreSDK: CoreSDK + ) { + self.init( + data: data, + objectID: objectID, + semantics: semantics, + delegate: delegate, + coreSDK: coreSDK, + ) + } + + private init( + data: [String: ObjectsMapEntry], + objectID: String?, + semantics: WireEnum?, + delegate: LiveMapObjectPoolDelegate?, + coreSDK: CoreSDK + ) { + mutableState = .init(data: data, objectID: objectID, semantics: semantics) + self.delegate = .init(referenced: delegate) + self.coreSDK = coreSDK + } + + /// Creates a "zero-value LiveMap", per RTLM4. + /// + /// - Parameters: + /// - objectID: The value to use for the "private `objectId` field" of RTO5c1b1b. + /// - semantics: The value to use for the "private `semantics` field" of RTO5c1b1b. + internal static func createZeroValued( + objectID: String? = nil, + semantics: WireEnum? = nil, + delegate: LiveMapObjectPoolDelegate?, + coreSDK: CoreSDK, + ) -> Self { + .init( + data: [:], + objectID: objectID, + semantics: semantics, + delegate: delegate, + coreSDK: coreSDK, + ) + } + + // MARK: - LiveMap conformance + + /// Returns the value associated with a given key, following RTLM5d specification. + internal func get(key: String) throws(ARTErrorInfo) -> LiveMapValue? { + // RTLM5c: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 + let currentChannelState = coreSDK.channelState + if currentChannelState == .detached || currentChannelState == .failed { + throw ARTErrorInfo.create(withCode: Int(ARTErrorCode.channelOperationFailedInvalidState.rawValue), message: "LiveMap.get operation failed (invalid channel state: \(currentChannelState))") + } + + let entry = mutex.withLock { + mutableState.data[key] + } + + // RTLM5d1: If no ObjectsMapEntry exists at the key, return undefined/null + guard let entry else { + return nil + } + + // RTLM5d2: If a ObjectsMapEntry exists at the key + + // RTLM5d2a: If ObjectsMapEntry.tombstone is true, return undefined/null + if entry.tombstone == true { + return nil + } + + // Handle primitive values in the order specified by RTLM5d2b through RTLM5d2e + + // RTLM5d2b: If ObjectsMapEntry.data.boolean exists, return it + if let boolean = entry.data.boolean { + return .primitive(.bool(boolean)) + } + + // RTLM5d2c: If ObjectsMapEntry.data.bytes exists, return it + if let bytes = entry.data.bytes { + return .primitive(.data(bytes)) + } + + // RTLM5d2d: If ObjectsMapEntry.data.number exists, return it + if let number = entry.data.number { + return .primitive(.number(number.doubleValue)) + } + + // RTLM5d2e: If ObjectsMapEntry.data.string exists, return it + if let string = entry.data.string { + switch string { + case let .string(string): + return .primitive(.string(string)) + case .json: + // TODO: Understand how to handle JSON values (https://github.com/ably/specification/pull/333/files#r2164561055) + notYetImplemented() + } + } + + // RTLM5d2f: If ObjectsMapEntry.data.objectId exists, get the object stored at that objectId from the internal ObjectsPool + if let objectId = entry.data.objectId { + // RTLM5d2f1: If an object with id objectId does not exist, return undefined/null + guard let poolEntry = delegate.referenced?.getObjectFromPool(id: objectId) else { + return nil + } + + // RTLM5d2f2: If an object with id objectId exists, return it + switch poolEntry { + case let .map(map): + return .liveMap(map) + case let .counter(counter): + return .liveCounter(counter) + } + } + + // RTLM5d2g: Otherwise, return undefined/null + return nil + } + + internal var size: Int { + mutex.withLock { + // TODO: this is not yet specified, but it seems like the obvious right thing and it unlocks some integration tests; add spec point once specified + mutableState.data.count + } + } + + internal var entries: [(key: String, value: LiveMapValue)] { + notYetImplemented() + } + + internal var keys: [String] { + notYetImplemented() + } + + internal var values: [LiveMapValue] { + notYetImplemented() + } + + internal func set(key _: String, value _: LiveMapValue) async throws(ARTErrorInfo) { + notYetImplemented() + } + + internal func remove(key _: String) async throws(ARTErrorInfo) { + notYetImplemented() + } + + internal func subscribe(listener _: (sending any LiveMapUpdate) -> Void) -> any SubscribeResponse { + notYetImplemented() + } + + internal func unsubscribeAll() { + notYetImplemented() + } + + internal func on(event _: LiveObjectLifecycleEvent, callback _: () -> Void) -> any OnLiveObjectLifecycleEventResponse { + notYetImplemented() + } + + internal func offAll() { + notYetImplemented() + } + + // MARK: - Data manipulation + + /// Replaces the internal data of this map with the provided ObjectState, per RTLM6. + /// + /// - Parameters: + /// - objectsPool: The pool into which should be inserted any objects created by a `MAP_SET` operation. + internal func replaceData(using state: ObjectState, objectsPool: inout ObjectsPool) { + mutex.withLock { + mutableState.replaceData( + using: state, + objectsPool: &objectsPool, + mapDelegate: delegate.referenced, + coreSDK: coreSDK, + ) + } + } + + /// Applies a `MAP_SET` operation to a key, per RTLM7. + /// + /// This is currently exposed just so that the tests can test RTLM7 without having to go through a convoluted replaceData(…) call, but I _think_ that it's going to be used in further contexts when we introduce the handling of incoming object operations in a future spec PR. + internal func testsOnly_applyMapSetOperation( + key: String, + operationTimeserial: String?, + operationData: ObjectData, + objectsPool: inout ObjectsPool, + ) { + mutex.withLock { + mutableState.applyMapSetOperation( + key: key, + operationTimeserial: operationTimeserial, + operationData: operationData, + objectsPool: &objectsPool, + mapDelegate: delegate.referenced, + coreSDK: coreSDK, + ) + } + } + + /// Applies a `MAP_REMOVE` operation to a key, per RTLM8. + /// + /// This is currently exposed just so that the tests can test RTLM8 without having to go through a convoluted replaceData(…) call, but I _think_ that it's going to be used in further contexts when we introduce the handling of incoming object operations in a future spec PR. + internal func testsOnly_applyMapRemoveOperation(key: String, operationTimeserial: String?) { + mutex.withLock { + mutableState.applyMapRemoveOperation( + key: key, + operationTimeserial: operationTimeserial, + ) + } + } + + // MARK: - Mutable state and the operations that affect it + + private struct MutableState { + /// The internal data that this map holds, per RTLM3. + internal var data: [String: ObjectsMapEntry] + + /// The "private `objectId` field" of RTO5c1b1b. + internal var objectID: String? + + /// The "private `semantics` field" of RTO5c1b1b. + internal var semantics: WireEnum? + + /// The site timeserials for this map, per RTLM6a. + internal var siteTimeserials: [String: String]? + + /// Whether the create operation has been merged, per RTLM6b and RTLM6d2. + internal var createOperationIsMerged: Bool? + + /// Replaces the internal data of this map with the provided ObjectState, per RTLM6. + /// + /// - Parameters: + /// - objectsPool: The pool into which should be inserted any objects created by a `MAP_SET` operation. + internal mutating func replaceData( + using state: ObjectState, + objectsPool: inout ObjectsPool, + mapDelegate: LiveMapObjectPoolDelegate?, + coreSDK: CoreSDK, + ) { + // RTLM6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials + siteTimeserials = state.siteTimeserials + + // RTLM6b: Set the private flag createOperationIsMerged to false + createOperationIsMerged = false + + // RTLM6c: Set data to ObjectState.map.entries, or to an empty map if it does not exist + data = state.map?.entries ?? [:] + + // RTLM6d: If ObjectState.createOp is present + if let createOp = state.createOp { + // RTLM6d1: For each key–ObjectsMapEntry pair in ObjectState.createOp.map.entries + if let entries = createOp.map?.entries { + for (key, entry) in entries { + if entry.tombstone == true { + // RTLM6d1b: If ObjectsMapEntry.tombstone is true, apply the MAP_REMOVE operation + // to the specified key using ObjectsMapEntry.timeserial per RTLM8 + applyMapRemoveOperation( + key: key, + operationTimeserial: entry.timeserial, + ) + } else { + // RTLM6d1a: If ObjectsMapEntry.tombstone is false, apply the MAP_SET operation + // to the specified key using ObjectsMapEntry.timeserial and ObjectsMapEntry.data per RTLM7 + applyMapSetOperation( + key: key, + operationTimeserial: entry.timeserial, + operationData: entry.data, + objectsPool: &objectsPool, + mapDelegate: mapDelegate, + coreSDK: coreSDK, + ) + } + } + } + // RTLM6d2: Set the private flag createOperationIsMerged to true + createOperationIsMerged = true + } + } + + /// Applies a `MAP_SET` operation to a key, per RTLM7. + internal mutating func applyMapSetOperation( + key: String, + operationTimeserial: String?, + operationData: ObjectData, + objectsPool: inout ObjectsPool, + mapDelegate: LiveMapObjectPoolDelegate?, + coreSDK: CoreSDK, + ) { + // RTLM7a: If an entry exists in the private data for the specified key + if let existingEntry = data[key] { + // RTLM7a1: If the operation cannot be applied as per RTLM9, discard the operation + if !Self.canApplyMapOperation(entryTimeserial: existingEntry.timeserial, operationTimeserial: operationTimeserial) { + return + } + // RTLM7a2: Otherwise, apply the operation + // RTLM7a2a: Set ObjectsMapEntry.data to the ObjectData from the operation + // RTLM7a2b: Set ObjectsMapEntry.timeserial to the operation's serial + // RTLM7a2c: Set ObjectsMapEntry.tombstone to false + var updatedEntry = existingEntry + updatedEntry.data = operationData + updatedEntry.timeserial = operationTimeserial + updatedEntry.tombstone = false + data[key] = updatedEntry + } else { + // RTLM7b: If an entry does not exist in the private data for the specified key + // RTLM7b1: Create a new entry in data for the specified key with the provided ObjectData and the operation's serial + // RTLM7b2: Set ObjectsMapEntry.tombstone for the new entry to false + data[key] = ObjectsMapEntry(tombstone: false, timeserial: operationTimeserial, data: operationData) + } + + // RTLM7c: If the operation has a non-empty ObjectData.objectId attribute + if let objectId = operationData.objectId, !objectId.isEmpty { + // RTLM7c1: Create a zero-value LiveObject in the internal ObjectsPool per RTO6 + _ = objectsPool.createZeroValueObject(forObjectID: objectId, mapDelegate: mapDelegate, coreSDK: coreSDK) + } + } + + /// Applies a `MAP_REMOVE` operation to a key, per RTLM8. + internal mutating func applyMapRemoveOperation(key: String, operationTimeserial: String?) { + // (Note that, where the spec tells us to set ObjectsMapEntry.data to nil, we actually set it to an empty ObjectData, which is equivalent, since it contains no data) + + // RTLM8a: If an entry exists in the private data for the specified key + if let existingEntry = data[key] { + // RTLM8a1: If the operation cannot be applied as per RTLM9, discard the operation + if !Self.canApplyMapOperation(entryTimeserial: existingEntry.timeserial, operationTimeserial: operationTimeserial) { + return + } + // RTLM8a2: Otherwise, apply the operation + // RTLM8a2a: Set ObjectsMapEntry.data to undefined/null + // RTLM8a2b: Set ObjectsMapEntry.timeserial to the operation's serial + // RTLM8a2c: Set ObjectsMapEntry.tombstone to true + var updatedEntry = existingEntry + updatedEntry.data = ObjectData() + updatedEntry.timeserial = operationTimeserial + updatedEntry.tombstone = true + data[key] = updatedEntry + } else { + // RTLM8b: If an entry does not exist in the private data for the specified key + // RTLM8b1: Create a new entry in data for the specified key, with ObjectsMapEntry.data set to undefined/null and the operation's serial + // RTLM8b2: Set ObjectsMapEntry.tombstone for the new entry to true + data[key] = ObjectsMapEntry(tombstone: true, timeserial: operationTimeserial, data: ObjectData()) + } + } + + /// Determines whether a map operation can be applied to a map entry, per RTLM9. + private static func canApplyMapOperation(entryTimeserial: String?, operationTimeserial: String?) -> Bool { + // I am going to treat "exists" and "is non-empty" as equivalent here, because the spec mentions "null or empty" in some places and is vague in others. + func normalize(timeserial: String?) -> String? { + // swiftlint:disable:next empty_string + timeserial == "" ? nil : timeserial + } + + let ( + normalizedEntryTimeserial, + normalizedOperationTimeserial + ) = ( + normalize(timeserial: entryTimeserial), + normalize(timeserial: operationTimeserial), + ) + + return switch (normalizedEntryTimeserial, normalizedOperationTimeserial) { + case let (.some(normalizedEntryTimeserial), .some(normalizedOperationTimeserial)): + // RTLM9a: For a LiveMap using LWW (Last-Write-Wins) CRDT semantics, the operation must + // only be applied if its serial is strictly greater ("after") than the entry's serial + // when compared lexicographically + // RTLM9e: If both serials exist, compare them lexicographically and allow operation + // to be applied only if the operation's serial is greater than the entry's serial + normalizedOperationTimeserial > normalizedEntryTimeserial + case (nil, .some): + // RTLM9d: If only the operation serial exists, it is considered greater than the missing + // entry serial, so the operation can be applied + true + case (.some, nil): + // RTLM9c: If only the entry serial exists, the missing operation serial is considered lower + // than the existing entry serial, so the operation must not be applied + false + case (nil, nil): + // RTLM9b: If both the entry serial and the operation serial are null or empty strings, + // they are treated as the "earliest possible" serials and considered "equal", + // so the operation must not be applied + false + } + } + } +} diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift new file mode 100644 index 00000000..11d8d001 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -0,0 +1,197 @@ +internal import AblyPlugin + +/// Maintains the list of objects present on a channel, as described by RTO3. +/// +/// Note that this is a value type. +internal struct ObjectsPool { + /// The possible `ObjectsPool` entries, as described by RTO3a. + internal enum Entry { + case map(DefaultLiveMap) + case counter(DefaultLiveCounter) + + /// Convenience getter for accessing the map value if this entry is a map + internal var mapValue: DefaultLiveMap? { + switch self { + case let .map(map): + map + case .counter: + nil + } + } + + /// Convenience getter for accessing the counter value if this entry is a counter + internal var counterValue: DefaultLiveCounter? { + switch self { + case .map: + nil + case let .counter(counter): + counter + } + } + } + + /// Keyed by `objectId`. + /// + /// Per RTO3b, always contains an entry for `ObjectsPool.rootKey`, and this entry is always of type `map`. + internal private(set) var entries: [String: Entry] + + /// The key under which the root object is stored. + internal static let rootKey = "root" + + // MARK: - Initialization + + /// Creates an `ObjectsPool` whose root is a zero-value `LiveMap`. + internal init( + rootDelegate: LiveMapObjectPoolDelegate?, + rootCoreSDK: CoreSDK, + testsOnly_otherEntries otherEntries: [String: Entry]? = nil, + ) { + self.init( + rootDelegate: rootDelegate, + rootCoreSDK: rootCoreSDK, + otherEntries: otherEntries, + ) + } + + private init( + rootDelegate: LiveMapObjectPoolDelegate?, + rootCoreSDK: CoreSDK, + otherEntries: [String: Entry]? + ) { + entries = otherEntries ?? [:] + // TODO: What initial root entry to use? https://github.com/ably/specification/pull/333/files#r2152312933 + entries[Self.rootKey] = .map(.createZeroValued(delegate: rootDelegate, coreSDK: rootCoreSDK)) + } + + // MARK: - Typed root + + /// Fetches the root object. + internal var root: DefaultLiveMap { + guard let rootEntry = entries[Self.rootKey] else { + preconditionFailure("ObjectsPool should always contain a root object") + } + + switch rootEntry { + case let .map(map): + return map + case .counter: + preconditionFailure("The ObjectsPool root object must always be a map") + } + } + + // MARK: - Data manipulation + + /// Creates a zero-value object if it does not exist in the pool, per RTO6. This is used when applying a `MAP_SET` operation that contains a reference to another object. + /// + /// - Parameters: + /// - objectID: The ID of the object to create + /// - mapDelegate: The delegate to use for any created LiveMap + /// - coreSDK: The CoreSDK to use for any created LiveObject + /// - Returns: The existing or newly created object + internal mutating func createZeroValueObject(forObjectID objectID: String, mapDelegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK) -> Entry? { + // RTO6a: If an object with objectId exists in ObjectsPool, do not create a new object + if let existingEntry = entries[objectID] { + return existingEntry + } + + // RTO6b: The expected type of the object can be inferred from the provided objectId + // RTO6b1: Split the objectId (formatted as type:hash@timestamp) on the separator : and parse the first part as the type string + let components = objectID.split(separator: ":") + guard let typeString = components.first else { + return nil + } + + // RTO6b2: If the parsed type is map, create a zero-value LiveMap per RTLM4 in the ObjectsPool + // RTO6b3: If the parsed type is counter, create a zero-value LiveCounter per RTLC4 in the ObjectsPool + let entry: Entry + switch typeString { + case "map": + entry = .map(.createZeroValued(objectID: objectID, delegate: mapDelegate, coreSDK: coreSDK)) + case "counter": + entry = .counter(.createZeroValued(objectID: objectID, coreSDK: coreSDK)) + default: + return nil + } + + // Note that already know that the key is not "root" per the above check so there's no risk of breaking the RTO3b invariant that the root object is always a map + entries[objectID] = entry + return entry + } + + /// Applies the objects gathered during an `OBJECT_SYNC` to this `ObjectsPool`, per RTO5c1. + /// + /// - Parameters: + /// - mapDelegate: The delegate to use for any created LiveMap + /// - coreSDK: The CoreSDK to use for any created LiveObject + internal mutating func applySyncObjectsPool( + _ syncObjectsPool: [ObjectState], + mapDelegate: LiveMapObjectPoolDelegate, + coreSDK: CoreSDK, + logger: AblyPlugin.Logger, + ) { + logger.log("applySyncObjectsPool called with \(syncObjectsPool.count) objects", level: .debug) + + // Keep track of object IDs that were received during sync for RTO5c2 + var receivedObjectIds = Set() + + // RTO5c1: For each ObjectState member in the SyncObjectsPool list + for objectState in syncObjectsPool { + receivedObjectIds.insert(objectState.objectId) + + // RTO5c1a: If an object with ObjectState.objectId exists in the internal ObjectsPool + if let existingEntry = entries[objectState.objectId] { + logger.log("Updating existing object with ID: \(objectState.objectId)", level: .debug) + + // RTO5c1a1: Override the internal data for the object as per RTLC6, RTLM6 + switch existingEntry { + case let .map(map): + map.replaceData(using: objectState, objectsPool: &self) + case let .counter(counter): + counter.replaceData(using: objectState) + } + } else { + // RTO5c1b: If an object with ObjectState.objectId does not exist in the internal ObjectsPool + logger.log("Creating new object with ID: \(objectState.objectId)", level: .debug) + + // RTO5c1b1: Create a new LiveObject using the data from ObjectState and add it to the internal ObjectsPool: + let newEntry: Entry? + + if objectState.counter != nil { + // RTO5c1b1a: If ObjectState.counter is present, create a zero-value LiveCounter, + // set its private objectId equal to ObjectState.objectId and override its internal data per RTLC6 + let counter = DefaultLiveCounter.createZeroValued(objectID: objectState.objectId, coreSDK: coreSDK) + counter.replaceData(using: objectState) + newEntry = .counter(counter) + } else if let objectsMap = objectState.map { + // RTO5c1b1b: If ObjectState.map is present, create a zero-value LiveMap, + // set its private objectId equal to ObjectState.objectId, set its private semantics + // equal to ObjectState.map.semantics and override its internal data per RTLM6 + let map = DefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, delegate: mapDelegate, coreSDK: coreSDK) + map.replaceData(using: objectState, objectsPool: &self) + newEntry = .map(map) + } else { + // RTO5c1b1c: Otherwise, log a warning that an unsupported object state message has been received, and discard the current ObjectState without taking any action + logger.log("Unsupported object state message received for objectId: \(objectState.objectId)", level: .warn) + newEntry = nil + } + + if let newEntry { + // Note that we will never replace the root object here, and thus never break the RTO3b invariant that the root object is always a map. This is because the pool always contains a root object and thus we always go through the RTO5c1a branch of the `if` above. + entries[objectState.objectId] = newEntry + } + } + } + + // RTO5c2: Remove any objects from the internal ObjectsPool for which objectIds were not received during the sync sequence + // RTO5c2a: The object with ID "root" must not be removed from ObjectsPool, as per RTO3b + let objectIdsToRemove = Set(entries.keys).subtracting(receivedObjectIds + [Self.rootKey]) + if !objectIdsToRemove.isEmpty { + logger.log("Removing objects with IDs: \(objectIdsToRemove) as they were not in sync", level: .debug) + for objectId in objectIdsToRemove { + entries.removeValue(forKey: objectId) + } + } + + logger.log("applySyncObjectsPool completed. Pool now contains \(entries.count) objects", level: .debug) + } +} diff --git a/Sources/AblyLiveObjects/Protocol/SyncCursor.swift b/Sources/AblyLiveObjects/Protocol/SyncCursor.swift new file mode 100644 index 00000000..d3d92ebc --- /dev/null +++ b/Sources/AblyLiveObjects/Protocol/SyncCursor.swift @@ -0,0 +1,38 @@ +import Foundation + +/// The `OBJECT_SYNC` sync cursor, as extracted from a `channelSerial` per RTO5a1 and RTO5a4. +internal struct SyncCursor { + internal var sequenceID: String + /// `nil` in the case where the objects sync sequence is complete (RTO5a4). + internal var cursorValue: String? + + internal enum Error: Swift.Error { + case channelSerialDoesNotMatchExpectedFormat(String) + } + + /// Creates a `SyncCursor` from the `channelSerial` of an `OBJECT_SYNC` `ProtocolMessage`. + internal init(channelSerial: String) throws(InternalError) { + let scanner = Scanner(string: channelSerial) + scanner.charactersToBeSkipped = nil + + // Get everything up to the colon as the sequence ID + let sequenceID = scanner.scanUpToString(":") ?? "" + + // Check if we have a colon + guard scanner.scanString(":") != nil else { + throw Error.channelSerialDoesNotMatchExpectedFormat(channelSerial).toInternalError() + } + + // Everything after the colon (if anything) is the cursor value + let remainingString = channelSerial[scanner.currentIndex...] + let cursorValue = remainingString.isEmpty ? nil : String(remainingString) + + self.sequenceID = sequenceID + self.cursorValue = cursorValue + } + + /// Whether this cursor represents the end of the sync sequence, per RTO5a4. + internal var isEndOfSequence: Bool { + cursorValue == nil + } +} diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index ed77e33e..425953b7 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -82,6 +82,52 @@ public enum LiveMapValue: Sendable { case primitive(PrimitiveObjectValue) case liveMap(any LiveMap) case liveCounter(any LiveCounter) + + // MARK: - Convenience getters for associated values + + /// If this `LiveMapValue` has case `primitive`, this returns the associated value. Else, it returns `nil`. + public var primitiveValue: PrimitiveObjectValue? { + if case let .primitive(value) = self { + return value + } + return nil + } + + /// If this `LiveMapValue` has case `liveMap`, this returns the associated value. Else, it returns `nil`. + public var liveMapValue: (any LiveMap)? { + if case let .liveMap(value) = self { + return value + } + return nil + } + + /// If this `LiveMapValue` has case `liveCounter`, this returns the associated value. Else, it returns `nil`. + public var liveCounterValue: (any LiveCounter)? { + if case let .liveCounter(value) = self { + return value + } + return nil + } + + /// If this `LiveMapValue` has case `primitive` with a string value, this returns that value. Else, it returns `nil`. + public var stringValue: String? { + primitiveValue?.stringValue + } + + /// If this `LiveMapValue` has case `primitive` with a number value, this returns that value. Else, it returns `nil`. + public var numberValue: Double? { + primitiveValue?.numberValue + } + + /// If this `LiveMapValue` has case `primitive` with a boolean value, this returns that value. Else, it returns `nil`. + public var boolValue: Bool? { + primitiveValue?.boolValue + } + + /// If this `LiveMapValue` has case `primitive` with a data value, this returns that value. Else, it returns `nil`. + public var dataValue: Data? { + primitiveValue?.dataValue + } } /// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. @@ -162,7 +208,7 @@ public protocol LiveMap: LiveObject where Update == LiveMapUpdate { /// /// - Parameter key: The key to retrieve the value for. /// - Returns: A ``LiveObject``, a primitive type (string, number, boolean, or binary data) or `nil` if the key doesn't exist in a map or the associated ``LiveObject`` has been deleted. Always `nil` if this map object is deleted. - func get(key: String) -> LiveMapValue? + func get(key: String) throws(ARTErrorInfo) -> LiveMapValue? /// Returns the number of key-value pairs in the map. var size: Int { get } @@ -219,12 +265,46 @@ public enum PrimitiveObjectValue: Sendable { case number(Double) case bool(Bool) case data(Data) + + // MARK: - Convenience getters for associated values + + /// If this `PrimitiveObjectValue` has case `string`, this returns the associated value. Else, it returns `nil`. + public var stringValue: String? { + if case let .string(value) = self { + return value + } + return nil + } + + /// If this `PrimitiveObjectValue` has case `number`, this returns the associated value. Else, it returns `nil`. + public var numberValue: Double? { + if case let .number(value) = self { + return value + } + return nil + } + + /// If this `PrimitiveObjectValue` has case `bool`, this returns the associated value. Else, it returns `nil`. + public var boolValue: Bool? { + if case let .bool(value) = self { + return value + } + return nil + } + + /// If this `PrimitiveObjectValue` has case `data`, this returns the associated value. Else, it returns `nil`. + public var dataValue: Data? { + if case let .data(value) = self { + return value + } + return nil + } } /// The `LiveCounter` class represents a counter that can be incremented or decremented and is synchronized across clients in realtime. public protocol LiveCounter: LiveObject where Update == LiveCounterUpdate { /// Returns the current value of the counter. - var value: Double { get } + var value: Double { get throws(ARTErrorInfo) } /// Sends an operation to the Ably system to increment the value of this `LiveCounter` object. /// diff --git a/Sources/AblyLiveObjects/Utility/WeakRef.swift b/Sources/AblyLiveObjects/Utility/WeakRef.swift index c7175a7e..0678728a 100644 --- a/Sources/AblyLiveObjects/Utility/WeakRef.swift +++ b/Sources/AblyLiveObjects/Utility/WeakRef.swift @@ -6,3 +6,11 @@ internal struct WeakRef { } extension WeakRef: Sendable where Referenced: Sendable {} + +// MARK: - Specialized versions of WeakRef + +// These are protocol-specific versions of ``WeakRef`` that hold an existential type (e.g. `any CoreSDK`). (This is because the compiler complains that an existential of a class-bound protocol doesn't conform to `AnyObject`.) + +internal struct WeakLiveMapObjectPoolDelegateRef: Sendable { + internal weak var referenced: LiveMapObjectPoolDelegate? +} diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift new file mode 100644 index 00000000..973e1e6e --- /dev/null +++ b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift @@ -0,0 +1,130 @@ +@testable import AblyLiveObjects +import AblyPlugin +import Foundation +import Testing + +struct DefaultLiveCounterTests { + /// Tests for the `value` property, covering RTLC5 specification points + struct ValueTests { + // @spec RTLC5b + @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) + func valueThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { + let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: channelState)) + + #expect { + _ = try counter.value + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 90001 + } + } + + // @spec RTLC5c + @Test + func valueReturnsCurrentDataWhenChannelIsValid() throws { + let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attached)) + + // Set some test data + counter.replaceData(using: TestFactories.counterObjectState(count: 42)) + + #expect(try counter.value == 42) + } + } + + /// Tests for the `replaceData` method, covering RTLC6 specification points + struct ReplaceDataTests { + // @spec RTLC6a + @Test + func replacesSiteTimeserials() { + let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let state = TestFactories.counterObjectState( + siteTimeserials: ["site1": "ts1"], // Test value + ) + counter.replaceData(using: state) + #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) + } + + /// Tests for the case where createOp is not present + struct WithoutCreateOpTests { + // @spec RTLC6b - Tests the case without createOp, as RTLC6d2 takes precedence when createOp exists + @Test + func setsCreateOperationIsMergedToFalse() { + let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let state = TestFactories.counterObjectState( + createOp: nil, // Test value - must be nil to test RTLC6b + ) + counter.replaceData(using: state) + #expect(counter.testsOnly_createOperationIsMerged == false) + } + + // @specOneOf(1/4) RTLC6c - count but no createOp + @Test + func setsDataToCounterCount() throws { + let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let state = TestFactories.counterObjectState( + count: 42, // Test value + ) + counter.replaceData(using: state) + #expect(try counter.value == 42) + } + + // @specOneOf(2/4) RTLC6c - no count, no createOp + @Test + func setsDataToZeroWhenCounterCountDoesNotExist() throws { + let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + counter.replaceData(using: TestFactories.counterObjectState( + count: nil, // Test value - must be nil + )) + #expect(try counter.value == 0) + } + } + + /// Tests for RTLC6d (with createOp present) + struct WithCreateOpTests { + // @specOneOf(1/2) RTLC6d1 - with count + // @specOneOf(3/4) RTLC6c - count and createOp + @Test + func setsDataToCounterCountThenAddsCreateOpCounterCount() throws { + let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let state = TestFactories.counterObjectState( + createOp: TestFactories.counterCreateOperation(count: 10), // Test value - must exist + count: 5, // Test value - must exist + ) + counter.replaceData(using: state) + #expect(try counter.value == 15) // First sets to 5 (RTLC6c) then adds 10 (RTLC6d1) + } + + // @specOneOf(2/2) RTLC6d1 - no count + // @specOneOf(4/4) RTLC6c - no count but createOp + @Test + func doesNotModifyDataWhenCreateOpCounterCountDoesNotExist() throws { + let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let state = TestFactories.counterObjectState( + createOp: TestFactories.objectOperation( + action: .known(.counterCreate), + counter: nil, // Test value - must be nil + ), + count: 5, // Test value + ) + counter.replaceData(using: state) + #expect(try counter.value == 5) // Only the base counter.count value + } + + // @spec RTLC6d2 + @Test + func setsCreateOperationIsMergedToTrue() { + let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let state = TestFactories.counterObjectState( + createOp: TestFactories.objectOperation( // Test value - must be non-nil + action: .known(.counterCreate), + ), + ) + counter.replaceData(using: state) + #expect(counter.testsOnly_createOperationIsMerged == true) + } + } + } +} diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift new file mode 100644 index 00000000..c0d3725b --- /dev/null +++ b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift @@ -0,0 +1,629 @@ +@testable import AblyLiveObjects +import AblyPlugin +import Foundation +import Testing + +struct DefaultLiveMapTests { + /// Tests for the `get` method, covering RTLM5 specification points + struct GetTests { + // @spec RTLM5c + @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) + func getThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { + let map = DefaultLiveMap.createZeroValued(delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState)) + + #expect { + _ = try map.get(key: "test") + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 90001 + } + } + + // MARK: - RTLM5d Tests + + // @spec RTLM5d1 + @Test + func returnsNilWhenNoEntryExists() throws { + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(delegate: MockLiveMapObjectPoolDelegate(), coreSDK: coreSDK) + #expect(try map.get(key: "nonexistent") == nil) + } + + // @spec RTLM5d2a + @Test + func returnsNilWhenEntryIsTombstoned() throws { + let entry = TestFactories.mapEntry( + tombstone: true, + data: ObjectData(boolean: true), // Value doesn't matter as it's tombstoned + ) + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: nil, coreSDK: coreSDK) + #expect(try map.get(key: "key") == nil) + } + + // @spec RTLM5d2b + @Test + func returnsBooleanValue() throws { + let entry = TestFactories.mapEntry(data: ObjectData(boolean: true)) + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: nil, coreSDK: coreSDK) + let result = try map.get(key: "key") + #expect(result?.boolValue == true) + } + + // @spec RTLM5d2c + @Test + func returnsBytesValue() throws { + let bytes = Data([0x01, 0x02, 0x03]) + let entry = TestFactories.mapEntry(data: ObjectData(bytes: bytes)) + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: nil, coreSDK: coreSDK) + let result = try map.get(key: "key") + #expect(result?.dataValue == bytes) + } + + // @spec RTLM5d2d + @Test + func returnsNumberValue() throws { + let entry = TestFactories.mapEntry(data: ObjectData(number: NSNumber(value: 123.456))) + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: nil, coreSDK: coreSDK) + let result = try map.get(key: "key") + #expect(result?.numberValue == 123.456) + } + + // @spec RTLM5d2e + @Test + func returnsStringValue() throws { + let entry = TestFactories.mapEntry(data: ObjectData(string: .string("test"))) + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: nil, coreSDK: coreSDK) + let result = try map.get(key: "key") + #expect(result?.stringValue == "test") + } + + // @spec RTLM5d2f1 + @Test + func returnsNilWhenReferencedObjectDoesNotExist() throws { + let entry = TestFactories.mapEntry(data: ObjectData(objectId: "missing")) + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: delegate, coreSDK: coreSDK) + #expect(try map.get(key: "key") == nil) + } + + // @specOneOf(1/2) RTLM5d2f2 - Returns referenced map when it exists in pool + @Test + func returnsReferencedMap() throws { + let objectId = "map1" + let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let referencedMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + delegate.objects[objectId] = .map(referencedMap) + + let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: delegate, coreSDK: coreSDK) + let result = try map.get(key: "key") + let returnedMap = result?.liveMapValue + #expect(returnedMap as AnyObject === referencedMap as AnyObject) + } + + // @specOneOf(2/2) RTLM5d2f2 - Returns referenced counter when it exists in pool + @Test + func returnsReferencedCounter() throws { + let objectId = "counter1" + let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let referencedCounter = DefaultLiveCounter.createZeroValued(coreSDK: coreSDK) + delegate.objects[objectId] = .counter(referencedCounter) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: delegate, coreSDK: coreSDK) + let result = try map.get(key: "key") + let returnedCounter = result?.liveCounterValue + #expect(returnedCounter as AnyObject === referencedCounter as AnyObject) + } + + // @spec RTLM5d2g + @Test + func returnsNullOtherwise() throws { + let entry = TestFactories.mapEntry(data: ObjectData()) + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + + let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: delegate, coreSDK: coreSDK) + #expect(try map.get(key: "key") == nil) + } + } + + /// Tests for the `replaceData` method, covering RTLM6 specification points + struct ReplaceDataTests { + // @spec RTLM6a + @Test + func replacesSiteTimeserials() { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let state = TestFactories.objectState( + objectId: "arbitrary-id", + siteTimeserials: ["site1": "ts1", "site2": "ts2"], + ) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + map.replaceData(using: state, objectsPool: &pool) + #expect(map.testsOnly_siteTimeserials == ["site1": "ts1", "site2": "ts2"]) + } + + // @spec RTLM6b + @Test + func setsCreateOperationIsMergedToFalseWhenCreateOpAbsent() { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let state = TestFactories.objectState(objectId: "arbitrary-id", createOp: nil) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + map.replaceData(using: state, objectsPool: &pool) + #expect(map.testsOnly_createOperationIsMerged == false) + } + + // @specOneOf(1/2) RTLM6c + @Test + func setsDataToMapEntries() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "test") + let state = TestFactories.mapObjectState( + objectId: "arbitrary-id", + entries: [key: entry], + ) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + map.replaceData(using: state, objectsPool: &pool) + let newData = map.testsOnly_data + #expect(newData.count == 1) + #expect(Set(newData.keys) == ["key1"]) + #expect(try map.get(key: "key1")?.stringValue == "test") + } + + // @specOneOf(2/2) RTLM6c - Tests that the map entries get combined with the createOp + // @spec RTLM6d1a + @Test + func appliesMapSetOperationFromCreateOp() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let state = TestFactories.objectState( + objectId: "arbitrary-id", + createOp: TestFactories.mapCreateOperation( + objectId: "arbitrary-id", + entries: [ + "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, + ], + ), + map: ObjectsMap( + semantics: .known(.lww), + entries: [ + "keyFromMapEntries": TestFactories.stringMapEntry(key: "keyFromMapEntries", value: "valueFromMapEntries").entry, + ], + ), + ) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + map.replaceData(using: state, objectsPool: &pool) + // Note that we just check for some basic expected side effects of applying MAP_SET; RTLM7 is tested in more detail elsewhere + // Check that it contains the data from the entries (per RTLM6c) and also the createOp (per RTLM6d1a) + #expect(try map.get(key: "keyFromMapEntries")?.stringValue == "valueFromMapEntries") + #expect(try map.get(key: "keyFromCreateOp")?.stringValue == "valueFromCreateOp") + } + + // @spec RTLM6d1b + @Test + func appliesMapRemoveOperationFromCreateOp() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap( + testsOnly_data: ["key1": TestFactories.stringMapEntry().entry], + delegate: delegate, + coreSDK: coreSDK, + ) + // Confirm that the initial data is there + #expect(try map.get(key: "key1") != nil) + + let entry = TestFactories.mapEntry( + tombstone: true, + data: ObjectData(), + ) + let state = TestFactories.objectState( + objectId: "arbitrary-id", + createOp: TestFactories.mapCreateOperation( + objectId: "arbitrary-id", + entries: ["key1": entry], + ), + ) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + map.replaceData(using: state, objectsPool: &pool) + // Note that we just check for some basic expected side effects of applying MAP_REMOVE; RTLM8 is tested in more detail elsewhere + // Check that MAP_REMOVE removed the initial data + #expect(try map.get(key: "key1") == nil) + } + + // @spec RTLM6d2 + @Test + func setsCreateOperationIsMergedToTrueWhenCreateOpPresent() { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let state = TestFactories.objectState( + objectId: "arbitrary-id", + createOp: TestFactories.mapCreateOperation(objectId: "arbitrary-id"), + ) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + map.replaceData(using: state, objectsPool: &pool) + #expect(map.testsOnly_createOperationIsMerged == true) + } + } + + /// Tests for `MAP_SET` operations, covering RTLM7 specification points + struct MapSetOperationTests { + // MARK: - RTLM7a Tests (Existing Entry) + + struct ExistingEntryTests { + // @spec RTLM7a1 + @Test + func discardsOperationWhenCannotBeApplied() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap( + testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], + delegate: delegate, + coreSDK: coreSDK, + ) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + + // Try to apply operation with lower timeserial (ts1 < ts2) + map.testsOnly_applyMapSetOperation( + key: "key1", + operationTimeserial: "ts1", + operationData: ObjectData(objectId: "new"), + objectsPool: &pool, + ) + + // Verify the operation was discarded - existing data unchanged + #expect(try map.get(key: "key1")?.stringValue == "existing") + // Verify that RTLM7c1 didn't happen (i.e. that we didn't create a zero-value object in the pool for object ID "new") + #expect(Set(pool.entries.keys) == ["root"]) + } + + // @spec RTLM7a2 + // @specOneOf(1/2) RTLM7c1 + @Test(arguments: [ + // Case 1: ObjectData refers to a number value (shouldn't modify the ObjectPool per RTLM7c) + (operationData: ObjectData(number: NSNumber(value: 42)), expectedCreatedObjectID: nil), + // Case 2: ObjectData refers to an object value but the object ID is an empty string (shouldn't modify the ObjectPool per RTLM7c) + (operationData: ObjectData(objectId: ""), expectedCreatedObjectID: nil), + // Case 3: ObjectData refers to an object value (should modify the ObjectPool per RTLM7c and RTLM7c1) + (operationData: ObjectData(objectId: "map:referenced@123"), expectedCreatedObjectID: "map:referenced@123"), + ] as [(operationData: ObjectData, expectedCreatedObjectID: String?)]) + func appliesOperationWhenCanBeApplied(operationData: ObjectData, expectedCreatedObjectID: String?) throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap( + testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: true, timeserial: "ts1", data: ObjectData(string: .string("existing")))], + delegate: delegate, + coreSDK: coreSDK, + ) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + + map.testsOnly_applyMapSetOperation( + key: "key1", + operationTimeserial: "ts2", + operationData: operationData, + objectsPool: &pool, + ) + + // Update the delegate's pool to include any objects created by the MAP_SET operation (so that when we verify RTLM7b1 using map.get it can return a referenced object) + if let expectedCreatedObjectID { + delegate.objects[expectedCreatedObjectID] = pool.entries[expectedCreatedObjectID] + } + + // Verify the operation was applied + let result = try map.get(key: "key1") + if let numberValue = operationData.number { + #expect(result?.numberValue == numberValue.doubleValue) + } else if expectedCreatedObjectID != nil { + #expect(result?.liveMapValue != nil) + } + + // RTLM7a2a: Set ObjectsMapEntry.data to the ObjectData from the operation + #expect(map.testsOnly_data["key1"]?.data.number == operationData.number) + #expect(map.testsOnly_data["key1"]?.data.objectId == operationData.objectId) + + // RTLM7a2b: Set ObjectsMapEntry.timeserial to the operation's serial + #expect(map.testsOnly_data["key1"]?.timeserial == "ts2") + + // RTLM7a2c: Set ObjectsMapEntry.tombstone to false + #expect(map.testsOnly_data["key1"]?.tombstone == false) + + // RTLM7c/RTLM7c1: Check if zero-value object was created in pool + if let expectedCreatedObjectID { + let createdObject = pool.entries[expectedCreatedObjectID] + #expect(createdObject != nil) + #expect(createdObject?.mapValue != nil) + } else { + // For number values, no object should be created + #expect(Set(pool.entries.keys) == ["root"]) + } + } + } + + // MARK: - RTLM7b Tests (No Existing Entry) + + struct NoExistingEntryTests { + // @spec RTLM7b1 + // @spec RTLM7b2 + // @specOneOf(2/2) RTLM7c1 + @Test(arguments: [ + // Case 1: ObjectData refers to a number value (shouldn't modify the ObjectPool per RTLM7c) + (operationData: ObjectData(number: NSNumber(value: 42)), expectedCreatedObjectID: nil), + // Case 2: ObjectData refers to an object value but the object ID is an empty string (shouldn't modify the ObjectPool per RTLM7c) + (operationData: ObjectData(objectId: ""), expectedCreatedObjectID: nil), + // Case 3: ObjectData refers to an object value (should modify the ObjectPool per RTLM7c and RTLM7c1) + (operationData: ObjectData(objectId: "map:referenced@123"), expectedCreatedObjectID: "map:referenced@123"), + ] as [(operationData: ObjectData, expectedCreatedObjectID: String?)]) + func createsNewEntryWhenNoExistingEntry(operationData: ObjectData, expectedCreatedObjectID: String?) throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + + map.testsOnly_applyMapSetOperation( + key: "newKey", + operationTimeserial: "ts1", + operationData: operationData, + objectsPool: &pool, + ) + + // Update the delegate's pool to include any objects created by the MAP_SET operation (so that when we verify RTLM7b1 using map.get it can return a referenced object) + if let expectedCreatedObjectID { + delegate.objects[expectedCreatedObjectID] = pool.entries[expectedCreatedObjectID] + } + + // Verify new entry was created + // RTLM7b1 + let result = try map.get(key: "newKey") + if let numberValue = operationData.number { + #expect(result?.numberValue == numberValue.doubleValue) + } else if expectedCreatedObjectID != nil { + #expect(result?.liveMapValue != nil) + } + let entry = try #require(map.testsOnly_data["newKey"]) + #expect(entry.timeserial == "ts1") + // RTLM7b2 + #expect(entry.tombstone == false) + + // RTLM7c/RTLM7c1: Check if zero-value object was created in pool + if let expectedCreatedObjectID { + let createdObject = try #require(pool.entries[expectedCreatedObjectID]) + #expect(createdObject.mapValue != nil) + } else { + // For number values, no object should be created + #expect(Set(pool.entries.keys) == ["root"]) + } + } + } + + // MARK: - RTLM7c1 Standalone Test (RTO6a Integration) + + // This is a sense check to convince ourselves that when applying a MAP_SET operation that references an object, then, because of RTO6a, if the referenced object already exists in the pool it is not replaced when RTLM7c1 is applied. + @Test + func doesNotReplaceExistingObjectWhenReferencedByMapSet() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + + // Create an existing object in the pool with some data + let existingObjectId = "map:existing@123" + let existingObject = DefaultLiveMap( + testsOnly_data: [:], + delegate: delegate, + coreSDK: coreSDK, + ) + var pool = ObjectsPool( + rootDelegate: delegate, + rootCoreSDK: coreSDK, + testsOnly_otherEntries: [existingObjectId: .map(existingObject)], + ) + // Populate the delegate so that when we "verify the MAP_SET operation was applied correctly" using map.get below it returns the referenced object + delegate.objects[existingObjectId] = pool.entries[existingObjectId] + + // Apply MAP_SET operation that references the existing object + map.testsOnly_applyMapSetOperation( + key: "referenceKey", + operationTimeserial: "ts1", + operationData: ObjectData(objectId: existingObjectId), + objectsPool: &pool, + ) + + // RTO6a: Verify that the existing object was NOT replaced + let objectAfterMapSetValue = try #require(pool.entries[existingObjectId]?.mapValue) + #expect(objectAfterMapSetValue as AnyObject === existingObject as AnyObject) + + // Verify the MAP_SET operation was applied correctly (creates reference in the map) + let referenceValue = try map.get(key: "referenceKey") + #expect(referenceValue?.liveMapValue != nil) + } + } + + /// Tests for `MAP_REMOVE` operations, covering RTLM8 specification points + struct MapRemoveOperationTests { + // MARK: - RTLM8a Tests (Existing Entry) + + struct ExistingEntryTests { + // @spec RTLM8a1 + @Test + func discardsOperationWhenCannotBeApplied() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap( + testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], + delegate: delegate, + coreSDK: coreSDK, + ) + + // Try to apply operation with lower timeserial (ts1 < ts2), cannot be applied per RTLM9 + map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts1") + + // Verify the operation was discarded - existing data unchanged + #expect(try map.get(key: "key1")?.stringValue == "existing") + } + + // @spec RTLM8a2a + // @spec RTLM8a2b + // @spec RTLM8a2c + @Test + func appliesOperationWhenCanBeApplied() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap( + testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: false, timeserial: "ts1", data: ObjectData(string: .string("existing")))], + delegate: delegate, + coreSDK: coreSDK, + ) + + // Apply operation with higher timeserial (ts2 > ts1), so can be applied per RTLM9 + map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts2") + + // Verify the operation was applied + #expect(try map.get(key: "key1") == nil) + + // RTLM8a2a: Set ObjectsMapEntry.data to undefined/null + let entry = map.testsOnly_data["key1"] + #expect(entry?.data.string == nil) + #expect(entry?.data.number == nil) + #expect(entry?.data.boolean == nil) + #expect(entry?.data.bytes == nil) + #expect(entry?.data.objectId == nil) + + // RTLM8a2b: Set ObjectsMapEntry.timeserial to the operation's serial + #expect(map.testsOnly_data["key1"]?.timeserial == "ts2") + + // RTLM8a2c: Set ObjectsMapEntry.tombstone to true + #expect(map.testsOnly_data["key1"]?.tombstone == true) + } + } + + // MARK: - RTLM8b Tests (No Existing Entry) + + struct NoExistingEntryTests { + // @spec RTLM8b1 - Create new entry with ObjectsMapEntry.data set to undefined/null and operation's serial + @Test + func createsNewEntryWhenNoExistingEntry() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + + map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") + + // Verify new entry was created + let entry = map.testsOnly_data["newKey"] + #expect(entry != nil) + #expect(entry?.timeserial == "ts1") + #expect(entry?.data.string == nil) + #expect(entry?.data.number == nil) + #expect(entry?.data.boolean == nil) + #expect(entry?.data.bytes == nil) + #expect(entry?.data.objectId == nil) + } + + // @spec RTLM8b2 - Set ObjectsMapEntry.tombstone for new entry to true + @Test + func setsNewEntryTombstoneToTrue() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + + map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") + + // Verify tombstone is true for new entry + #expect(map.testsOnly_data["newKey"]?.tombstone == true) + } + } + } + + /// Tests for map operation applicability, covering RTLM9 specification points + struct MapOperationApplicabilityTests { + // @spec RTLM9a + // @spec RTLM9b + // @spec RTLM9c + // @spec RTLM9d + // @spec RTLM9e + @Test(arguments: [ + // RTLM9a, RTLM9e: LWW lexicographical comparison - operation can be applied + // Standard case: ts2 > ts1 + (entrySerial: "ts1", operationSerial: "ts2", shouldApply: true), + // Simple lexicographical: b > a + (entrySerial: "a", operationSerial: "b", shouldApply: true), + // Numeric strings: 2 > 1 + (entrySerial: "1", operationSerial: "2", shouldApply: true), + // Longer string comparison: ts10 > ts1 + (entrySerial: "ts1", operationSerial: "ts10", shouldApply: true), + + // RTLM9a, RTLM9e: LWW lexicographical comparison - operation cannot be applied + // Standard case: ts1 < ts2 + (entrySerial: "ts2", operationSerial: "ts1", shouldApply: false), + // Simple lexicographical: a < b + (entrySerial: "b", operationSerial: "a", shouldApply: false), + // Numeric strings: 1 < 2 + (entrySerial: "2", operationSerial: "1", shouldApply: false), + // Longer string comparison: ts1 < ts10 + (entrySerial: "ts10", operationSerial: "ts1", shouldApply: false), + // Equal case: ts1 == ts1 + (entrySerial: "ts1", operationSerial: "ts1", shouldApply: false), + + // RTLM9b: Both serials null or empty - operation cannot be applied + // Both null + (entrySerial: nil, operationSerial: nil, shouldApply: false), + // Both empty strings + (entrySerial: "", operationSerial: "", shouldApply: false), + + // RTLM9c: Only entry serial exists - operation cannot be applied + // Entry has serial, operation doesn't + (entrySerial: "ts1", operationSerial: nil, shouldApply: false), + // Entry has serial, operation empty + (entrySerial: "ts1", operationSerial: "", shouldApply: false), + + // RTLM9d: Only operation serial exists - operation can be applied + // Entry no serial, operation has serial + (entrySerial: nil, operationSerial: "ts1", shouldApply: true), + // Entry empty, operation has serial + (entrySerial: "", operationSerial: "ts1", shouldApply: true), + ] as [(entrySerial: String?, operationSerial: String?, shouldApply: Bool)]) + func mapOperationApplicability(entrySerial: String?, operationSerial: String?, shouldApply: Bool) throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap( + testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: entrySerial, data: ObjectData(string: .string("existing")))], + delegate: delegate, + coreSDK: coreSDK, + ) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + + map.testsOnly_applyMapSetOperation( + key: "key1", + operationTimeserial: operationSerial, + operationData: ObjectData(string: .string("new")), + objectsPool: &pool, + ) + + // We check whether the side effects of the MAP_SET operation have occurred or not as our proxy for checking that the appropriate applicability rules were applied. + + if shouldApply { + // Verify operation was applied + #expect(try map.get(key: "key1")?.stringValue == "new") + } else { + // Verify operation was discarded + #expect(try map.get(key: "key1")?.stringValue == "existing") + } + } + } +} diff --git a/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift new file mode 100644 index 00000000..b70dffd1 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift @@ -0,0 +1,667 @@ +import Ably +@testable import AblyLiveObjects +import AblyPlugin +import Testing + +/// Tests for `DefaultRealtimeObjects`. +struct DefaultRealtimeObjectsTests { + // MARK: - Test Helpers + + /// Creates a DefaultRealtimeObjects instance for testing + static func createDefaultRealtimeObjects(channelState: ARTRealtimeChannelState = .attached) -> DefaultRealtimeObjects { + let coreSDK = MockCoreSDK(channelState: channelState) + let logger = TestLogger() + return DefaultRealtimeObjects(coreSDK: coreSDK, logger: logger) + } + + /// Tests for `DefaultRealtimeObjects.handleObjectSyncProtocolMessage`, covering RTO5 specification points. + struct HandleObjectSyncProtocolMessageTests { + // MARK: - RTO5a5: Single ProtocolMessage Sync Tests + + // @spec RTO5a5 + @Test + func handlesSingleProtocolMessageSync() async throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let objectMessages = [ + TestFactories.simpleMapMessage(objectId: "map:1@123"), + TestFactories.simpleMapMessage(objectId: "map:2@456"), + ] + + // Verify no sync sequence before handling + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + + // Call with no channelSerial (RTO5a5 case) + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: objectMessages, + protocolMessageChannelSerial: nil, + ) + + // Verify sync was applied immediately and sequence was cleared (RTO5c3) + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + + // Verify objects were added to pool (side effect of applySyncObjectsPool per RTO5c1b1b) + let pool = realtimeObjects.testsOnly_objectsPool + #expect(pool.entries["map:1@123"] != nil) + #expect(pool.entries["map:2@456"] != nil) + } + + // MARK: - RTO5a1, RTO5a3, RTO5a4: Multi-ProtocolMessage Sync Tests + + // @spec RTO5a1 + // @spec RTO5a3 + // @spec RTO5a4 + // @spec RTO5b + // @spec RTO5c3 + // @spec RTO5c4 + @Test + func handlesMultiProtocolMessageSync() async throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let sequenceId = "seq123" + + // First message in sequence + let firstMessages = [TestFactories.simpleMapMessage(objectId: "map:1@123")] + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: firstMessages, + protocolMessageChannelSerial: "\(sequenceId):cursor1", + ) + + // Verify sync sequence is active (RTO5a1, RTO5a3) + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // Verify objects not yet applied to pool + let poolAfterFirst = realtimeObjects.testsOnly_objectsPool + #expect(poolAfterFirst.entries["map:1@123"] == nil) + + // Second message in sequence + let secondMessages = [TestFactories.simpleMapMessage(objectId: "map:2@456")] + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: secondMessages, + protocolMessageChannelSerial: "\(sequenceId):cursor2", + ) + + // Verify sync sequence still active + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // Verify objects still not applied to pool + let poolAfterSecond = realtimeObjects.testsOnly_objectsPool + #expect(poolAfterSecond.entries["map:1@123"] == nil) + #expect(poolAfterSecond.entries["map:2@456"] == nil) + + // Final message in sequence (end of sequence per RTO5a4) + let finalMessages = [TestFactories.simpleMapMessage(objectId: "map:3@789")] + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: finalMessages, + protocolMessageChannelSerial: "\(sequenceId):", // Empty cursor indicates end + ) + + // Verify sync sequence is cleared and there is no SyncObjectsPool (RTO5c3, RTO5c4) + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + + // Verify all objects were applied to pool (side effect of applySyncObjectsPool per RTO5c1b1b) + let finalPool = realtimeObjects.testsOnly_objectsPool + #expect(finalPool.entries["map:1@123"] != nil) + #expect(finalPool.entries["map:2@456"] != nil) + #expect(finalPool.entries["map:3@789"] != nil) + } + + // MARK: - RTO5a2: New Sync Sequence Tests + + // @spec RTO5a2 + // @spec RTO5a2a + @Test + func newSequenceIdDiscardsInFlightSync() async throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let firstSequenceId = "seq1" + let secondSequenceId = "seq2" + + // Start first sequence + let firstMessages = [TestFactories.simpleMapMessage(objectId: "map:1@123")] + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: firstMessages, + protocolMessageChannelSerial: "\(firstSequenceId):cursor1", + ) + + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // Start new sequence with different ID (RTO5a2) + let secondMessages = [TestFactories.simpleMapMessage(objectId: "map:2@456")] + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: secondMessages, + protocolMessageChannelSerial: "\(secondSequenceId):cursor1", + ) + + // Verify sync sequence is still active but with new ID + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // Complete the new sequence + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [], + protocolMessageChannelSerial: "\(secondSequenceId):", + ) + + // Verify only the second sequence's objects were applied (RTO5a2a - previous cleared) + let pool = realtimeObjects.testsOnly_objectsPool + #expect(pool.entries["map:1@123"] == nil) // From discarded first sequence + #expect(pool.entries["map:2@456"] != nil) // From completed second sequence + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + } + + // MARK: - RTO5c: Post-Sync Behavior Tests + + // @spec(RTO5c2, RTO5c2a) Objects not in sync are removed, except root + @Test + func removesObjectsNotInSyncButPreservesRoot() async throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // Perform sync with only one object (RTO5a5 case) + let syncMessages = [TestFactories.mapObjectMessage(objectId: "map:synced@1")] + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: syncMessages, + protocolMessageChannelSerial: nil, + ) + + // Verify root is preserved (RTO5c2a) and sync completed (RTO5c3) + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + let finalPool = realtimeObjects.testsOnly_objectsPool + #expect(finalPool.entries["root"] != nil) // Root preserved + #expect(finalPool.entries["map:synced@1"] != nil) // Synced object added + + // Note: We rely on applySyncObjectsPool being tested separately for RTO5c2 removal behavior + // as the side effect of removing pre-existing objects is tested in ObjectsPoolTests + } + + // MARK: - Error Handling Tests + + /// Test handling of invalid channelSerial format + @Test + func handlesInvalidChannelSerialFormat() async throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let objectMessages = [TestFactories.mapObjectMessage(objectId: "map:1@123")] + + // Call with invalid channelSerial (missing colon) + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: objectMessages, + protocolMessageChannelSerial: "invalid_format_no_colon", + ) + + // Verify no sync sequence was created due to parsing error + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + + // Verify objects were not applied to pool + let pool = realtimeObjects.testsOnly_objectsPool + #expect(pool.entries["map:1@123"] == nil) + } + + // MARK: - Edge Cases + + /// Test with empty sequence ID + @Test + func handlesEmptySequenceId() async throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let objectMessages = [TestFactories.mapObjectMessage(objectId: "map:1@123")] + + // Start sequence with empty sequence ID + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: objectMessages, + protocolMessageChannelSerial: ":cursor1", + ) + + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // End sequence with empty sequence ID + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [], + protocolMessageChannelSerial: ":", + ) + + // Verify sequence completed successfully + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + let pool = realtimeObjects.testsOnly_objectsPool + #expect(pool.entries["map:1@123"] != nil) + } + + /// Test mixed object types in single sync + @Test + func handlesMixedObjectTypesInSync() async throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + let mixedMessages = [ + TestFactories.mapObjectMessage(objectId: "map:1@123"), + TestFactories.counterObjectMessage(objectId: "counter:1@456"), + TestFactories.mapObjectMessage(objectId: "map:2@789"), + ] + + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: mixedMessages, + protocolMessageChannelSerial: nil, // Single message sync + ) + + // Verify all object types were processed + let pool = realtimeObjects.testsOnly_objectsPool + #expect(pool.entries["map:1@123"] != nil) + #expect(pool.entries["counter:1@456"] != nil) + #expect(pool.entries["map:2@789"] != nil) + #expect(pool.entries.count == 4) // root + 3 objects + } + + /// Test continuation of sync after interruption by new sequence + @Test + func handlesSequenceInterruptionCorrectly() async throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // Start first sequence + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [TestFactories.mapObjectMessage(objectId: "map:old@1")], + protocolMessageChannelSerial: "oldSeq:cursor1", + ) + + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // Interrupt with new sequence + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [TestFactories.mapObjectMessage(objectId: "map:new@1")], + protocolMessageChannelSerial: "newSeq:cursor1", + ) + + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // Continue new sequence + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [TestFactories.mapObjectMessage(objectId: "map:new@2")], + protocolMessageChannelSerial: "newSeq:cursor2", + ) + + // Complete new sequence + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [], + protocolMessageChannelSerial: "newSeq:", + ) + + // Verify only new sequence objects were applied + let pool = realtimeObjects.testsOnly_objectsPool + #expect(pool.entries["map:old@1"] == nil) // From interrupted sequence + #expect(pool.entries["map:new@1"] != nil) // From completed sequence + #expect(pool.entries["map:new@2"] != nil) // From completed sequence + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + } + } + + /// Tests for `DefaultRealtimeObjects.onChannelAttached`, covering RTO4 specification points. + /// + /// Note: These tests use `OBJECT_SYNC` messages to populate the initial state of objects pools + /// and sync sequences. This approach is more realistic than directly manipulating internal state, + /// as it simulates how objects actually enter pools during normal operation. + struct OnChannelAttachedTests { + // MARK: - RTO4a Tests + + // @spec RTO4a - Checks that when the `HAS_OBJECTS` flag is 1 (i.e. the server will shortly perform an `OBJECT_SYNC` sequence) we don't modify any internal state + @Test + func doesNotModifyStateWhenHasObjectsIsTrue() { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // Set up initial state with additional objects by using the createZeroValueObject method + let originalPool = realtimeObjects.testsOnly_objectsPool + let originalRootObject = originalPool.root + _ = realtimeObjects.testsOnly_createZeroValueLiveObject(forObjectID: "map:test@123", coreSDK: MockCoreSDK(channelState: .attaching)) + + // Set up an in-progress sync sequence + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:sync@456"), + ], + protocolMessageChannelSerial: "seq1:cursor1", + ) + + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // When: onChannelAttached is called with hasObjects = true + realtimeObjects.onChannelAttached(hasObjects: true) + + // Then: Nothing should be modified + #expect(realtimeObjects.testsOnly_onChannelAttachedHasObjects == true) + + // Verify ObjectsPool is unchanged + let poolAfter = realtimeObjects.testsOnly_objectsPool + #expect(poolAfter.root as AnyObject === originalRootObject as AnyObject) + #expect(poolAfter.entries.count == 2) // root + additional map + #expect(poolAfter.entries["map:test@123"] != nil) + + // Verify sync sequence is still active + #expect(realtimeObjects.testsOnly_hasSyncSequence) + } + + // MARK: - RTO4b Tests + + // @spec RTO4b1 + // @spec RTO4b2 + // @spec RTO4b3 + // @spec RTO4b4 + @Test + func handlesHasObjectsFalse() { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // Set up initial state with additional objects in the pool using sync + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:existing@123"), + TestFactories.counterObjectMessage(objectId: "counter:existing@456"), + ], + protocolMessageChannelSerial: nil, // Complete sync immediately + ) + + let originalPool = realtimeObjects.testsOnly_objectsPool + + // Set up an in-progress sync sequence + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:sync@789"), + ], + protocolMessageChannelSerial: "seq1:cursor1", + ) + + #expect(realtimeObjects.testsOnly_hasSyncSequence) + #expect(originalPool.entries.count == 3) // root + 2 additional objects + + // When: onChannelAttached is called with hasObjects = false + realtimeObjects.onChannelAttached(hasObjects: false) + + // Then: Verify the expected behavior per RTO4b + #expect(realtimeObjects.testsOnly_onChannelAttachedHasObjects == false) + + // RTO4b1, RTO4b2: All objects except root must be removed, root must be cleared to zero-value + let newPool = realtimeObjects.testsOnly_objectsPool + #expect(newPool.entries.count == 1) // Only root should remain + #expect(newPool.entries["root"] != nil) + #expect(newPool.entries["map:existing@123"] == nil) // Should be removed + #expect(newPool.entries["counter:existing@456"] == nil) // Should be removed + + // Verify root is a new zero-valued map (RTO4b2) + // TODO: this one is unclear (are we meant to replace the root or just clear its data?) https://github.com/ably/specification/pull/333/files#r2183493458 + let newRoot = newPool.root + #expect(newRoot as AnyObject !== originalPool.root as AnyObject) // Should be a new instance + #expect(newRoot.testsOnly_data.isEmpty) // Should be zero-valued (empty) + + // RTO4b3, RTO4b4: SyncObjectsPool must be cleared, sync sequence cleared + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + } + + // MARK: - Edge Cases and Integration Tests + + /// Test that multiple calls to onChannelAttached work correctly + @Test + func handlesMultipleCallsCorrectly() { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // First call with hasObjects = true (should do nothing) + realtimeObjects.onChannelAttached(hasObjects: true) + #expect(realtimeObjects.testsOnly_onChannelAttachedHasObjects == true) + let originalPool = realtimeObjects.testsOnly_objectsPool + let originalRoot = originalPool.root + + // Second call with hasObjects = false (should reset) + realtimeObjects.onChannelAttached(hasObjects: false) + #expect(realtimeObjects.testsOnly_onChannelAttachedHasObjects == false) + let newPool = realtimeObjects.testsOnly_objectsPool + #expect(newPool.root as AnyObject !== originalRoot as AnyObject) + #expect(newPool.entries.count == 1) + + // Third call with hasObjects = true again (should do nothing) + let secondResetRoot = newPool.root + realtimeObjects.onChannelAttached(hasObjects: true) + #expect(realtimeObjects.testsOnly_onChannelAttachedHasObjects == true) + let finalPool = realtimeObjects.testsOnly_objectsPool + #expect(finalPool.root as AnyObject === secondResetRoot as AnyObject) // Should be unchanged + } + + /// Test that sync sequence is properly discarded even with complex sync state + @Test + func discardsComplexSyncSequence() { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // Create a complex sync sequence using OBJECT_SYNC messages + // (This simulates realistic multi-message sync scenarios) + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:sync1@123"), + ], + protocolMessageChannelSerial: "seq1:cursor1", + ) + + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.counterObjectMessage(objectId: "counter:sync1@456"), + ], + protocolMessageChannelSerial: "seq1:cursor2", + ) + + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // When: onChannelAttached is called with hasObjects = false + realtimeObjects.onChannelAttached(hasObjects: false) + + // Then: All sync data should be discarded + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + let pool = realtimeObjects.testsOnly_objectsPool + #expect(pool.entries.count == 1) // Only root + #expect(pool.entries["map:sync1@123"] == nil) + #expect(pool.entries["counter:sync1@456"] == nil) + } + + /// Test behavior when there's no sync sequence in progress + @Test + func handlesNoSyncSequenceCorrectly() { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // Add some objects to the pool using OBJECT_SYNC messages + // (This is the realistic way objects enter the pool, not through direct manipulation) + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:test@123"), + ], + protocolMessageChannelSerial: nil, // Complete sync immediately + ) + + let pool = realtimeObjects.testsOnly_objectsPool + + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + #expect(pool.entries.count == 2) // root + additional map + + // When: onChannelAttached is called with hasObjects = false + realtimeObjects.onChannelAttached(hasObjects: false) + + // Then: Should still reset the pool correctly + let newPool = realtimeObjects.testsOnly_objectsPool + #expect(newPool.entries.count == 1) // Only root + #expect(newPool.entries["map:test@123"] == nil) + #expect(!realtimeObjects.testsOnly_hasSyncSequence) // Should remain false + } + + /// Test that the root object's delegate is correctly set after reset + @Test + func setsCorrectDelegateOnNewRoot() { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // When: onChannelAttached is called with hasObjects = false + realtimeObjects.onChannelAttached(hasObjects: false) + + // Then: The new root should have the correct delegate + let newRoot = realtimeObjects.testsOnly_objectsPool.root + #expect(newRoot.testsOnly_delegate as AnyObject === realtimeObjects as AnyObject) + } + } + + /// Tests for `DefaultRealtimeObjects.getRoot`, covering RTO1 specification points + struct GetRootTests { + // MARK: - RTO1c Tests + + // @specOneOf(1/4) RTO1c - getRoot waits for sync completion when sync completes via ATTACHED with `HAS_OBJECTS` false (RTO4b) + @Test + func waitsForSyncCompletionViaAttachedHasObjectsFalse() async throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // Start getRoot call - it should wait for sync completion + async let getRootTask = realtimeObjects.getRoot() + + // Wait for getRoot to start waiting for sync + _ = try #require(await realtimeObjects.testsOnly_waitingForSyncEvents.first { _ in true }) + + // Complete sync via ATTACHED with HAS_OBJECTS false (RTO4b) + realtimeObjects.onChannelAttached(hasObjects: false) + + // getRoot should now complete + _ = try await getRootTask + } + + // @specOneOf(2/4) RTO1c - getRoot waits for sync completion when sync completes via single `OBJECT_SYNC` with no channelSerial (RTO5a5) + @Test + func waitsForSyncCompletionViaSingleObjectSync() async throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // Start getRoot call - it should wait for sync completion + async let getRootTask = realtimeObjects.getRoot() + + // Wait for getRoot to start waiting for sync + _ = try #require(await realtimeObjects.testsOnly_waitingForSyncEvents.first { _ in true }) + + // Complete sync via single OBJECT_SYNC with no channelSerial (RTO5a5) + let (testKey, testEntry) = TestFactories.stringMapEntry(key: "testKey", value: "testValue") + let (referencedKey, referencedEntry) = TestFactories.objectReferenceMapEntry(key: "referencedObject", objectId: "map:test@123") + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.rootObjectMessage(entries: [ + testKey: testEntry, + referencedKey: referencedEntry, + ]), + TestFactories.mapObjectMessage(objectId: "map:test@123"), + ], + protocolMessageChannelSerial: nil, // RTO5a5 case + ) + + // getRoot should now complete + let root = try await getRootTask + + // Verify the root object contains the expected entries from the sync + let testValue = try root.get(key: "testKey")?.stringValue + #expect(testValue == "testValue") + + // Verify the root object contains a reference to the other LiveObject + let referencedObject = try root.get(key: "referencedObject") + #expect(referencedObject != nil) + } + + // @specOneOf(3/4) RTO1c - getRoot waits for sync completion when sync completes via multiple `OBJECT_SYNC` messages (RTO5a4) + @Test + func waitsForSyncCompletionViaMultipleObjectSync() async throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let sequenceId = "seq123" + + // Start getRoot call - it should wait for sync completion + async let getRootTask = realtimeObjects.getRoot() + + // Wait for getRoot to start waiting for sync + _ = try #require(await realtimeObjects.testsOnly_waitingForSyncEvents.first { _ in true }) + + // Start multi-message sync sequence (RTO5a1, RTO5a3) + let (firstKey, firstEntry) = TestFactories.stringMapEntry(key: "firstKey", value: "firstValue") + let (firstObjectKey, firstObjectEntry) = TestFactories.objectReferenceMapEntry(key: "firstObject", objectId: "map:first@123") + let (secondObjectKey, secondObjectEntry) = TestFactories.objectReferenceMapEntry(key: "secondObject", objectId: "map:second@456") + let (finalObjectKey, finalObjectEntry) = TestFactories.objectReferenceMapEntry(key: "finalObject", objectId: "map:final@789") + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.rootObjectMessage(entries: [ + firstKey: firstEntry, + firstObjectKey: firstObjectEntry, + secondObjectKey: secondObjectEntry, + finalObjectKey: finalObjectEntry, + ]), + TestFactories.mapObjectMessage(objectId: "map:first@123"), + ], + protocolMessageChannelSerial: "\(sequenceId):cursor1", + ) + + // Continue sync sequence - add more objects but don't redefine root + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:second@456"), + ], + protocolMessageChannelSerial: "\(sequenceId):cursor2", + ) + + // Complete sync sequence (RTO5a4) - add final object + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:final@789"), + ], + protocolMessageChannelSerial: "\(sequenceId):", // Empty cursor indicates end + ) + + // getRoot should now complete + let root = try await getRootTask + + // Verify the root object contains the expected entries from the sync sequence + let firstValue = try root.get(key: "firstKey")?.stringValue + let firstObject = try root.get(key: "firstObject") + let secondObject = try root.get(key: "secondObject") + let finalObject = try root.get(key: "finalObject") + #expect(firstValue == "firstValue") + #expect(firstObject != nil) + #expect(secondObject != nil) + #expect(finalObject != nil) + } + + // @specOneOf(4/4) RTO1c - getRoot returns immediately when sync is already complete + @Test + func returnsImmediatelyWhenSyncAlreadyComplete() async throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // Complete sync first + realtimeObjects.onChannelAttached(hasObjects: false) + + // getRoot should return + _ = try await realtimeObjects.getRoot() + + // Verify no waiting events were emitted + realtimeObjects.testsOnly_finishAllTestHelperStreams() + let waitingEvents: [Void] = await realtimeObjects.testsOnly_waitingForSyncEvents.reduce(into: []) { result, _ in + result.append(()) + } + #expect(waitingEvents.isEmpty) + } + + // MARK: - RTO1d Tests + + // @spec RTO1d + @Test + func returnsRootObjectFromObjectsPool() async throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // Complete sync first + realtimeObjects.onChannelAttached(hasObjects: false) + + // Call getRoot + let root = try await realtimeObjects.getRoot() + + // Verify it's the same object as the one in the pool with key "root" + let poolRoot = realtimeObjects.testsOnly_objectsPool.entries["root"]?.mapValue + #expect(root as AnyObject === poolRoot as AnyObject) + } + + // MARK: - RTO1b Tests + + // @spec RTO1b + @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) + func getRootThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects(channelState: channelState) + + await #expect { + _ = try await realtimeObjects.getRoot() + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 90001 + } + } + } +} diff --git a/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift b/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift index 4a0bab3f..b554a80d 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift @@ -14,6 +14,18 @@ extension ARTRealtimeChannelProtocol { } }.get() } + + func detachAsync() async throws(ARTErrorInfo) { + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + detach { error in + if let error { + continuation.resume(returning: .failure(error)) + } else { + continuation.resume(returning: .success(())) + } + } + }.get() + } } extension ARTRestProtocol { diff --git a/Tests/AblyLiveObjectsTests/Helpers/Assertions.swift b/Tests/AblyLiveObjectsTests/Helpers/Assertions.swift new file mode 100644 index 00000000..87641644 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/Helpers/Assertions.swift @@ -0,0 +1,7 @@ +/// Stops execution because we tried to use a protocol requirement that is not implemented. +func protocolRequirementNotImplemented(_ message: @autoclosure () -> String = String(), file _: StaticString = #file, line _: UInt = #line) -> Never { + fatalError({ + let returnedMessage = message() + return "Protocol requirement not implemented\(returnedMessage.isEmpty ? "" : ": \(returnedMessage)")" + }()) +} diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift new file mode 100644 index 00000000..b61414c0 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -0,0 +1,554 @@ +@testable import AblyLiveObjects +import AblyPlugin +import Foundation + +// Note that this file was created entirely by Cursor upon my giving it some guidelines — I have not checked its contents in any detail and it may well turn out that there are mistakes here which we need to fix in the future. + +/// Factory for creating test objects with sensible defaults and override capabilities. +/// This follows a pattern similar to Ruby's factory_bot to reduce boilerplate in tests. +/// +/// ## Key Principles +/// +/// 1. **Sensible Defaults**: All factory methods provide reasonable default values +/// 2. **Override Capability**: You can override any default value when needed +/// 3. **No Assertions Against Defaults**: Tests should specify input values explicitly when making assertions about outputs +/// 4. **Common Scenarios**: Factory methods exist for common test scenarios +/// +/// ## Usage Examples +/// +/// ### Creating ObjectState instances +/// ```swift +/// // Basic map object state with defaults +/// let mapState = TestFactories.mapObjectState() +/// +/// // Map object state with custom objectId and entries +/// let (key, entry) = TestFactories.stringMapEntry(key: "customKey", value: "customValue") +/// let customMapState = TestFactories.mapObjectState( +/// objectId: "map:custom@123", +/// entries: [key: entry] +/// ) +/// +/// // Counter object state with custom count +/// let counterState = TestFactories.counterObjectState(count: 100) +/// ``` +/// +/// ### Creating InboundObjectMessage instances +/// ```swift +/// // Simple map message +/// let mapMessage = TestFactories.simpleMapMessage( +/// objectId: "map:test@123", +/// key: "testKey", +/// value: "testValue" +/// ) +/// +/// // Counter message +/// let counterMessage = TestFactories.simpleCounterMessage( +/// objectId: "counter:test@123", +/// count: 42 +/// ) +/// +/// // Root message with multiple entries +/// let rootMessage = TestFactories.rootMessageWithEntries([ +/// "key1": "value1", +/// "key2": "value2" +/// ]) +/// ``` +/// +/// ### Creating Map Entries +/// ```swift +/// // String entry +/// let (stringKey, stringEntry) = TestFactories.stringMapEntry( +/// key: "stringKey", +/// value: "stringValue" +/// ) +/// +/// // Number entry +/// let (numberKey, numberEntry) = TestFactories.numberMapEntry( +/// key: "numberKey", +/// value: NSNumber(value: 123.45) +/// ) +/// +/// // Boolean entry +/// let (boolKey, boolEntry) = TestFactories.booleanMapEntry( +/// key: "boolKey", +/// value: true +/// ) +/// +/// // Object reference entry +/// let (refKey, refEntry) = TestFactories.objectReferenceMapEntry( +/// key: "refKey", +/// objectId: "map:referenced@123" +/// ) +/// ``` +/// +/// ## Migration Guide +/// +/// When migrating existing tests to use factories: +/// +/// 1. **Replace direct object creation** with factory calls +/// 2. **Remove arbitrary values** that don't affect the test +/// 3. **Keep only the values** that are relevant to the test assertions +/// 4. **Use descriptive factory method names** to make test intent clear +/// +/// ### Before (with boilerplate) +/// ```swift +/// let state = ObjectState( +/// objectId: "arbitrary-id", +/// siteTimeserials: ["site1": "ts1"], +/// tombstone: false, +/// createOp: nil, +/// map: nil, +/// counter: WireObjectsCounter(count: 42), // Only this value matters +/// ) +/// ``` +/// +/// ### After (using factory) +/// ```swift +/// let state = TestFactories.counterObjectState(count: 42) // Only specify what matters +/// ``` +/// +/// ## Best Practices +/// +/// 1. **Use the most specific factory method** available for your use case +/// 2. **Override only the values** that are relevant to your test +/// 3. **Use descriptive parameter names** when overriding defaults +/// 4. **Document complex factory usage** with comments when needed +/// 5. **Group related factory calls** together for readability +/// +/// ## Available Factory Methods +/// +/// ### ObjectState Factories +/// - `objectState()` - Basic ObjectState with defaults +/// - `mapObjectState()` - ObjectState for map objects +/// - `counterObjectState()` - ObjectState for counter objects +/// - `rootObjectState()` - ObjectState for root object +/// +/// ### InboundObjectMessage Factories +/// - `inboundObjectMessage()` - Basic InboundObjectMessage with defaults +/// - `mapObjectMessage()` - InboundObjectMessage with map ObjectState +/// - `counterObjectMessage()` - InboundObjectMessage with counter ObjectState +/// - `rootObjectMessage()` - InboundObjectMessage with root ObjectState +/// - `objectMessageWithoutState()` - InboundObjectMessage without ObjectState +/// - `simpleMapMessage()` - Simple map message with one string entry +/// - `simpleCounterMessage()` - Simple counter message +/// - `rootMessageWithEntries()` - Root message with multiple string entries +/// +/// ### ObjectOperation Factories +/// - `objectOperation()` - Basic ObjectOperation with defaults +/// - `mapCreateOperation()` - Map create operation +/// - `counterCreateOperation()` - Counter create operation +/// +/// ### Map Entry Factories +/// - `mapEntry()` - Basic ObjectsMapEntry with defaults +/// - `stringMapEntry()` - Map entry with string data +/// - `numberMapEntry()` - Map entry with number data +/// - `booleanMapEntry()` - Map entry with boolean data +/// - `bytesMapEntry()` - Map entry with bytes data +/// - `objectReferenceMapEntry()` - Map entry with object reference data +/// +/// ### Other Factories +/// - `objectsMap()` - Basic ObjectsMap with defaults +/// - `objectsMapWithStringEntries()` - ObjectsMap with string entries +/// - `wireObjectsCounter()` - WireObjectsCounter with defaults +/// +/// ## Extending the Factory System +/// +/// When adding new factory methods, follow these patterns: +/// +/// 1. **Use descriptive method names** that indicate the type and purpose +/// 2. **Provide sensible defaults** for all parameters +/// 3. **Group related methods** together with MARK comments +/// 4. **Include comprehensive documentation** explaining the purpose and usage +/// 5. **Follow the existing naming conventions** (e.g., `objectState()`, `mapObjectState()`) +/// 6. **Consider common test scenarios** and create convenience methods for them +/// 7. **Ensure all factory methods are static** for easy access +/// 8. **Use type-safe parameters** and avoid magic strings/numbers +/// 9. **Include examples in documentation** showing typical usage patterns +/// 10. **Test the factory methods** to ensure they work correctly +/// +/// ### Example of Adding a New Factory Method +/// ```swift +/// /// Creates a LiveMap with specific data for testing +/// /// - Parameters: +/// /// - objectId: The object ID for the map (default: "map:test@123") +/// /// - entries: Dictionary of key-value pairs to populate the map +/// /// - delegate: The delegate for the map (default: MockLiveMapObjectPoolDelegate()) +/// /// - Returns: A configured DefaultLiveMap instance +/// static func liveMap( +/// objectId: String = "map:test@123", +/// entries: [String: String] = [:], +/// delegate: LiveMapObjectPoolDelegate = MockLiveMapObjectPoolDelegate() +/// ) -> DefaultLiveMap { +/// let map = DefaultLiveMap.createZeroValued(delegate: delegate) +/// // Configure map with entries... +/// return map +/// } +/// ``` +struct TestFactories { + // MARK: - ObjectState Factory + + /// Creates an ObjectState with sensible defaults + static func objectState( + objectId: String = "test:object@123", + siteTimeserials: [String: String] = ["site1": "ts1"], + tombstone: Bool = false, + createOp: ObjectOperation? = nil, + map: ObjectsMap? = nil, + counter: WireObjectsCounter? = nil, + ) -> ObjectState { + ObjectState( + objectId: objectId, + siteTimeserials: siteTimeserials, + tombstone: tombstone, + createOp: createOp, + map: map, + counter: counter, + ) + } + + /// Creates an ObjectState for a map object + static func mapObjectState( + objectId: String = "map:test@123", + siteTimeserials: [String: String] = ["site1": "ts1"], + tombstone: Bool = false, + createOp: ObjectOperation? = nil, + entries: [String: ObjectsMapEntry]? = nil, + ) -> ObjectState { + objectState( + objectId: objectId, + siteTimeserials: siteTimeserials, + tombstone: tombstone, + createOp: createOp, + map: ObjectsMap( + semantics: .known(.lww), + entries: entries, + ), + counter: nil, + ) + } + + /// Creates an ObjectState for a counter object + static func counterObjectState( + objectId: String = "counter:test@123", + siteTimeserials: [String: String] = ["site1": "ts1"], + tombstone: Bool = false, + createOp: ObjectOperation? = nil, + count: Int? = 42, + ) -> ObjectState { + objectState( + objectId: objectId, + siteTimeserials: siteTimeserials, + tombstone: tombstone, + createOp: createOp, + map: nil, + counter: WireObjectsCounter(count: count.map { NSNumber(value: $0) }), + ) + } + + /// Creates an ObjectState for the root object + static func rootObjectState( + siteTimeserials: [String: String] = ["site1": "ts1"], + entries: [String: ObjectsMapEntry]? = nil, + ) -> ObjectState { + mapObjectState( + objectId: "root", + siteTimeserials: siteTimeserials, + entries: entries, + ) + } + + // MARK: - InboundObjectMessage Factory + + /// Creates an InboundObjectMessage with sensible defaults + static func inboundObjectMessage( + id: String? = nil, + clientId: String? = nil, + connectionId: String? = nil, + extras: [String: JSONValue]? = nil, + timestamp: Date? = nil, + operation: ObjectOperation? = nil, + object: ObjectState? = nil, + serial: String? = nil, + siteCode: String? = nil, + ) -> InboundObjectMessage { + InboundObjectMessage( + id: id, + clientId: clientId, + connectionId: connectionId, + extras: extras, + timestamp: timestamp, + operation: operation, + object: object, + serial: serial, + siteCode: siteCode, + ) + } + + /// Creates an InboundObjectMessage with a map ObjectState + static func mapObjectMessage( + objectId: String = "map:test@123", + siteTimeserials: [String: String] = ["site1": "ts1"], + entries: [String: ObjectsMapEntry]? = nil, + ) -> InboundObjectMessage { + inboundObjectMessage( + object: mapObjectState( + objectId: objectId, + siteTimeserials: siteTimeserials, + entries: entries, + ), + ) + } + + /// Creates an InboundObjectMessage with a counter ObjectState + static func counterObjectMessage( + objectId: String = "counter:test@123", + siteTimeserials: [String: String] = ["site1": "ts1"], + count: Int? = 42, + ) -> InboundObjectMessage { + inboundObjectMessage( + object: counterObjectState( + objectId: objectId, + siteTimeserials: siteTimeserials, + count: count, + ), + ) + } + + /// Creates an InboundObjectMessage with a root ObjectState + static func rootObjectMessage( + siteTimeserials: [String: String] = ["site1": "ts1"], + entries: [String: ObjectsMapEntry]? = nil, + ) -> InboundObjectMessage { + inboundObjectMessage( + object: rootObjectState( + siteTimeserials: siteTimeserials, + entries: entries, + ), + ) + } + + /// Creates an InboundObjectMessage without an ObjectState + static func objectMessageWithoutState() -> InboundObjectMessage { + inboundObjectMessage(object: nil) + } + + // MARK: - ObjectOperation Factory + + /// Creates an ObjectOperation with sensible defaults + static func objectOperation( + action: WireEnum = .known(.mapCreate), + objectId: String = "test:object@123", + mapOp: ObjectsMapOp? = nil, + counterOp: WireObjectsCounterOp? = nil, + map: ObjectsMap? = nil, + counter: WireObjectsCounter? = nil, + nonce: String? = nil, + initialValue: Data? = nil, + initialValueEncoding: String? = nil, + ) -> ObjectOperation { + ObjectOperation( + action: action, + objectId: objectId, + mapOp: mapOp, + counterOp: counterOp, + map: map, + counter: counter, + nonce: nonce, + initialValue: initialValue, + initialValueEncoding: initialValueEncoding, + ) + } + + /// Creates a map create operation + static func mapCreateOperation( + objectId: String = "map:test@123", + entries: [String: ObjectsMapEntry]? = nil, + ) -> ObjectOperation { + objectOperation( + action: .known(.mapCreate), + objectId: objectId, + map: ObjectsMap( + semantics: .known(.lww), + entries: entries, + ), + ) + } + + /// Creates a counter create operation + static func counterCreateOperation( + objectId: String = "counter:test@123", + count: Int? = 42, + ) -> ObjectOperation { + objectOperation( + action: .known(.counterCreate), + objectId: objectId, + counter: WireObjectsCounter(count: count.map { NSNumber(value: $0) }), + ) + } + + // MARK: - ObjectsMapEntry Factory + + /// Creates an ObjectsMapEntry with sensible defaults + static func mapEntry( + tombstone: Bool? = false, + timeserial: String? = "ts1", + data: ObjectData, + ) -> ObjectsMapEntry { + ObjectsMapEntry( + tombstone: tombstone, + timeserial: timeserial, + data: data, + ) + } + + /// Creates a map entry with string data + static func stringMapEntry( + key: String = "testKey", + value: String = "testValue", + tombstone: Bool? = false, + timeserial: String? = "ts1", + ) -> (key: String, entry: ObjectsMapEntry) { + ( + key: key, + entry: mapEntry( + tombstone: tombstone, + timeserial: timeserial, + data: ObjectData(string: .string(value)), + ), + ) + } + + /// Creates a map entry with number data + static func numberMapEntry( + key: String = "testKey", + value: NSNumber = NSNumber(value: 42), + tombstone: Bool? = false, + timeserial: String? = "ts1", + ) -> (key: String, entry: ObjectsMapEntry) { + ( + key: key, + entry: mapEntry( + tombstone: tombstone, + timeserial: timeserial, + data: ObjectData(number: value), + ), + ) + } + + /// Creates a map entry with boolean data + static func booleanMapEntry( + key: String = "testKey", + value: Bool = true, + tombstone: Bool? = false, + timeserial: String? = "ts1", + ) -> (key: String, entry: ObjectsMapEntry) { + ( + key: key, + entry: mapEntry( + tombstone: tombstone, + timeserial: timeserial, + data: ObjectData(boolean: value), + ), + ) + } + + /// Creates a map entry with bytes data + static func bytesMapEntry( + key: String = "testKey", + value: Data = Data([0x01, 0x02, 0x03]), + tombstone: Bool? = false, + timeserial: String? = "ts1", + ) -> (key: String, entry: ObjectsMapEntry) { + ( + key: key, + entry: mapEntry( + tombstone: tombstone, + timeserial: timeserial, + data: ObjectData(bytes: value), + ), + ) + } + + /// Creates a map entry with object reference data + static func objectReferenceMapEntry( + key: String = "testKey", + objectId: String = "map:referenced@123", + tombstone: Bool? = false, + timeserial: String? = "ts1", + ) -> (key: String, entry: ObjectsMapEntry) { + ( + key: key, + entry: mapEntry( + tombstone: tombstone, + timeserial: timeserial, + data: ObjectData(objectId: objectId), + ), + ) + } + + // MARK: - ObjectsMap Factory + + /// Creates an ObjectsMap with sensible defaults + static func objectsMap( + semantics: WireEnum = .known(.lww), + entries: [String: ObjectsMapEntry]? = nil, + ) -> ObjectsMap { + ObjectsMap( + semantics: semantics, + entries: entries, + ) + } + + /// Creates an ObjectsMap with string entries + static func objectsMapWithStringEntries( + entries: [String: String] = ["key1": "value1", "key2": "value2"], + ) -> ObjectsMap { + let mapEntries = entries.mapValues { value in + mapEntry(data: ObjectData(string: .string(value))) + } + return objectsMap(entries: mapEntries) + } + + // MARK: - WireObjectsCounter Factory + + /// Creates a WireObjectsCounter with sensible defaults + static func wireObjectsCounter(count: Int? = 42) -> WireObjectsCounter { + WireObjectsCounter(count: count.map { NSNumber(value: $0) }) + } + + // MARK: - Common Test Scenarios + + /// Creates a simple map object message with one string entry + static func simpleMapMessage( + objectId: String = "map:simple@123", + key: String = "testKey", + value: String = "testValue", + ) -> InboundObjectMessage { + let (entryKey, entry) = stringMapEntry(key: key, value: value) + return mapObjectMessage( + objectId: objectId, + entries: [entryKey: entry], + ) + } + + /// Creates a simple counter object message + static func simpleCounterMessage( + objectId: String = "counter:simple@123", + count: Int = 42, + ) -> InboundObjectMessage { + counterObjectMessage( + objectId: objectId, + count: count, + ) + } + + /// Creates a root object message with multiple entries + static func rootMessageWithEntries( + entries: [String: String] = ["key1": "value1", "key2": "value2"], + ) -> InboundObjectMessage { + let mapEntries = entries.mapValues { value in + mapEntry(data: ObjectData(string: .string(value))) + } + return rootObjectMessage(entries: mapEntries) + } +} diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift new file mode 100644 index 00000000..bd81079a --- /dev/null +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift @@ -0,0 +1,540 @@ +import Ably +@testable import AblyLiveObjects +import AblyPlugin +import Foundation + +// This file is copied from the file objects.test.js in ably-js. + +/// This is a Swift port of the JavaScript ObjectsHelper class used for testing. +final class ObjectsHelper: Sendable { + // MARK: - Constants + + /// Object operation actions + enum Actions: Int { + case mapCreate = 0 + case mapSet = 1 + case mapRemove = 2 + case counterCreate = 3 + case counterInc = 4 + case objectDelete = 5 + + var stringValue: String { + switch self { + case .mapCreate: + "MAP_CREATE" + case .mapSet: + "MAP_SET" + case .mapRemove: + "MAP_REMOVE" + case .counterCreate: + "COUNTER_CREATE" + case .counterInc: + "COUNTER_INC" + case .objectDelete: + "OBJECT_DELETE" + } + } + } + + // MARK: - Properties + + private let rest: ARTRest + + // MARK: - Initialization + + init() async throws { + let options = try await ARTClientOptions(key: Sandbox.fetchSharedAPIKey()) + options.useBinaryProtocol = false + options.environment = "sandbox" + rest = ARTRest(options: options) + } + + // MARK: - Static Properties and Methods + + /// Static access to the Actions enum (equivalent to JavaScript static ACTIONS) + static let ACTIONS = Actions.self + + /// Returns the root keys used in the fixture objects tree + static func fixtureRootKeys() -> [String] { + ["emptyCounter", "initialValueCounter", "referencedCounter", "emptyMap", "referencedMap", "valuesMap"] + } + + // MARK: - Channel Initialization + + /// Sends Objects REST API requests to create objects tree on a provided channel: + /// + /// - root "emptyMap" -> Map#1 {} -- empty map + /// - root "referencedMap" -> Map#2 { "counterKey": } + /// - root "valuesMap" -> Map#3 { "stringKey": "stringValue", "emptyStringKey": "", "bytesKey": , "emptyBytesKey": , "numberKey": 1, "zeroKey": 0, "trueKey": true, "falseKey": false, "mapKey": } + /// - root "emptyCounter" -> Counter#1 -- no initial value counter, should be 0 + /// - root "initialValueCounter" -> Counter#2 count=10 + /// - root "referencedCounter" -> Counter#3 count=20 + func initForChannel(_ channelName: String) async throws { + _ = try await createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "emptyCounter", + createOp: counterCreateRestOp(), + ) + + _ = try await createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "initialValueCounter", + createOp: counterCreateRestOp(number: 10), + ) + + let referencedCounter = try await createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "referencedCounter", + createOp: counterCreateRestOp(number: 20), + ) + + _ = try await createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "emptyMap", + createOp: mapCreateRestOp(), + ) + + let referencedMapData: [String: JSONValue] = [ + "counterKey": .object(["objectId": .string(referencedCounter.objectId)]), + ] + let referencedMap = try await createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "referencedMap", + createOp: mapCreateRestOp(data: referencedMapData), + ) + + let valuesMapData: [String: JSONValue] = [ + "stringKey": .object(["string": .string("stringValue")]), + "emptyStringKey": .object(["string": .string("")]), + "bytesKey": .object(["bytes": .string("eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9")]), + "emptyBytesKey": .object(["bytes": .string("")]), + "numberKey": .object(["number": .number(1)]), + "zeroKey": .object(["number": .number(0)]), + "trueKey": .object(["boolean": .bool(true)]), + "falseKey": .object(["boolean": .bool(false)]), + "mapKey": .object(["objectId": .string(referencedMap.objectId)]), + ] + _ = try await createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "valuesMap", + createOp: mapCreateRestOp(data: valuesMapData), + ) + } + + // MARK: - Wire Object Messages + + /// Creates a map create operation + func mapCreateOp(objectId: String? = nil, entries: [String: JSONValue]? = nil) -> [String: JSONValue] { + var operation: [String: JSONValue] = [ + "action": .number(NSNumber(value: Actions.mapCreate.rawValue)), + "nonce": .string(nonce()), + "map": .object(["semantics": .number(NSNumber(value: 0))]), + ] + + if let objectId { + operation["objectId"] = .string(objectId) + } + + if let entries { + var mapValue = operation["map"]!.objectValue! + mapValue["entries"] = .object(entries) + operation["map"] = .object(mapValue) + } + + return ["operation": .object(operation)] + } + + /// Creates a map set operation + func mapSetOp(objectId: String, key: String, data: JSONValue) -> [String: JSONValue] { + [ + "operation": .object([ + "action": .number(NSNumber(value: Actions.mapSet.rawValue)), + "objectId": .string(objectId), + "mapOp": .object([ + "key": .string(key), + "data": data, + ]), + ]), + ] + } + + /// Creates a map remove operation + func mapRemoveOp(objectId: String, key: String) -> [String: JSONValue] { + [ + "operation": .object([ + "action": .number(NSNumber(value: Actions.mapRemove.rawValue)), + "objectId": .string(objectId), + "mapOp": .object([ + "key": .string(key), + ]), + ]), + ] + } + + /// Creates a counter create operation + func counterCreateOp(objectId: String? = nil, count: Int? = nil) -> [String: JSONValue] { + var operation: [String: JSONValue] = [ + "action": .number(NSNumber(value: Actions.counterCreate.rawValue)), + "nonce": .string(nonce()), + ] + + if let objectId { + operation["objectId"] = .string(objectId) + } + + if let count { + operation["counter"] = .object(["count": .number(NSNumber(value: count))]) + } + + return ["operation": .object(operation)] + } + + /// Creates a counter increment operation + func counterIncOp(objectId: String, amount: Int) -> [String: JSONValue] { + [ + "operation": .object([ + "action": .number(NSNumber(value: Actions.counterInc.rawValue)), + "objectId": .string(objectId), + "counterOp": .object([ + "amount": .number(NSNumber(value: amount)), + ]), + ]), + ] + } + + /// Creates an object delete operation + func objectDeleteOp(objectId: String) -> [String: JSONValue] { + [ + "operation": .object([ + "action": .number(NSNumber(value: Actions.objectDelete.rawValue)), + "objectId": .string(objectId), + ]), + ] + } + + /// Creates a map object structure + func mapObject( + objectId: String, + siteTimeserials: [String: String], + initialEntries: [String: JSONValue]? = nil, + materialisedEntries: [String: JSONValue]? = nil, + tombstone: Bool = false, + ) -> [String: JSONValue] { + var object: [String: JSONValue] = [ + "objectId": .string(objectId), + "siteTimeserials": .object(siteTimeserials.mapValues { .string($0) }), + "tombstone": .bool(tombstone), + "map": .object([ + "semantics": .number(NSNumber(value: 0)), + "entries": .object(materialisedEntries ?? [:]), + ]), + ] + + if let initialEntries { + let createOp = mapCreateOp(objectId: objectId, entries: initialEntries) + object["createOp"] = createOp["operation"]! + } + + return ["object": .object(object)] + } + + /// Creates a counter object structure + func counterObject( + objectId: String, + siteTimeserials: [String: String], + initialCount: Int? = nil, + materialisedCount: Int? = nil, + tombstone: Bool = false, + ) -> [String: JSONValue] { + let materialisedCountValue: JSONValue = if let materialisedCount { + .number(NSNumber(value: materialisedCount)) + } else { + .null + } + + var object: [String: JSONValue] = [ + "objectId": .string(objectId), + "siteTimeserials": .object(siteTimeserials.mapValues { .string($0) }), + "tombstone": .bool(tombstone), + "counter": .object([ + "count": materialisedCountValue, + ]), + ] + + if let initialCount { + let createOp = counterCreateOp(objectId: objectId, count: initialCount) + object["createOp"] = createOp["operation"]! + } + + return ["object": .object(object)] + } + + /// Creates an object operation message + func objectOperationMessage( + channelName: String, + serial: String, + siteCode: String, + state: [[String: JSONValue]]? = nil, + ) -> [String: JSONValue] { + let stateWithSerials = state?.map { objectMessage in + var message = objectMessage + message["serial"] = .string(serial) + message["siteCode"] = .string(siteCode) + return message + } + + let stateArray = stateWithSerials?.map { dict in JSONValue.object(dict) } ?? [] + + return [ + "action": .number(NSNumber(value: 19)), // OBJECT + "channel": .string(channelName), + "channelSerial": .string(serial), + "state": .array(stateArray), + ] + } + + /// Creates an object state message + func objectStateMessage( + channelName: String, + syncSerial: String, + state: [[String: JSONValue]]? = nil, + ) -> [String: JSONValue] { + let stateArray = state?.map { dict in JSONValue.object(dict) } ?? [] + return [ + "action": .number(NSNumber(value: 20)), // OBJECT_SYNC + "channel": .string(channelName), + "channelSerial": .string(syncSerial), + "state": .array(stateArray), + ] + } + + /// This is the equivalent of the JS ObjectHelper's channel.processMessage(createPM(…)). + private func processDeserializedProtocolMessage( + _ deserialized: [String: JSONValue], + channel: ARTRealtimeChannel, + ) { + let jsonEncoder = ARTJsonEncoder() + let encoder = ARTJsonLikeEncoder( + rest: channel.internal.realtime!.rest, + delegate: jsonEncoder, + logger: channel.internal.logger, + ) + + let foundationObject = deserialized.toJSONSerializationInput + let protocolMessage = withExtendedLifetime(jsonEncoder) { + encoder.protocolMessage(from: foundationObject)! + } + + channel.internal.onChannelMessage(protocolMessage) + } + + /// Processes an object operation message on a channel + func processObjectOperationMessageOnChannel( + channel: ARTRealtimeChannel, + serial: String, + siteCode: String, + state: [[String: JSONValue]]? = nil, + ) async throws { + processDeserializedProtocolMessage( + objectOperationMessage( + channelName: channel.name, + serial: serial, + siteCode: siteCode, + state: state, + ), + channel: channel, + ) + } + + /// Processes an object state message on a channel + func processObjectStateMessageOnChannel( + channel: ARTRealtimeChannel, + syncSerial: String, + state: [[String: JSONValue]]? = nil, + ) async throws { + processDeserializedProtocolMessage( + objectStateMessage( + channelName: channel.name, + syncSerial: syncSerial, + state: state, + ), + channel: channel, + ) + } + + // MARK: - REST API Operations + + /// Result of a REST API operation + struct OperationResult { + let objectId: String + let success: Bool + } + + /// Creates an object and sets it on a map + func createAndSetOnMap( + channelName: String, + mapObjectId: String, + key: String, + createOp: [String: JSONValue], + ) async throws -> OperationResult { + let createResult = try await operationRequest(channelName: channelName, opBody: createOp) + let objectId = createResult.objectId + + let setOp = mapSetRestOp( + objectId: mapObjectId, + key: key, + value: ["objectId": .string(objectId)], + ) + _ = try await operationRequest(channelName: channelName, opBody: setOp) + + return createResult + } + + /// Creates a map create REST operation + func mapCreateRestOp(objectId: String? = nil, nonce: String? = nil, data: [String: JSONValue]? = nil) -> [String: JSONValue] { + var opBody: [String: JSONValue] = [ + "operation": .string(Actions.mapCreate.stringValue), + ] + + if let data { + opBody["data"] = .object(data) + } + + if let objectId { + opBody["objectId"] = .string(objectId) + opBody["nonce"] = .string(nonce ?? "") + } + + return opBody + } + + /// Creates a map set REST operation + func mapSetRestOp(objectId: String, key: String, value: [String: JSONValue]) -> [String: JSONValue] { + [ + "operation": .string(Actions.mapSet.stringValue), + "objectId": .string(objectId), + "data": .object([ + "key": .string(key), + "value": .object(value), + ]), + ] + } + + /// Creates a map remove REST operation + func mapRemoveRestOp(objectId: String, key: String) -> [String: JSONValue] { + [ + "operation": .string(Actions.mapRemove.stringValue), + "objectId": .string(objectId), + "data": .object([ + "key": .string(key), + ]), + ] + } + + /// Creates a counter create REST operation + func counterCreateRestOp(objectId: String? = nil, nonce: String? = nil, number: Int? = nil) -> [String: JSONValue] { + var opBody: [String: JSONValue] = [ + "operation": .string(Actions.counterCreate.stringValue), + ] + + if let number { + opBody["data"] = .object(["number": .number(NSNumber(value: number))]) + } + + if let objectId { + opBody["objectId"] = .string(objectId) + opBody["nonce"] = .string(nonce ?? "") + } + + return opBody + } + + /// Creates a counter increment REST operation + func counterIncRestOp(objectId: String, number: Int) -> [String: JSONValue] { + [ + "operation": .string(Actions.counterInc.stringValue), + "objectId": .string(objectId), + "data": .object(["number": .number(NSNumber(value: number))]), + ] + } + + /// Sends an operation request to the REST API + private func operationRequest(channelName: String, opBody: [String: JSONValue]) async throws -> OperationResult { + let path = "/channels/\(channelName)/objects" + + do { + let response = try await rest.requestAsync("POST", path: path, params: nil, body: opBody.toJSONSerializationInput, headers: nil) + + guard (200 ..< 300).contains(response.statusCode) else { + throw NSError( + domain: "ObjectsHelper", + code: response.statusCode, + userInfo: [ + NSLocalizedDescriptionKey: "REST API request failed", + "path": path, + "operation": opBody.toJSONSerializationInput, + ], + ) + } + + guard let firstItem = response.items.first as? [String: Any] else { + throw NSError( + domain: "ObjectsHelper", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Invalid response format - no items"], + ) + } + + // Extract objectId from the response + let objectId: String + if let objectIds = firstItem["objectIds"] as? [String], let firstObjectId = objectIds.first { + objectId = firstObjectId + } else if let directObjectId = firstItem["objectId"] as? String { + objectId = directObjectId + } else { + throw NSError( + domain: "ObjectsHelper", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "No objectId found in response"], + ) + } + + return OperationResult(objectId: objectId, success: true) + } catch let error as ARTErrorInfo { + throw error + } catch { + throw error + } + } + + // MARK: - Utility Methods + + /// Generates a fake map object ID + func fakeMapObjectId() -> String { + "map:\(randomString())@\(Int(Date().timeIntervalSince1970 * 1000))" + } + + /// Generates a fake counter object ID + func fakeCounterObjectId() -> String { + "counter:\(randomString())@\(Int(Date().timeIntervalSince1970 * 1000))" + } + + // MARK: - Private Methods + + /// Generates a random nonce + private func nonce() -> String { + randomString() + } + + /// Generates a random string + private func randomString() -> String { + let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + return String((0 ..< 16).map { _ in letters.randomElement()! }) + } +} diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift new file mode 100644 index 00000000..9931a54e --- /dev/null +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -0,0 +1,787 @@ +import Ably +@testable import AblyLiveObjects +import Testing + +// This file is copied from the file objects.test.js in ably-js. + +// Disable trailing_closure so that we can pass `action:` to the TestScenario initializer, consistent with the JS code +// swiftlint:disable trailing_closure + +// MARK: - Top-level helpers + +private func realtimeWithObjects(options: PartialClientOptions?) async throws -> ARTRealtime { + let key = try await Sandbox.fetchSharedAPIKey() + let clientOptions = ARTClientOptions(key: key) + clientOptions.plugins = [.liveObjects: AblyLiveObjects.Plugin.self] + clientOptions.environment = "sandbox" + + clientOptions.testOptions.transportFactory = TestProxyTransportFactory() + + if TestLogger.loggingEnabled { + clientOptions.logLevel = .verbose + } + + if let options { + clientOptions.useBinaryProtocol = options.useBinaryProtocol + } + + return ARTRealtime(options: clientOptions) +} + +private func channelOptionsWithObjects() -> ARTRealtimeChannelOptions { + let options = ARTRealtimeChannelOptions() + options.modes = [.objectSubscribe, .objectPublish] + return options +} + +// Swift version of the JS lexicoTimeserial function +// +// Example: +// +// 01726585978590-001@abcdefghij:001 +// |____________| |_| |________| |_| +// | | | | +// timestamp counter seriesId idx +private func lexicoTimeserial(seriesId: String, timestamp: Int64, counter: Int, index: Int? = nil) -> String { + let paddedTimestamp = String(format: "%014d", timestamp) + let paddedCounter = String(format: "%03d", counter) + + var result = "\(paddedTimestamp)-\(paddedCounter)@\(seriesId)" + + if let index { + let paddedIndex = String(format: "%03d", index) + result += ":\(paddedIndex)" + } + + return result +} + +func monitorConnectionThenCloseAndFinishAsync(_ realtime: ARTRealtime, action: @escaping @Sendable () async throws -> Void) async throws { + defer { realtime.connection.close() } + + try await withThrowingTaskGroup { group in + // Monitor connection state + for state in [ARTRealtimeConnectionEvent.failed, .suspended] { + group.addTask { + let (stream, continuation) = AsyncThrowingStream.makeStream() + + let subscription = realtime.connection.on(state) { _ in + realtime.close() + + let error = NSError( + domain: "IntegrationTestsError", + code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "Connection monitoring: state changed to \(state), aborting test", + ], + ) + continuation.finish(throwing: error) + } + continuation.onTermination = { _ in + realtime.connection.off(subscription) + } + + try await stream.first { _ in true } + } + } + + // Perform the action + group.addTask { + try await action() + } + + // Wait for either connection monitoring to throw an error or for the action to complete + guard let result = await group.nextResult() else { + return + } + + group.cancelAll() + try result.get() + } +} + +func waitFixtureChannelIsReady(_: ARTRealtime) async throws { + // TODO: Implement this using the subscription APIs once we've got a spec for those, but this should be fine for now + try await Task.sleep(nanoseconds: 5 * NSEC_PER_SEC) +} + +func waitForMapKeyUpdate(_ map: any LiveMap, _ key: String) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + var subscription: SubscribeResponse! + subscription = map.subscribe { update in + if update.update[key] != nil { + subscription.unsubscribe() + continuation.resume() + } + } + } +} + +func waitForCounterUpdate(_ counter: any LiveCounter) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + var subscription: SubscribeResponse! + subscription = counter.subscribe { _ in + subscription.unsubscribe() + continuation.resume() + } + } +} + +// I added this @MainActor as an "I don't understand what's going on there; let's try this" when observing that for some reason the setter of setListenerAfterProcessingIncomingMessage was hanging inside `-[ARTSRDelegateController dispatchQueue]`. This seems to avoid it and I have not investigated more deeply 🤷 +@MainActor +func waitForObjectSync(_ realtime: ARTRealtime) async throws { + let testProxyTransport = try #require(realtime.internal.transport as? TestProxyTransport) + + await withCheckedContinuation { (continuation: CheckedContinuation) in + testProxyTransport.setListenerAfterProcessingIncomingMessage { protocolMessage in + if protocolMessage.action == .objectSync { + testProxyTransport.setListenerAfterProcessingIncomingMessage(nil) + continuation.resume() + } + } + } +} + +// MARK: - Constants + +private let objectsFixturesChannel = "objects_fixtures" + +// MARK: - Support for parameterised tests + +/// The output of `forScenarios`. One element of the one-dimensional arguments array that is passed to a Swift Testing test. +private struct TestCase: Identifiable, CustomStringConvertible { + var disabled: Bool + var scenario: TestScenario + var options: PartialClientOptions? + var channelName: String + + /// This `Identifiable` conformance allows us to re-run individual test cases from the Xcode UI (https://developer.apple.com/documentation/testing/parameterizedtesting#Run-selected-test-cases) + var id: TestCaseID { + .init(description: scenario.description, options: options) + } + + /// This seems to determine the nice name that you see for this when it's used as a test case parameter. (I can't see anywhere that this is documented; found it by experimentation). + var description: String { + var result = scenario.description + + if let options { + result += " (\(options.useBinaryProtocol ? "binary" : "text"))" + } + + return result + } +} + +/// Enables `TestCase`'s conformance to `Identifiable`. +private struct TestCaseID: Encodable, Hashable { + var description: String + var options: PartialClientOptions? +} + +private struct PartialClientOptions: Encodable, Hashable { + var useBinaryProtocol: Bool +} + +/// The input to `forScenarios`. +private struct TestScenario { + var disabled: Bool + var allTransportsAndProtocols: Bool + var description: String + var action: @Sendable (Context) async throws -> Void +} + +private func forScenarios(_ scenarios: [TestScenario]) -> [TestCase] { + scenarios.map { scenario -> [TestCase] in + if scenario.allTransportsAndProtocols { + [ + PartialClientOptions(useBinaryProtocol: true), + PartialClientOptions(useBinaryProtocol: false), + ].map { options -> TestCase in + .init( + disabled: scenario.disabled, + scenario: scenario, + options: options, + channelName: "\(scenario.description) \(options.useBinaryProtocol ? "binary" : "text")", + ) + } + } else { + [.init(disabled: scenario.disabled, scenario: scenario, options: nil, channelName: scenario.description)] + } + } + .flatMap(\.self) +} + +private protocol Scenarios { + associatedtype Context + static var scenarios: [TestScenario] { get } +} + +private extension Scenarios { + static var testCases: [TestCase] { + forScenarios(scenarios) + } +} + +// MARK: - Test lifecycle + +/// Creates the fixtures on ``objectsFixturesChannel`` if not yet created. +/// +/// This fulfils the role of JS's `before` hook. +private actor ObjectsFixturesTrait: SuiteTrait, TestScoping { + private actor SetupManager { + private var setupTask: Task? + + func setUpFixtures() async throws { + let setupTask: Task = if let existingSetupTask = self.setupTask { + existingSetupTask + } else { + Task { + let helper = try await ObjectsHelper() + try await helper.initForChannel(objectsFixturesChannel) + } + } + + try await setupTask.value + } + } + + private static let setupManager = SetupManager() + + func provideScope(for _: Test, testCase _: Test.Case?, performing function: () async throws -> Void) async throws { + try await Self.setupManager.setUpFixtures() + try await function() + } +} + +extension Trait where Self == ObjectsFixturesTrait { + static var objectsFixtures: Self { Self() } +} + +// MARK: - Test suite + +@Suite(.objectsFixtures) +private struct ObjectsIntegrationTests { + // TODO: Add the non-parameterised tests + + enum FirstSetOfScenarios: Scenarios { + struct Context { + var objects: any RealtimeObjects + var root: any LiveMap + var objectsHelper: ObjectsHelper + var channelName: String + var channel: ARTRealtimeChannel + var client: ARTRealtime + var clientOptions: PartialClientOptions? + } + + static let scenarios: [TestScenario] = { + let objectSyncSequenceScenarios: [TestScenario] = [ + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "OBJECT_SYNC sequence builds object tree on channel attachment", + action: { ctx in + let client = ctx.client + + try await waitFixtureChannelIsReady(client) + + let channel = client.channels.get(objectsFixturesChannel, options: channelOptionsWithObjects()) + let objects = channel.objects + + try await channel.attachAsync() + let root = try await objects.getRoot() + + let counterKeys = ["emptyCounter", "initialValueCounter", "referencedCounter"] + let mapKeys = ["emptyMap", "referencedMap", "valuesMap"] + let rootKeysCount = counterKeys.count + mapKeys.count + + #expect(root.size == rootKeysCount, "Check root has correct number of keys") + + for key in counterKeys { + let counter = try #require(try root.get(key: key)) + #expect(counter.liveCounterValue != nil, "Check counter at key=\"\(key)\" in root is of type LiveCounter") + } + + for key in mapKeys { + let map = try #require(try root.get(key: key)) + #expect(map.liveMapValue != nil, "Check map at key=\"\(key)\" in root is of type LiveMap") + } + + let valuesMap = try #require(root.get(key: "valuesMap")?.liveMapValue) + let valueMapKeys = [ + "stringKey", + "emptyStringKey", + "bytesKey", + "emptyBytesKey", + "numberKey", + "zeroKey", + "trueKey", + "falseKey", + "mapKey", + ] + #expect(valuesMap.size == valueMapKeys.count, "Check nested map has correct number of keys") + for key in valueMapKeys { + #expect(try valuesMap.get(key: key) != nil, "Check value at key=\"\(key)\" in nested map exists") + } + + // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 + withExtendedLifetime(channel) {} + }, + ), + .init( + disabled: true, // Uses LiveMap.set which we haven't implemented yet + allTransportsAndProtocols: true, + description: "OBJECT_SYNC sequence builds object tree with all operations applied", + action: { ctx in + let root = ctx.root + let objects = ctx.objects + + // Create the promise first, before the operations that will trigger it + async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await waitForMapKeyUpdate(root, "counter") + } + group.addTask { + await waitForMapKeyUpdate(root, "map") + } + while try await group.next() != nil {} + } + + // MAP_CREATE + let map = try await objects.createMap(entries: ["shouldStay": .primitive(.string("foo")), "shouldDelete": .primitive(.string("bar"))]) + // COUNTER_CREATE + let counter = try await objects.createCounter(count: 1) + + // Set the values and await the promise + async let setMapPromise: Void = root.set(key: "map", value: .liveMap(map)) + async let setCounterPromise: Void = root.set(key: "counter", value: .liveCounter(counter)) + _ = try await (setMapPromise, setCounterPromise, objectsCreatedPromise) + + // Create the promise first, before the operations that will trigger it + async let operationsAppliedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await waitForMapKeyUpdate(map, "anotherKey") + } + group.addTask { + await waitForMapKeyUpdate(map, "shouldDelete") + } + group.addTask { + await waitForCounterUpdate(counter) + } + while try await group.next() != nil {} + } + + // Perform the operations and await the promise + async let setAnotherKeyPromise: Void = map.set(key: "anotherKey", value: .primitive(.string("baz"))) + async let removeKeyPromise: Void = map.remove(key: "shouldDelete") + async let incrementPromise: Void = counter.increment(amount: 10) + _ = try await (setAnotherKeyPromise, removeKeyPromise, incrementPromise, operationsAppliedPromise) + + // create a new client and check it syncs with the aggregated data + let client2 = try await realtimeWithObjects(options: ctx.clientOptions) + + try await monitorConnectionThenCloseAndFinishAsync(client2) { + let channel2 = client2.channels.get(ctx.channelName, options: channelOptionsWithObjects()) + let objects2 = channel2.objects + + try await channel2.attachAsync() + let root2 = try await objects2.getRoot() + + let counter2 = try #require(root2.get(key: "counter")?.liveCounterValue) + #expect(try counter2.value == 11, "Check counter has correct value") + + let map2 = try #require(root2.get(key: "map")?.liveMapValue) + #expect(map2.size == 2, "Check map has correct number of keys") + #expect(try #require(map2.get(key: "shouldStay")?.stringValue) == "foo", "Check map has correct value for \"shouldStay\" key") + #expect(try #require(map2.get(key: "anotherKey")?.stringValue) == "baz", "Check map has correct value for \"anotherKey\" key") + #expect(try map2.get(key: "shouldDelete") == nil, "Check map does not have \"shouldDelete\" key") + + // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 + withExtendedLifetime(channel2) {} + } + }, + ), + .init( + disabled: true, // Uses LiveMap.set which we haven't implemented yet + allTransportsAndProtocols: false, + description: "OBJECT_SYNC sequence does not change references to existing objects", + action: { ctx in + let root = ctx.root + let objects = ctx.objects + let channel = ctx.channel + let client = ctx.client + + // Create the promise first, before the operations that will trigger it + async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await waitForMapKeyUpdate(root, "counter") + } + group.addTask { + await waitForMapKeyUpdate(root, "map") + } + while try await group.next() != nil {} + } + + let map = try await objects.createMap() + let counter = try await objects.createCounter() + + // Set the values and await the promise + async let setMapPromise: Void = root.set(key: "map", value: .liveMap(map)) + async let setCounterPromise: Void = root.set(key: "counter", value: .liveCounter(counter)) + _ = try await (setMapPromise, setCounterPromise, objectsCreatedPromise) + + try await channel.detachAsync() + + // wait for the actual OBJECT_SYNC message to confirm it was received and processed + async let objectSyncPromise: Void = waitForObjectSync(client) + try await channel.attachAsync() + try await objectSyncPromise + + let newRootRef = try await channel.objects.getRoot() + let newMapRefMap = try #require(newRootRef.get(key: "map")?.liveMapValue) + let newCounterRef = try #require(newRootRef.get(key: "counter")?.liveCounterValue) + + #expect(newRootRef === root, "Check root reference is the same after OBJECT_SYNC sequence") + #expect(newMapRefMap === map, "Check map reference is the same after OBJECT_SYNC sequence") + #expect(newCounterRef === counter, "Check counter reference is the same after OBJECT_SYNC sequence") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "LiveCounter is initialized with initial value from OBJECT_SYNC sequence", + action: { ctx in + let client = ctx.client + + try await waitFixtureChannelIsReady(client) + + let channel = client.channels.get(objectsFixturesChannel, options: channelOptionsWithObjects()) + let objects = channel.objects + + try await channel.attachAsync() + let root = try await objects.getRoot() + + let counters = [ + (key: "emptyCounter", value: 0), + (key: "initialValueCounter", value: 10), + (key: "referencedCounter", value: 20), + ] + + for counter in counters { + let counterObj = try #require(root.get(key: counter.key)?.liveCounterValue) + #expect(try counterObj.value == Double(counter.value), "Check counter at key=\"\(counter.key)\" in root has correct value") + } + + // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 + withExtendedLifetime(channel) {} + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "LiveMap is initialized with initial value from OBJECT_SYNC sequence", + action: { ctx in + let client = ctx.client + + try await waitFixtureChannelIsReady(client) + + let channel = client.channels.get(objectsFixturesChannel, options: channelOptionsWithObjects()) + let objects = channel.objects + + try await channel.attachAsync() + let root = try await objects.getRoot() + + let emptyMap = try #require(root.get(key: "emptyMap")?.liveMapValue) + #expect(emptyMap.size == 0, "Check empty map in root has no keys") + + let referencedMap = try #require(root.get(key: "referencedMap")?.liveMapValue) + #expect(referencedMap.size == 1, "Check referenced map in root has correct number of keys") + + let counterFromReferencedMap = try #require(referencedMap.get(key: "counterKey")?.liveCounterValue) + #expect(try counterFromReferencedMap.value == 20, "Check nested counter has correct value") + + let valuesMap = try #require(root.get(key: "valuesMap")?.liveMapValue) + #expect(valuesMap.size == 9, "Check values map in root has correct number of keys") + + #expect(try #require(valuesMap.get(key: "stringKey")?.stringValue) == "stringValue", "Check values map has correct string value key") + #expect(try #require(valuesMap.get(key: "emptyStringKey")?.stringValue).isEmpty, "Check values map has correct empty string value key") + #expect(try #require(valuesMap.get(key: "bytesKey")?.dataValue) == Data(base64Encoded: "eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9"), "Check values map has correct bytes value key") + #expect(try #require(valuesMap.get(key: "emptyBytesKey")?.dataValue) == Data(base64Encoded: ""), "Check values map has correct empty bytes values key") + #expect(try #require(valuesMap.get(key: "numberKey")?.numberValue) == 1, "Check values map has correct number value key") + #expect(try #require(valuesMap.get(key: "zeroKey")?.numberValue) == 0, "Check values map has correct zero number value key") + #expect(try #require(valuesMap.get(key: "trueKey")?.boolValue as Bool?) == true, "Check values map has correct 'true' value key") + #expect(try #require(valuesMap.get(key: "falseKey")?.boolValue as Bool?) == false, "Check values map has correct 'false' value key") + + let mapFromValuesMap = try #require(valuesMap.get(key: "mapKey")?.liveMapValue) + #expect(mapFromValuesMap.size == 1, "Check nested map has correct number of keys") + + // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 + withExtendedLifetime(channel) {} + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "LiveMap can reference the same object in their keys", + action: { ctx in + let client = ctx.client + + try await waitFixtureChannelIsReady(client) + + let channel = client.channels.get(objectsFixturesChannel, options: channelOptionsWithObjects()) + let objects = channel.objects + + try await channel.attachAsync() + let root = try await objects.getRoot() + + let referencedCounter = try #require(root.get(key: "referencedCounter")?.liveCounterValue) + let referencedMap = try #require(root.get(key: "referencedMap")?.liveMapValue) + let valuesMap = try #require(root.get(key: "valuesMap")?.liveMapValue) + + let counterFromReferencedMap = try #require(referencedMap.get(key: "counterKey")?.liveCounterValue, "Check nested counter is of type LiveCounter") + #expect(counterFromReferencedMap === referencedCounter, "Check nested counter is the same object instance as counter on the root") + #expect(try counterFromReferencedMap.value == 20, "Check nested counter has correct value") + + let mapFromValuesMap = try #require(valuesMap.get(key: "mapKey")?.liveMapValue, "Check nested map is of type LiveMap") + #expect(mapFromValuesMap.size == 1, "Check nested map has correct number of keys") + #expect(mapFromValuesMap === referencedMap, "Check nested map is the same object instance as map on the root") + + // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 + withExtendedLifetime(channel) {} + }, + ), + .init( + disabled: true, // This relies on the LiveMap.get returning `nil` when the referenced object's internal `tombstone` flag is true; this is not yet specified, have asked in https://ably-real-time.slack.com/archives/D067YAXGYQ5/p1751376526929339 + allTransportsAndProtocols: false, + description: "OBJECT_SYNC sequence with object state \"tombstone\" property creates tombstoned object", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + let mapId = objectsHelper.fakeMapObjectId() + let counterId = objectsHelper.fakeCounterObjectId() + + try await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:", // empty serial so sync sequence ends immediately + // add object states with tombstone=true + state: [ + objectsHelper.mapObject( + objectId: mapId, + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialEntries: [:], + tombstone: true, + ), + objectsHelper.counterObject( + objectId: counterId, + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialCount: 1, + tombstone: true, + ), + objectsHelper.mapObject( + objectId: "root", + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialEntries: [ + "map": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["objectId": .string(mapId)]), + ]), + "counter": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["objectId": .string(counterId)]), + ]), + "foo": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + ], + ), + ], + ) + + #expect(try root.get(key: "map") == nil, "Check map does not exist on root after OBJECT_SYNC with \"tombstone=true\" for a map object") + #expect(try root.get(key: "counter") == nil, "Check counter does not exist on root after OBJECT_SYNC with \"tombstone=true\" for a counter object") + // control check that OBJECT_SYNC was applied at all + #expect(try root.get(key: "foo") != nil, "Check property exists on root after OBJECT_SYNC") + }, + ), + .init( + disabled: true, // Uses LiveMap.subscribe (through waitForMapKeyUpdate) which we haven't implemented yet. It also seems to rely on the same internal `tombstone` flag as the previous test. + allTransportsAndProtocols: true, + description: "OBJECT_SYNC sequence with object state \"tombstone\" property deletes existing object", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + let channel = ctx.channel + + async let counterCreatedPromise: Void = waitForMapKeyUpdate(root, "counter") + let counterResult = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "counter", + createOp: objectsHelper.counterCreateRestOp(number: 1), + ) + _ = await counterCreatedPromise + + #expect(try root.get(key: "counter") != nil, "Check counter exists on root before OBJECT_SYNC sequence with \"tombstone=true\"") + + // inject an OBJECT_SYNC message where a counter is now tombstoned + try await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:", // empty serial so sync sequence ends immediately + state: [ + objectsHelper.counterObject( + objectId: counterResult.objectId, + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialCount: 1, + tombstone: true, + ), + objectsHelper.mapObject( + objectId: "root", + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialEntries: [ + "counter": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["objectId": .string(counterResult.objectId)]), + ]), + "foo": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + ], + ), + ], + ) + + #expect(try root.get(key: "counter") == nil, "Check counter does not exist on root after OBJECT_SYNC with \"tombstone=true\" for an existing counter object") + // control check that OBJECT_SYNC was applied at all + #expect(try root.get(key: "foo") != nil, "Check property exists on root after OBJECT_SYNC") + }, + ), + .init( + disabled: true, // Uses LiveMap.subscribe (through waitForMapKeyUpdate) which we haven't implemented yet + allTransportsAndProtocols: true, + description: "OBJECT_SYNC sequence with object state \"tombstone\" property triggers subscription callback for existing object", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + let channel = ctx.channel + + async let counterCreatedPromise: Void = waitForMapKeyUpdate(root, "counter") + let counterResult = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "counter", + createOp: objectsHelper.counterCreateRestOp(number: 1), + ) + _ = await counterCreatedPromise + + async let counterSubPromise: Void = withCheckedThrowingContinuation { continuation in + do { + try #require(root.get(key: "counter")?.liveCounterValue).subscribe { update in + #expect(update.amount == -1, "Check counter subscription callback is called with an expected update object after OBJECT_SYNC sequence with \"tombstone=true\"") + continuation.resume() + } + } catch { + continuation.resume(throwing: error) + } + } + + // inject an OBJECT_SYNC message where a counter is now tombstoned + try await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:", // empty serial so sync sequence ends immediately + state: [ + objectsHelper.counterObject( + objectId: counterResult.objectId, + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialCount: 1, + tombstone: true, + ), + objectsHelper.mapObject( + objectId: "root", + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialEntries: [ + "counter": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["objectId": .string(counterResult.objectId)]), + ]), + ], + ), + ], + ) + + _ = try await counterSubPromise + }, + ), + ] + + let applyOperationsScenarios: [TestScenario] = [ + // TODO: Implement these scenarios + ] + + let applyOperationsDuringSyncScenarios: [TestScenario] = [ + // TODO: Implement these scenarios + ] + + let writeApiScenarios: [TestScenario] = [ + // TODO: Implement these scenarios + ] + + let liveMapEnumerationScenarios: [TestScenario] = [ + // TODO: Implement these scenarios + ] + + return [ + objectSyncSequenceScenarios, + applyOperationsScenarios, + applyOperationsDuringSyncScenarios, + writeApiScenarios, + liveMapEnumerationScenarios, + ].flatMap(\.self) + }() + } + + @Test(arguments: FirstSetOfScenarios.testCases) + func firstSetOfScenarios(testCase: TestCase) async throws { + guard !testCase.disabled else { + withKnownIssue { + Issue.record("Test case is disabled") + } + return + } + + let objectsHelper = try await ObjectsHelper() + let client = try await realtimeWithObjects(options: testCase.options) + + try await monitorConnectionThenCloseAndFinishAsync(client) { + let channel = client.channels.get(testCase.channelName, options: channelOptionsWithObjects()) + let objects = channel.objects + + try await channel.attachAsync() + let root = try await objects.getRoot() + + try await testCase.scenario.action( + .init( + objects: objects, + root: root, + objectsHelper: objectsHelper, + channelName: testCase.channelName, + channel: channel, + client: client, + clientOptions: testCase.options, + ), + ) + + // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 + withExtendedLifetime(channel) {} + } + } + + // TODO: Implement the remaining scenarios +} + +// swiftlint:enable trailing_closure diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift new file mode 100644 index 00000000..154285cf --- /dev/null +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift @@ -0,0 +1,456 @@ +@preconcurrency import Ably.Private + +class TestProxyTransportFactory: RealtimeTransportFactory { + // This value will be used by all TestProxyTransportFactory instances created by this factory (including those created before this property is updated). + var fakeNetworkResponse: FakeNetworkResponse? + + // This value will be used by all TestProxyTransportFactory instances created by this factory (including those created before this property is updated). + var networkConnectEvent: ((ARTRealtimeTransport, URL) -> Void)? + + var transportCreatedEvent: ((ARTRealtimeTransport) -> Void)? + + func transport(withRest rest: ARTRestInternal, options: ARTClientOptions, resumeKey: String?, logger: InternalLog) -> ARTRealtimeTransport { + let webSocketFactory = WebSocketFactory() + + let testProxyTransport = TestProxyTransport( + factory: self, + rest: rest, + options: options, + resumeKey: resumeKey, + logger: logger, + webSocketFactory: webSocketFactory, + ) + + webSocketFactory.testProxyTransport = testProxyTransport + + transportCreatedEvent?(testProxyTransport) + + return testProxyTransport + } + + private class WebSocketFactory: Ably.WebSocketFactory { + weak var testProxyTransport: TestProxyTransport? + + func createWebSocket(with request: URLRequest, logger: InternalLog?) -> ARTWebSocket { + let webSocket = WebSocket(urlRequest: request, logger: logger) + webSocket.testProxyTransport = testProxyTransport + + return webSocket + } + } + + private class WebSocket: ARTSRWebSocket { + weak var testProxyTransport: TestProxyTransport? + + override func open() { + guard let testProxyTransport else { + preconditionFailure("Tried to fetch testProxyTransport but it's already been deallocated") + } + if !testProxyTransport.handleWebSocketOpen() { + super.open() + } + } + } +} + +/// Records each message for test purpose. +class TestProxyTransport: ARTWebSocketTransport, @unchecked Sendable { + /// The factory that created this TestProxyTransport instance. + private weak var _factory: TestProxyTransportFactory? + private var factory: TestProxyTransportFactory { + guard let _factory else { + preconditionFailure("Tried to fetch factory but it's already been deallocated") + } + return _factory + } + + init(factory: TestProxyTransportFactory, rest: ARTRestInternal, options: ARTClientOptions, resumeKey: String?, logger: InternalLog, webSocketFactory: WebSocketFactory) { + _factory = factory + super.init(rest: rest, options: options, resumeKey: resumeKey, logger: logger, webSocketFactory: webSocketFactory) + } + + fileprivate(set) var lastUrl: URL? + + private var _protocolMessagesReceived: [ARTProtocolMessage] = [] + var protocolMessagesReceived: [ARTProtocolMessage] { + var result: [ARTProtocolMessage] = [] + queue.sync { + result = self._protocolMessagesReceived + } + return result + } + + private var _protocolMessagesSent: [ARTProtocolMessage] = [] + var protocolMessagesSent: [ARTProtocolMessage] { + var result: [ARTProtocolMessage] = [] + queue.sync { + result = self._protocolMessagesSent + } + return result + } + + private var _protocolMessagesSentIgnored: [ARTProtocolMessage] = [] + var protocolMessagesSentIgnored: [ARTProtocolMessage] { + var result: [ARTProtocolMessage] = [] + queue.sync { + result = self._protocolMessagesSentIgnored + } + return result + } + + fileprivate(set) var rawDataSent = [Data]() + fileprivate(set) var rawDataReceived = [Data]() + + private var replacingAcksWithNacks: ARTErrorInfo? + + var ignoreWebSocket = false + var ignoreSends = false + var actionsIgnored = [ARTProtocolMessageAction]() + + var queue: DispatchQueue { + guard let delegateDispatchQueue = websocket?.delegateDispatchQueue else { + preconditionFailure("I don't know what queue to use in this case (in ably-cocoa they used AblyTests.queue); cross this bridge if we come to it") + } + return delegateDispatchQueue + } + + private var callbackBeforeProcessingIncomingMessage: ((ARTProtocolMessage) -> Void)? + private var callbackAfterProcessingIncomingMessage: ((ARTProtocolMessage) -> Void)? + private var callbackBeforeProcessingOutgoingMessage: ((ARTProtocolMessage) -> Void)? + private var callbackBeforeIncomingMessageModifier: ((ARTProtocolMessage) -> ARTProtocolMessage?)? + private var callbackAfterIncomingMessageModifier: ((ARTProtocolMessage) -> ARTProtocolMessage?)? + + // Represents a request to replace the implementation of a method. + private final class Hook: Sendable { + private let implementation: @Sendable () -> Void + + init(implementation: @escaping @Sendable () -> Void) { + self.implementation = implementation + } + + func performImplementation() { + implementation() + } + } + + /// The active request, if any, to replace the implementation of the ARTWebSocket#open method for all WebSocket objects created by this transport. Access must be synchronised using webSocketOpenHookSemaphore. + private var webSocketOpenHook: Hook? + /// Used for synchronising access to webSocketOpenHook. + private let webSocketOpenHookSempahore = DispatchSemaphore(value: 1) + + func setListenerBeforeProcessingIncomingMessage(_ callback: ((ARTProtocolMessage) -> Void)?) { + queue.sync { + self.callbackBeforeProcessingIncomingMessage = callback + } + } + + func setListenerAfterProcessingIncomingMessage(_ callback: ((ARTProtocolMessage) -> Void)?) { + queue.sync { + self.callbackAfterProcessingIncomingMessage = callback + } + } + + func setListenerBeforeProcessingOutgoingMessage(_ callback: ((ARTProtocolMessage) -> Void)?) { + queue.sync { + self.callbackBeforeProcessingOutgoingMessage = callback + } + } + + /// The modifier will be called on the internal queue. + /// + /// If `callback` returns nil, the message will be ignored. + func setBeforeIncomingMessageModifier(_ callback: ((ARTProtocolMessage) -> ARTProtocolMessage?)?) { + callbackBeforeIncomingMessageModifier = callback + } + + /// The modifier will be called on the internal queue. + /// + /// If `callback` returns nil, the message will be ignored. + func setAfterIncomingMessageModifier(_ callback: ((ARTProtocolMessage) -> ARTProtocolMessage?)?) { + callbackAfterIncomingMessageModifier = callback + } + + func enableReplaceAcksWithNacks(with errorInfo: ARTErrorInfo) { + queue.sync { + self.replacingAcksWithNacks = errorInfo + } + } + + func disableReplaceAcksWithNacks() { + queue.sync { + self.replacingAcksWithNacks = nil + } + } + + func emulateTokenRevokationBeforeConnected() { + setBeforeIncomingMessageModifier { protocolMessage in + if protocolMessage.action == .connected { + protocolMessage.action = .disconnected + protocolMessage.error = .create(withCode: Int(ARTErrorCode.tokenRevoked.rawValue), status: 401, message: "Test token revokation") + } + return protocolMessage + } + } + + // MARK: ARTWebSocket + + override func connect(withKey key: String) { + if let fakeResponse = factory.fakeNetworkResponse { + setupFakeNetworkResponse(fakeResponse) + } + super.connect(withKey: key) + performNetworkConnectEvent() + } + + override func connect(withToken token: String) { + if let fakeResponse = factory.fakeNetworkResponse { + setupFakeNetworkResponse(fakeResponse) + } + super.connect(withToken: token) + performNetworkConnectEvent() + } + + private func addWebSocketOpenHook(withImplementation implementation: @Sendable @escaping () -> Void) -> Hook { + webSocketOpenHookSempahore.wait() + let hook = Hook(implementation: implementation) + webSocketOpenHook = hook + webSocketOpenHookSempahore.signal() + return hook + } + + private func removeWebSocketOpenHook(_ hook: Hook) { + webSocketOpenHookSempahore.wait() + if webSocketOpenHook === hook { + webSocketOpenHook = nil + } + webSocketOpenHookSempahore.signal() + } + + /// If this transport has been configured with a replacement implementation of ARTWebSocket#open, then this performs that implementation and returns `true`. Else, returns `false`. + func handleWebSocketOpen() -> Bool { + let hook: Hook? + webSocketOpenHookSempahore.wait() + hook = webSocketOpenHook + webSocketOpenHookSempahore.signal() + + if let hook { + hook.performImplementation() + return true + } else { + return false + } + } + + private func setupFakeNetworkResponse(_ networkResponse: FakeNetworkResponse) { + nonisolated(unsafe) var hook: Hook? + hook = addWebSocketOpenHook { + if self.factory.fakeNetworkResponse == nil { + return + } + + func performFakeConnectionError(_ secondsForDelay: TimeInterval, error: ARTRealtimeTransportError) { + self.queue.asyncAfter(deadline: .now() + secondsForDelay) { + self.delegate?.realtimeTransportFailed(self, withError: error) + if let hook { + self.removeWebSocketOpenHook(hook) + } + } + } + + guard let url = self.lastUrl else { + fatalError("MockNetworkResponse: lastUrl should not be nil") + } + + switch networkResponse { + case .noInternet, + .hostUnreachable, + .hostInternalError, + .host400BadRequest, + .arbitraryError: + performFakeConnectionError(0.1, error: networkResponse.transportError(for: url)) + case let .requestTimeout(timeout): + performFakeConnectionError(0.1 + timeout, error: networkResponse.transportError(for: url)) + } + } + } + + private func performNetworkConnectEvent() { + guard let networkConnectEventHandler = factory.networkConnectEvent else { + return + } + if let lastUrl { + networkConnectEventHandler(self, lastUrl) + } else { + queue.asyncAfter(deadline: .now() + 0.1) { + // Repeat until `lastUrl` is assigned. + self.performNetworkConnectEvent() + } + } + } + + override func setupWebSocket(_ params: [String: URLQueryItem], with options: ARTClientOptions, resumeKey: String?) -> URL { + let url = super.setupWebSocket(params, with: options, resumeKey: resumeKey) + lastUrl = url + return url + } + + func send(_ message: ARTProtocolMessage) { + // swiftlint:disable:next force_try + let data = try! encoder.encode(message) + send(data, withSource: message) + } + + @discardableResult + override func send(_ data: Data, withSource decodedObject: Any?) -> Bool { + if let networkAnswer = factory.fakeNetworkResponse, let ws = websocket { + // Ignore it because it should fake a failure. + webSocket(ws, didFailWithError: networkAnswer.error) + return false + } + + if let msg = decodedObject as? ARTProtocolMessage { + if ignoreSends { + _protocolMessagesSentIgnored.append(msg) + return false + } + _protocolMessagesSent.append(msg) + if let performEvent = callbackBeforeProcessingOutgoingMessage { + DispatchQueue.main.async { + performEvent(msg) + } + } + } + rawDataSent.append(data) + return super.send(data, withSource: decodedObject) + } + + override func receive(_ original: ARTProtocolMessage) { + if original.action == .ack || original.action == .presence { + if let error = replacingAcksWithNacks { + original.action = .nack + original.error = error + } + } + _protocolMessagesReceived.append(original) + if actionsIgnored.contains(original.action) { + return + } + if let performEvent = callbackBeforeProcessingIncomingMessage { + DispatchQueue.main.async { + performEvent(original) + } + } + var msg = original + if let performEvent = callbackBeforeIncomingMessageModifier { + guard let modifiedMsg = performEvent(msg) else { + return + } + msg = modifiedMsg + } + super.receive(msg) + if let performEvent = callbackAfterIncomingMessageModifier { + guard let modifiedMsg = performEvent(msg) else { + return + } + msg = modifiedMsg + } + if let performEvent = callbackAfterProcessingIncomingMessage { + DispatchQueue.main.async { + performEvent(msg) + } + } + } + + override func receive(with data: Data) -> ARTProtocolMessage? { + rawDataReceived.append(data) + return super.receive(with: data) + } + + override func webSocketDidOpen(_ webSocket: ARTWebSocket) { + if !ignoreWebSocket { + super.webSocketDidOpen(webSocket) + } + } + + override func webSocket(_ webSocket: ARTWebSocket, didFailWithError error: Error) { + if !ignoreWebSocket { + super.webSocket(webSocket, didFailWithError: error) + } + } + + override func webSocket(_ webSocket: ARTWebSocket, didReceiveMessage message: Any?) { + if let networkAnswer = factory.fakeNetworkResponse, let ws = websocket { + // Ignore it because it should fake a failure. + self.webSocket(ws, didFailWithError: networkAnswer.error) + return + } + + if !ignoreWebSocket { + super.webSocket(webSocket, didReceiveMessage: message as Any) + } + } + + override func webSocket(_ webSocket: ARTWebSocket, didCloseWithCode code: Int, reason: String?, wasClean: Bool) { + if !ignoreWebSocket { + super.webSocket(webSocket, didCloseWithCode: code, reason: reason, wasClean: wasClean) + } + } + + // MARK: Helpers + + func simulateTransportSuccess(clientId: String? = nil) { + ignoreWebSocket = true + let msg = ARTProtocolMessage() + msg.action = .connected + msg.connectionId = "x-xxxxxxxx" + msg.connectionKey = "xxxxxxx-xxxxxxxxxxxxxx-xxxxxxxx" + msg.connectionDetails = ARTConnectionDetails(clientId: clientId, connectionKey: "a8c10!t-3D0O4ejwTdvLkl-b33a8c10", maxMessageSize: 16384, maxFrameSize: 262_144, maxInboundRate: 250, connectionStateTtl: 60, serverId: "testServerId", maxIdleInterval: 15000) + super.receive(msg) + } +} + +// swiftlint:disable:next identifier_name +let AblyTestsErrorDomain = "test.ably.io" + +enum FakeNetworkResponse { + case noInternet + case hostUnreachable + case requestTimeout(timeout: TimeInterval) + case hostInternalError(code: Int) + case host400BadRequest + case arbitraryError + + var error: NSError { + switch self { + case .noInternet: + NSError(domain: NSPOSIXErrorDomain, code: 50, userInfo: [NSLocalizedDescriptionKey: "network is down", NSLocalizedFailureReasonErrorKey: AblyTestsErrorDomain + ".FakeNetworkResponse"]) + case .hostUnreachable: + NSError(domain: kCFErrorDomainCFNetwork as String, code: 2, userInfo: [NSLocalizedDescriptionKey: "host unreachable", NSLocalizedFailureReasonErrorKey: AblyTestsErrorDomain + ".FakeNetworkResponse"]) + case .requestTimeout: + NSError(domain: "com.squareup.SocketRocket", code: 504, userInfo: [NSLocalizedDescriptionKey: "timed out", NSLocalizedFailureReasonErrorKey: AblyTestsErrorDomain + ".FakeNetworkResponse"]) + case let .hostInternalError(code): + NSError(domain: AblyTestsErrorDomain, code: code, userInfo: [NSLocalizedDescriptionKey: "internal error", NSLocalizedFailureReasonErrorKey: AblyTestsErrorDomain + ".FakeNetworkResponse"]) + case .host400BadRequest: + NSError(domain: AblyTestsErrorDomain, code: 400, userInfo: [NSLocalizedDescriptionKey: "bad request", NSLocalizedFailureReasonErrorKey: AblyTestsErrorDomain + ".FakeNetworkResponse"]) + case .arbitraryError: + NSError(domain: AblyTestsErrorDomain, code: 1, userInfo: [NSLocalizedDescriptionKey: "error from FakeNetworkResponse.arbitraryError"]) + } + } + + func transportError(for url: URL) -> ARTRealtimeTransportError { + switch self { + case .noInternet: + ARTRealtimeTransportError(error: error, type: .noInternet, url: url) + case .hostUnreachable: + ARTRealtimeTransportError(error: error, type: .hostUnreachable, url: url) + case .requestTimeout: + ARTRealtimeTransportError(error: error, type: .timeout, url: url) + case let .hostInternalError(code): + ARTRealtimeTransportError(error: error, badResponseCode: code, url: url) + case .host400BadRequest: + ARTRealtimeTransportError(error: error, badResponseCode: 400, url: url) + case .arbitraryError: + ARTRealtimeTransportError(error: error, type: .other, url: url) + } + } +} diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift new file mode 100644 index 00000000..6893a5f7 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift @@ -0,0 +1,30 @@ +import Ably +@testable import AblyLiveObjects + +final class MockCoreSDK: CoreSDK { + /// Synchronizes access to all of this instance's mutable state. + private let mutex = NSLock() + + private nonisolated(unsafe) var _channelState: ARTRealtimeChannelState + + init(channelState: ARTRealtimeChannelState) { + _channelState = channelState + } + + func sendObject(objectMessages _: [AblyLiveObjects.OutboundObjectMessage]) async throws(AblyLiveObjects.InternalError) { + protocolRequirementNotImplemented() + } + + var channelState: ARTRealtimeChannelState { + get { + mutex.withLock { + _channelState + } + } + set { + mutex.withLock { + _channelState = newValue + } + } + } +} diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectPoolDelegate.swift b/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectPoolDelegate.swift new file mode 100644 index 00000000..b094f2e9 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectPoolDelegate.swift @@ -0,0 +1,25 @@ +@testable import AblyLiveObjects +import Foundation + +/// A mock delegate that can return predefined objects +final class MockLiveMapObjectPoolDelegate: LiveMapObjectPoolDelegate { + private let mutex = NSLock() + private nonisolated(unsafe) var _objects: [String: ObjectsPool.Entry] = [:] + var objects: [String: ObjectsPool.Entry] { + get { + mutex.withLock { + _objects + } + } + + set { + mutex.withLock { + _objects = newValue + } + } + } + + func getObjectFromPool(id: String) -> ObjectsPool.Entry? { + objects[id] + } +} diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift new file mode 100644 index 00000000..2495cf3d --- /dev/null +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -0,0 +1,346 @@ +@testable import AblyLiveObjects +import AblyPlugin +import Testing + +struct ObjectsPoolTests { + /// Tests for the `createZeroValueObject` method, covering RTO6 specification points + struct CreateZeroValueObjectTests { + // @spec RTO6a + @Test + func returnsExistingObject() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let existingMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) + + let result = pool.createZeroValueObject(forObjectID: "map:123@456", mapDelegate: delegate, coreSDK: coreSDK) + let map = try #require(result?.mapValue) + #expect(map as AnyObject === existingMap as AnyObject) + } + + // @spec RTO6b2 + @Test + func createsZeroValueMap() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + + let result = pool.createZeroValueObject(forObjectID: "map:123@456", mapDelegate: delegate, coreSDK: coreSDK) + let map = try #require(result?.mapValue) + + // Verify it was added to the pool + #expect(pool.entries["map:123@456"]?.mapValue != nil) + + // Verify the map has the delegate set + #expect(map.testsOnly_delegate as AnyObject === delegate as AnyObject) + // Verify the objectID is correctly set + #expect(map.testsOnly_objectID == "map:123@456") + } + + // @spec RTO6b3 + @Test + func createsZeroValueCounter() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + + let result = pool.createZeroValueObject(forObjectID: "counter:123@456", mapDelegate: delegate, coreSDK: coreSDK) + let counter = try #require(result?.counterValue) + #expect(try counter.value == 0) + + // Verify it was added to the pool + #expect(pool.entries["counter:123@456"]?.counterValue != nil) + // Verify the objectID is correctly set + #expect(counter.testsOnly_objectID == "counter:123@456") + } + + // Sense check to see how it behaves when given an object ID not in the format of RTO6b1 (spec isn't prescriptive here) + @Test + func returnsNilForInvalidObjectId() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + + let result = pool.createZeroValueObject(forObjectID: "invalid", mapDelegate: delegate, coreSDK: coreSDK) + #expect(result == nil) + } + + // Sense check to see how it behaves when given an object ID not covered by RTO6b2 or RTO6b3 + @Test + func returnsNilForUnknownType() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + + let result = pool.createZeroValueObject(forObjectID: "unknown:123@456", mapDelegate: delegate, coreSDK: coreSDK) + #expect(result == nil) + #expect(pool.entries["unknown:123@456"] == nil) + } + } + + /// Tests for the `applySyncObjectsPool` method, covering RTO5c1 and RTO5c2 specification points + struct ApplySyncObjectsPoolTests { + // MARK: - RTO5c1 Tests + + // @specOneOf(1/2) RTO5c1a1 - Override the internal data for existing map objects + @Test + func updatesExistingMapObject() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let existingMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) + let logger = TestLogger() + + let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "updated_value") + let objectState = TestFactories.mapObjectState( + objectId: "map:hash@123", + siteTimeserials: ["site1": "ts1"], + entries: [key: entry], + ) + + pool.applySyncObjectsPool([objectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + + // Verify the existing map was updated by checking side effects of DefaultLiveMap.replaceData(using:) + let updatedMap = try #require(pool.entries["map:hash@123"]?.mapValue) + #expect(updatedMap === existingMap) + // Checking map data to verify replaceData was called successfully + #expect(try updatedMap.get(key: "key1")?.stringValue == "updated_value") + // Checking site timeserials to verify they were updated by replaceData + #expect(updatedMap.testsOnly_siteTimeserials == ["site1": "ts1"]) + } + + // @specOneOf(2/2) RTO5c1a1 - Override the internal data for existing counter objects + @Test + func updatesExistingCounterObject() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let existingCounter = DefaultLiveCounter.createZeroValued(coreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) + let logger = TestLogger() + + let objectState = TestFactories.counterObjectState( + objectId: "counter:hash@123", + siteTimeserials: ["site1": "ts1"], + count: 42, + ) + + pool.applySyncObjectsPool([objectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + + // Verify the existing counter was updated by checking side effects of DefaultLiveCounter.replaceData(using:) + let updatedCounter = try #require(pool.entries["counter:hash@123"]?.counterValue) + #expect(updatedCounter === existingCounter) + // Checking counter value to verify replaceData was called successfully + #expect(try updatedCounter.value == 42) + // Checking site timeserials to verify they were updated by replaceData + #expect(updatedCounter.testsOnly_siteTimeserials == ["site1": "ts1"]) + } + + // @spec RTO5c1b1a + @Test + func createsNewCounterObject() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + let logger = TestLogger() + + let objectState = TestFactories.counterObjectState( + objectId: "counter:hash@456", + siteTimeserials: ["site2": "ts2"], + count: 100, + ) + + pool.applySyncObjectsPool([objectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + + // Verify a new counter was created and data was set by checking side effects of DefaultLiveCounter.replaceData(using:) + let newCounter = try #require(pool.entries["counter:hash@456"]?.counterValue) + // Checking counter value to verify the new counter was created and replaceData was called + #expect(try newCounter.value == 100) + // Checking site timeserials to verify they were set by replaceData + #expect(newCounter.testsOnly_siteTimeserials == ["site2": "ts2"]) + // Verify the objectID is correctly set per RTO5c1b1a + #expect(newCounter.testsOnly_objectID == "counter:hash@456") + } + + // @spec RTO5c1b1b + @Test + func createsNewMapObject() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + let logger = TestLogger() + + let (key, entry) = TestFactories.stringMapEntry(key: "key2", value: "new_value") + let objectState = TestFactories.mapObjectState( + objectId: "map:hash@789", + siteTimeserials: ["site3": "ts3"], + entries: [key: entry], + ) + + pool.applySyncObjectsPool([objectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + + // Verify a new map was created and data was set by checking side effects of DefaultLiveMap.replaceData(using:) + let newMap = try #require(pool.entries["map:hash@789"]?.mapValue) + // Checking map data to verify the new map was created and replaceData was called + #expect(try newMap.get(key: "key2")?.stringValue == "new_value") + // Checking site timeserials to verify they were set by replaceData + #expect(newMap.testsOnly_siteTimeserials == ["site3": "ts3"]) + // Verify delegate was set on the new map + #expect(newMap.testsOnly_delegate as AnyObject === delegate as AnyObject) + // Verify the objectID and semantics are correctly set per RTO5c1b1b + #expect(newMap.testsOnly_objectID == "map:hash@789") + #expect(newMap.testsOnly_semantics == .known(.lww)) + } + + // @spec RTO5c1b1c + @Test + func ignoresNonMapOrCounterObject() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + let logger = TestLogger() + + let validObjectState = TestFactories.counterObjectState( + objectId: "counter:hash@456", + siteTimeserials: ["site2": "ts2"], + count: 100, + ) + + let invalidObjectState = TestFactories.objectState(objectId: "invalid") + + pool.applySyncObjectsPool([invalidObjectState, validObjectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + + // Check that there's no entry for the key that we don't know how to handle, and that it didn't interfere with the insertion of the we one that we do know how to handle + #expect(Set(pool.entries.keys) == ["root", "counter:hash@456"]) + } + + // MARK: - RTO5c2 Tests + + // @spec(RTO5c2) Remove objects not received during sync + @Test + func removesObjectsNotInSync() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let existingMap1 = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let existingMap2 = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let existingCounter = DefaultLiveCounter.createZeroValued(coreSDK: coreSDK) + + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: [ + "map:hash@1": .map(existingMap1), + "map:hash@2": .map(existingMap2), + "counter:hash@1": .counter(existingCounter), + ]) + let logger = TestLogger() + + // Only sync one of the existing objects + let objectState = TestFactories.mapObjectState(objectId: "map:hash@1") + + pool.applySyncObjectsPool([objectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + + // Verify only synced object and root remain + #expect(pool.entries.count == 2) // root + map:hash@1 + #expect(pool.entries["root"] != nil) + #expect(pool.entries["map:hash@1"] != nil) + #expect(pool.entries["map:hash@2"] == nil) // Should be removed + #expect(pool.entries["counter:hash@1"] == nil) // Should be removed + } + + // @spec(RTO5c2a) Root object must not be removed + @Test + func doesNotRemoveRootObject() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let existingMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) + let logger = TestLogger() + + // Sync with empty list (no objects) + pool.applySyncObjectsPool([], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + + // Verify root is preserved but other objects are removed + #expect(pool.entries.count == 1) // Only root + #expect(pool.entries["root"] != nil) + #expect(pool.entries["map:hash@1"] == nil) // Should be removed + } + + // @spec(RTO5c1, RTO5c2) Complete sync scenario with mixed operations + @Test + func handlesComplexSyncScenario() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + + let existingMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let existingCounter = DefaultLiveCounter.createZeroValued(coreSDK: coreSDK) + let toBeRemovedMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: [ + "map:existing@1": .map(existingMap), + "counter:existing@1": .counter(existingCounter), + "map:toremove@1": .map(toBeRemovedMap), + ]) + let logger = TestLogger() + + let syncObjects = [ + // Update existing map + TestFactories.mapObjectState( + objectId: "map:existing@1", + siteTimeserials: ["site1": "ts1"], + entries: ["updated": TestFactories.mapEntry(data: ObjectData(string: .string("updated")))], + ), + // Update existing counter + TestFactories.counterObjectState( + objectId: "counter:existing@1", + siteTimeserials: ["site2": "ts2"], + count: 100, + ), + // Create new map + TestFactories.mapObjectState( + objectId: "map:new@1", + siteTimeserials: ["site3": "ts3"], + entries: ["new": TestFactories.mapEntry(data: ObjectData(string: .string("new")))], + ), + // Create new counter + TestFactories.counterObjectState( + objectId: "counter:new@1", + siteTimeserials: ["site4": "ts4"], + count: 50, + ), + // Note: "map:toremove@1" is not in sync, so it should be removed + ] + + pool.applySyncObjectsPool(syncObjects, mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + + // Verify final state + #expect(pool.entries.count == 5) // root + 4 synced objects + + // Root should remain + #expect(pool.entries["root"] != nil) + + // Updated existing objects - verify by checking side effects of replaceData calls + let updatedMap = try #require(pool.entries["map:existing@1"]?.mapValue) + // Checking map data to verify replaceData was called successfully + #expect(try updatedMap.get(key: "updated")?.stringValue == "updated") + + let updatedCounter = try #require(pool.entries["counter:existing@1"]?.counterValue) + // Checking counter value to verify replaceData was called successfully + #expect(try updatedCounter.value == 100) + + // New objects - verify by checking side effects of replaceData calls + let newMap = try #require(pool.entries["map:new@1"]?.mapValue) + // Checking map data to verify the new map was created and replaceData was called + #expect(try newMap.get(key: "new")?.stringValue == "new") + // Verify the objectID and semantics are correctly set per RTO5c1b1b + #expect(newMap.testsOnly_objectID == "map:new@1") + #expect(newMap.testsOnly_semantics == .known(.lww)) + + let newCounter = try #require(pool.entries["counter:new@1"]?.counterValue) + // Checking counter value to verify the new counter was created and replaceData was called + #expect(try newCounter.value == 50) + // Verify the objectID is correctly set per RTO5c1b1a + #expect(newCounter.testsOnly_objectID == "counter:new@1") + + // Removed object + #expect(pool.entries["map:toremove@1"] == nil) + } + } +} diff --git a/Tests/AblyLiveObjectsTests/SyncCursorTests.swift b/Tests/AblyLiveObjectsTests/SyncCursorTests.swift new file mode 100644 index 00000000..83f1e373 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/SyncCursorTests.swift @@ -0,0 +1,106 @@ +@testable import AblyLiveObjects +import Testing + +struct SyncCursorTests { + // The parsing described in RTO5a1 + @Test + func validChannelSerialWithCursorValue() throws { + // Given + let channelSerial = "sequence123:cursor456" + + // When + let cursor = try SyncCursor(channelSerial: channelSerial) + + // Then + #expect(cursor.sequenceID == "sequence123") + #expect(cursor.cursorValue == "cursor456") + #expect(!cursor.isEndOfSequence) + } + + // The scenario described in RTO5a2 + @Test + func validChannelSerialAtEndOfSequence() throws { + // Given + let channelSerial = "sequence123:" + + // When + let cursor = try SyncCursor(channelSerial: channelSerial) + + // Then + #expect(cursor.sequenceID == "sequence123") + #expect(cursor.cursorValue == nil) + #expect(cursor.isEndOfSequence) + } + + @Test + func invalidChannelSerialWithoutColon() { + // Given + let channelSerial = "sequence123" + + // When/Then + do { + _ = try SyncCursor(channelSerial: channelSerial) + Issue.record("Expected error was not thrown") + } catch { + guard case let .other(.generic(underlyingError)) = error, + let syncError = underlyingError as? SyncCursor.Error, + case .channelSerialDoesNotMatchExpectedFormat = syncError + else { + Issue.record("Expected channelSerialDoesNotMatchExpectedFormat error") + return + } + } + } + + @Test + func invalidEmptyChannelSerial() { + // Given + let channelSerial = "" + + // When/Then + do { + _ = try SyncCursor(channelSerial: channelSerial) + Issue.record("Expected error was not thrown") + } catch { + guard case let .other(.generic(underlyingError)) = error, + let syncError = underlyingError as? SyncCursor.Error, + case .channelSerialDoesNotMatchExpectedFormat = syncError + else { + Issue.record("Expected channelSerialDoesNotMatchExpectedFormat error") + return + } + } + } + + // The spec isn't explicit here but doesn't rule this out + @Test + func validChannelSerialWithEmptySequenceID() throws { + // Given + let channelSerial = ":cursor456" + + // When + let cursor = try SyncCursor(channelSerial: channelSerial) + + // Then + // swiftlint:disable:next empty_string + #expect(cursor.sequenceID == "") + #expect(cursor.cursorValue == "cursor456") + #expect(!cursor.isEndOfSequence) + } + + // The spec isn't explicit here but doesn't rule this out + @Test + func validChannelSerialWithEmptySequenceIDAtEndOfSequence() throws { + // Given + let channelSerial = ":" + + // When + let cursor = try SyncCursor(channelSerial: channelSerial) + + // Then + // swiftlint:disable:next empty_string + #expect(cursor.sequenceID == "") + #expect(cursor.cursorValue == nil) + #expect(cursor.isEndOfSequence) + } +}