diff --git a/.cursor/rules/swift.mdc b/.cursor/rules/swift.mdc index 0ebf7370..3fa734a6 100644 --- a/.cursor/rules/swift.mdc +++ b/.cursor/rules/swift.mdc @@ -11,6 +11,7 @@ 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 writing a JSON string, favour using Swift raw string literals instead of escaping double quotes. - 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` diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index fe9fba8a..5cd3fd27 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -89,12 +89,46 @@ internal final class InternalDefaultLiveCounter: Sendable { } } - internal func increment(amount _: Double) async throws(ARTErrorInfo) { - notYetImplemented() + internal func increment(amount: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) { + do throws(InternalError) { + // RTLC12c + do { + try coreSDK.validateChannelState( + notIn: [.detached, .failed, .suspended], + operationDescription: "LiveCounter.increment", + ) + } catch { + throw error.toInternalError() + } + + // RTLC12e1 + if !amount.isFinite { + throw LiveObjectsError.counterIncrementAmountInvalid(amount: amount).toARTErrorInfo().toInternalError() + } + + let objectMessage = OutboundObjectMessage( + operation: .init( + // RTLC12e2 + action: .known(.counterInc), + // RTLC12e3 + objectId: objectID, + counterOp: .init( + // RTLC12e4 + amount: .init(value: amount), + ), + ), + ) + + // RTLC12f + try await coreSDK.publish(objectMessages: [objectMessage]) + } catch { + throw error.toARTErrorInfo() + } } - internal func decrement(amount _: Double) async throws(ARTErrorInfo) { - notYetImplemented() + internal func decrement(amount: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) { + // RTLC13b + try await increment(amount: -amount, coreSDK: coreSDK) } @discardableResult diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 427a09c6..09546909 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -173,12 +173,63 @@ internal final class InternalDefaultLiveMap: Sendable { try entries(coreSDK: coreSDK, delegate: delegate).map(\.value) } - internal func set(key _: String, value _: LiveMapValue) async throws(ARTErrorInfo) { - notYetImplemented() + internal func set(key: String, value: InternalLiveMapValue, coreSDK: CoreSDK) async throws(ARTErrorInfo) { + do throws(InternalError) { + // RTLM20c + do { + try coreSDK.validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.set") + } catch { + throw error.toInternalError() + } + + let objectMessage = OutboundObjectMessage( + operation: .init( + // RTLM20e2 + action: .known(.mapSet), + // RTLM20e3 + objectId: objectID, + mapOp: .init( + // RTLM20e4 + key: key, + // RTLM20e5 + data: value.toObjectData, + ), + ), + ) + + try await coreSDK.publish(objectMessages: [objectMessage]) + } catch { + throw error.toARTErrorInfo() + } } - internal func remove(key _: String) async throws(ARTErrorInfo) { - notYetImplemented() + internal func remove(key: String, coreSDK: CoreSDK) async throws(ARTErrorInfo) { + do throws(InternalError) { + // RTLM21c + do { + try coreSDK.validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.remove") + } catch { + throw error.toInternalError() + } + + let objectMessage = OutboundObjectMessage( + operation: .init( + // RTLM21e2 + action: .known(.mapRemove), + // RTLM21e3 + objectId: objectID, + mapOp: .init( + // RTLM21e4 + key: key, + ), + ), + ) + + // RTLM21f + try await coreSDK.publish(objectMessages: [objectMessage]) + } catch { + throw error.toARTErrorInfo() + } } @discardableResult diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 904265f7..91486f3d 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -157,20 +157,88 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } } - internal func createMap(entries _: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap { - notYetImplemented() + internal func createMap(entries: [String: InternalLiveMapValue], coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { + do throws(InternalError) { + // RTO11d + do { + try coreSDK.validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createMap") + } catch { + throw error.toInternalError() + } + + // RTO11f + // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/50) + let timestamp = clock.now + let creationOperation = ObjectCreationHelpers.creationOperationForLiveMap( + entries: entries, + timestamp: timestamp, + ) + + // RTO11g + try await coreSDK.publish(objectMessages: [creationOperation.objectMessage]) + + // RTO11h + return mutex.withLock { + mutableState.objectsPool.getOrCreateMap( + creationOperation: creationOperation, + logger: logger, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + } catch { + throw error.toARTErrorInfo() + } } - internal func createMap() async throws(ARTErrorInfo) -> any LiveMap { - notYetImplemented() + internal func createMap(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { + // RTO11f4b + try await createMap(entries: [:], coreSDK: coreSDK) } - internal func createCounter(count _: Double) async throws(ARTErrorInfo) -> any LiveCounter { - notYetImplemented() + internal func createCounter(count: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveCounter { + do throws(InternalError) { + // RTO12d + do { + try coreSDK.validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createCounter") + } catch { + throw error.toInternalError() + } + + // RTO12f1 + if !count.isFinite { + throw LiveObjectsError.counterInitialValueInvalid(value: count).toARTErrorInfo().toInternalError() + } + + // RTO12f + + // TODO: This is a stopgap; change to use server time per RTO12f5 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/50) + let timestamp = clock.now + let creationOperation = ObjectCreationHelpers.creationOperationForLiveCounter( + count: count, + timestamp: timestamp, + ) + + // RTO12g + try await coreSDK.publish(objectMessages: [creationOperation.objectMessage]) + + // RTO12h + return mutex.withLock { + mutableState.objectsPool.getOrCreateCounter( + creationOperation: creationOperation, + logger: logger, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + } catch { + throw error.toARTErrorInfo() + } } - internal func createCounter() async throws(ARTErrorInfo) -> any LiveCounter { - notYetImplemented() + internal func createCounter(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveCounter { + // RTO12f2a + try await createCounter(count: 0, coreSDK: coreSDK) } internal func batch(callback _: sending BatchCallback) async throws { diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift index 0d6cb79b..657a37f3 100644 --- a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift +++ b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift @@ -6,6 +6,60 @@ internal enum InternalLiveMapValue: Sendable, Equatable { case liveMap(InternalDefaultLiveMap) case liveCounter(InternalDefaultLiveCounter) + // MARK: - Creating from a public LiveMapValue + + /// Converts a public ``LiveMapValue`` into an ``InternalLiveMapValue``. + /// + /// Needed in order to access the internals of user-provided LiveObject-valued LiveMap entries to extract their object ID. + internal init(liveMapValue: LiveMapValue) { + switch liveMapValue { + case let .primitive(primitiveValue): + self = .primitive(primitiveValue) + case let .liveMap(publicLiveMap): + guard let publicDefaultLiveMap = publicLiveMap as? PublicDefaultLiveMap else { + // TODO: Try and remove this runtime check and know this type statically, see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/37 + preconditionFailure("Expected PublicDefaultLiveMap, got \(publicLiveMap)") + } + self = .liveMap(publicDefaultLiveMap.proxied) + case let .liveCounter(publicLiveCounter): + guard let publicDefaultLiveCounter = publicLiveCounter as? PublicDefaultLiveCounter else { + // TODO: Try and remove this runtime check and know this type statically, see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/37 + preconditionFailure("Expected PublicDefaultLiveCounter, got \(publicLiveCounter)") + } + self = .liveCounter(publicDefaultLiveCounter.proxied) + } + } + + // MARK: - Representation in the Realtime protocol + + /// Converts an `InternalLiveMapValue` to the value that should be used when creating or updating a map entry in the Realtime protocol, per the rules of RTO11f4 and RTLM20e4. + internal var toObjectData: ObjectData { + // RTO11f4c1: Create an ObjectsMapEntry for the current value + switch self { + case let .primitive(primitiveValue): + switch primitiveValue { + case let .bool(value): + .init(boolean: value) + case let .data(value): + .init(bytes: value) + case let .number(value): + .init(number: NSNumber(value: value)) + case let .string(value): + .init(string: value) + case let .jsonArray(value): + .init(json: .array(value)) + case let .jsonObject(value): + .init(json: .object(value)) + } + case let .liveMap(liveMap): + // RTO11f4c1a: If the value is of type LiveMap, set ObjectsMapEntry.data.objectId to the objectId of that object + .init(objectId: liveMap.objectID) + case let .liveCounter(liveCounter): + // RTO11f4c1a: If the value is of type LiveCounter, set ObjectsMapEntry.data.objectId to the objectId of that object + .init(objectId: liveCounter.objectID) + } + } + // MARK: - Convenience getters for associated values /// If this `InternalLiveMapValue` has case `primitive`, this returns the associated value. Else, it returns `nil`. diff --git a/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift b/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift index da969c3b..40478ee3 100644 --- a/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift +++ b/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift @@ -1,7 +1,7 @@ import Foundation /// The entries stored in a `LiveMap`'s data. Same as an `ObjectsMapEntry` but with an additional `tombstonedAt` property, per RTLM3a. -internal struct InternalObjectsMapEntry { +internal struct InternalObjectsMapEntry: Equatable { internal var tombstonedAt: Date? // RTLM3a internal var tombstone: Bool { // TODO: Confirm that we don't need to store this (https://github.com/ably/specification/pull/350/files#r2213895661) diff --git a/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift b/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift new file mode 100644 index 00000000..b82f7d84 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift @@ -0,0 +1,223 @@ +internal import AblyPlugin +import CryptoKit +import Foundation + +/// Helpers for creating a new LiveObject. +/// +/// These generate an object ID and the `ObjectMessage` needed to create the LiveObject. +internal enum ObjectCreationHelpers { + /// The metadata that `createCounter` needs in order to request that Realtime create a LiveCounter and to populate the local objects pool. + internal struct CounterCreationOperation { + /// The generated object ID. Needed for populating the local objects pool. + /// + /// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``. + internal var objectID: String + + /// The operation that should be merged into any created LiveCounter. + /// + /// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``. + internal var operation: ObjectOperation + + /// The ObjectMessage that must be sent in order for Realtime to create the object. + internal var objectMessage: OutboundObjectMessage + } + + /// The metadata that `createMap` needs in order to request that Realtime create a LiveMap and to populate the local objects pool. + internal struct MapCreationOperation { + /// The generated object ID. Needed for populating the local objects pool. + /// + /// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``. + internal var objectID: String + + /// The operation that should be merged into any created LiveMap. + /// + /// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``. + internal var operation: ObjectOperation + + /// The ObjectMessage that must be sent in order for Realtime to create the object. + internal var objectMessage: OutboundObjectMessage + + /// The semantics that should be used for the created LiveMap. + /// + /// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``. + internal var semantics: ObjectsMapSemantics + } + + /// Creates a `COUNTER_CREATE` `ObjectMessage` for the `RealtimeObjects.createCounter` method per RTO12f. + /// + /// - Parameters: + /// - count: The initial count for the new LiveCounter object + /// - timestamp: The timestamp to use for the generated object ID. + internal static func creationOperationForLiveCounter( + count: Double, + timestamp: Date, + ) -> CounterCreationOperation { + // RTO12f2: Create initial value for the new LiveCounter + let initialValue = PartialObjectOperation( + counter: WireObjectsCounter(count: NSNumber(value: count)), + ) + + // RTO12f3: Create an initial value JSON string as described in RTO13 + let initialValueJSONString = createInitialValueJSONString(from: initialValue) + + // RTO12f4: Create a unique nonce as a random string + let nonce = generateNonce() + + // RTO12f5: Get the current server time (using the provided timestamp) + let serverTime = timestamp + + // RTO12f6: Create an objectId for the new LiveCounter object as described in RTO14 + let objectId = createObjectID( + type: "counter", + initialValue: initialValueJSONString, + nonce: nonce, + timestamp: serverTime, + ) + + // RTO12f7-12: Set ObjectMessage.operation fields + let operation = ObjectOperation( + action: .known(.counterCreate), + objectId: objectId, + counter: WireObjectsCounter(count: NSNumber(value: count)), + nonce: nonce, + initialValue: initialValueJSONString, + ) + + // Create the OutboundObjectMessage + let objectMessage = OutboundObjectMessage( + operation: operation, + ) + + return CounterCreationOperation( + objectID: objectId, + operation: operation, + objectMessage: objectMessage, + ) + } + + /// Creates a `MAP_CREATE` `ObjectMessage` for the `RealtimeObjects.createMap` method per RTO11f. + /// + /// - Parameters: + /// - entries: The initial entries for the new LiveMap object + /// - timestamp: The timestamp to use for the generated object ID. + internal static func creationOperationForLiveMap( + entries: [String: InternalLiveMapValue], + timestamp: Date, + ) -> MapCreationOperation { + // RTO11f4: Create initial value for the new LiveMap + let mapEntries = entries.mapValues { liveMapValue -> ObjectsMapEntry in + ObjectsMapEntry(data: liveMapValue.toObjectData) + } + + let initialValue = PartialObjectOperation( + map: ObjectsMap( + semantics: .known(.lww), + entries: mapEntries, + ), + ) + + // RTO11f5: Create an initial value JSON string as described in RTO13 + let initialValueJSONString = createInitialValueJSONString(from: initialValue) + + // RTO11f6: Create a unique nonce as a random string + let nonce = generateNonce() + + // RTO11f7: Get the current server time (using the provided timestamp) + let serverTime = timestamp + + // RTO11f8: Create an objectId for the new LiveMap object as described in RTO14 + let objectId = createObjectID( + type: "map", + initialValue: initialValueJSONString, + nonce: nonce, + timestamp: serverTime, + ) + + // RTO11f9-13: Set ObjectMessage.operation fields + let semantics = ObjectsMapSemantics.lww + let operation = ObjectOperation( + action: .known(.mapCreate), + objectId: objectId, + map: ObjectsMap( + semantics: .known(semantics), + entries: mapEntries, + ), + nonce: nonce, + initialValue: initialValueJSONString, + ) + + // Create the OutboundObjectMessage + let objectMessage = OutboundObjectMessage( + operation: operation, + ) + + return MapCreationOperation( + objectID: objectId, + operation: operation, + objectMessage: objectMessage, + semantics: semantics, + ) + } + + // MARK: - Private Helper Methods + + /// Creates an initial value JSON string from a PartialObjectOperation, per RTO13. + private static func createInitialValueJSONString(from initialValue: PartialObjectOperation) -> String { + // RTO13b: Encode the initial value using OM4 encoding + let partialWireObjectOperation = initialValue.toWire(format: .json) + let jsonObject = partialWireObjectOperation.toWireObject.mapValues { wireValue in + do { + return try wireValue.toJSONValue + } catch { + // By using `format: .json` we've requested a type that should be JSON-encodable, so if it isn't then it's a programmer error. (We can't reason about it statically though because of our choice to use a general-purpose WireValue type; maybe could improve upon this in the future.) + preconditionFailure("Failed to convert WireValue \(wireValue) to JSONValue when encoding initialValue") + } + } + + // RTO13c + return JSONObjectOrArray.object(jsonObject).toJSONString + } + + /// Creates an Object ID for a new LiveObject instance, per RTO14. + internal static func testsOnly_createObjectID( + type: String, + initialValue: String, + nonce: String, + timestamp: Date, + ) -> String { + createObjectID( + type: type, + initialValue: initialValue, + nonce: nonce, + timestamp: timestamp, + ) + } + + /// Creates an Object ID for a new LiveObject instance, per RTO14. + private static func createObjectID( + type: String, + initialValue: String, + nonce: String, + timestamp: Date, + ) -> String { + // RTO14b1: Generate a SHA-256 digest + let hash = SHA256.hash(data: Data("\(initialValue):\(nonce)".utf8)) + + // RTO14b2: Base64URL-encode the generated digest + let base64URLHash = Data(hash).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + // RTO14c: Return an Object ID in the format [type]:[hash]@[timestamp] + let timestampMillis = Int(timestamp.timeIntervalSince1970 * 1000) + return "\(type):\(base64URLHash)@\(timestampMillis)" + } + + /// Generates a unique nonce as a random string, per RTO11f6 and RTO12f4. + private static func generateNonce() -> String { + // TODO: confirm if there's any specific rules here: https://github.com/ably/specification/pull/353/files#r2228252389 + let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + return String((0 ..< 16).map { _ in letters.randomElement()! }) + } +} diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 933198a5..8d2d7e4f 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -306,6 +306,95 @@ internal struct ObjectsPool { logger.log("applySyncObjectsPool completed. Pool now contains \(entries.count) objects", level: .debug) } + /// Gets or creates a counter object in the pool, implementing the "find or create zero-value" behavior of RTO12h1. + /// + /// - Parameters: + /// - creationOperation: The CounterCreationOperation containing the object ID and operation to merge + /// - logger: The logger to use for any created LiveObject + /// - userCallbackQueue: The callback queue to use for any created LiveObject + /// - clock: The clock to use for any created LiveObject + /// - Returns: The existing or newly created counter object + internal mutating func getOrCreateCounter( + creationOperation: ObjectCreationHelpers.CounterCreationOperation, + logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + ) -> InternalDefaultLiveCounter { + // RTO12h2: If an object with the ObjectMessage.operation.objectId exists in the internal ObjectsPool, return it + if let existingEntry = entries[creationOperation.objectID] { + switch existingEntry { + case let .counter(counter): + return counter + case .map: + // TODO: Add the ability to statically reason about the type of pool entries in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/36 + preconditionFailure("Expected counter object with ID \(creationOperation.objectID) but found map object") + } + } + + // RTO12h3: Otherwise, if the object does not exist in the internal ObjectsPool: + // RTO12h3a: Create a zero-value LiveCounter, set its objectId to ObjectMessage.operation.objectId, and merge the initial value + let counter = InternalDefaultLiveCounter.createZeroValued( + objectID: creationOperation.objectID, + logger: logger, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + + // Merge the initial value from the creation operation + _ = counter.mergeInitialValue(from: creationOperation.operation) + + // RTO12h3b: Add the created LiveCounter instance to the internal ObjectsPool + entries[creationOperation.objectID] = .counter(counter) + + // RTO12h3c: Return the created LiveCounter instance + return counter + } + + /// Gets or creates a map object in the pool, implementing the "find or create zero-value" behavior of RTO11h1. + /// + /// - Parameters: + /// - creationOperation: The MapCreationOperation containing the object ID and operation to merge + /// - logger: The logger to use for any created LiveObject + /// - userCallbackQueue: The callback queue to use for any created LiveObject + /// - clock: The clock to use for any created LiveObject + /// - Returns: The existing or newly created map object + internal mutating func getOrCreateMap( + creationOperation: ObjectCreationHelpers.MapCreationOperation, + logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + ) -> InternalDefaultLiveMap { + // RTO11h2: If an object with the ObjectMessage.operation.objectId exists in the internal ObjectsPool, return it + if let existingEntry = entries[creationOperation.objectID] { + switch existingEntry { + case let .map(map): + return map + case .counter: + // TODO: Add the ability to statically reason about the type of pool entries in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/36 + preconditionFailure("Expected map object with ID \(creationOperation.objectID) but found counter object") + } + } + + // RTO11h3: Otherwise, if the object does not exist in the internal ObjectsPool: + // RTO11h3a: Create a zero-value LiveMap, set its objectId to ObjectMessage.operation.objectId, set its semantics to ObjectMessage.operation.map.semantics, and merge the initial value + let map = InternalDefaultLiveMap.createZeroValued( + objectID: creationOperation.objectID, + semantics: .known(creationOperation.semantics), + logger: logger, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + + // Merge the initial value from the creation operation + _ = map.mergeInitialValue(from: creationOperation.operation, objectsPool: &self) + + // RTO11h3b: Add the created LiveMap instance to the internal ObjectsPool + entries[creationOperation.objectID] = .map(map) + + // RTO11h3c: Return the created LiveMap instance + return map + } + /// Removes all entries except the root, and clears the root's data. This is to be used when an `ATTACHED` ProtocolMessage indicates that the only object in a channel is an empty root map, per RTO4b. internal mutating func reset() { let root = root diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index a9adea8e..23719a19 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -18,7 +18,7 @@ internal struct InboundObjectMessage { } /// An `ObjectMessage` to be sent in the `state` property of an `OBJECT` `ProtocolMessage`. -internal struct OutboundObjectMessage { +internal struct OutboundObjectMessage: Equatable { internal var id: String? // OM2a internal var clientId: String? // OM2b internal var connectionId: String? @@ -31,11 +31,10 @@ internal struct OutboundObjectMessage { internal var serialTimestamp: Date? // OM2j } -/// A partial version of `ObjectOperation` that excludes the `objectId` property. Used for encoding initial values where the `objectId` is not yet known. +/// A partial version of `ObjectOperation` that excludes the `action` and `objectId` property. Used for encoding initial values which don't include the `action` and where the `objectId` is not yet known. /// /// `ObjectOperation` delegates its encoding and decoding to `PartialObjectOperation`. internal struct PartialObjectOperation { - internal var action: WireEnum // OOP3a internal var mapOp: ObjectsMapOp? // OOP3c internal var counterOp: WireObjectsCounterOp? // OOP3d internal var map: ObjectsMap? // OOP3e @@ -44,7 +43,7 @@ internal struct PartialObjectOperation { internal var initialValue: String? // OOP3h } -internal struct ObjectOperation { +internal struct ObjectOperation: Equatable { internal var action: WireEnum // OOP3a internal var objectId: String // OOP3b internal var mapOp: ObjectsMapOp? // OOP3c @@ -55,7 +54,7 @@ internal struct ObjectOperation { internal var initialValue: String? // OOP3h } -internal struct ObjectData { +internal struct ObjectData: Equatable { internal var objectId: String? // OD2a internal var boolean: Bool? // OD2c internal var bytes: Data? // OD2d @@ -64,24 +63,24 @@ internal struct ObjectData { internal var json: JSONObjectOrArray? // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) } -internal struct ObjectsMapOp { +internal struct ObjectsMapOp: Equatable { internal var key: String // OMO2a internal var data: ObjectData? // OMO2b } -internal struct ObjectsMapEntry { +internal struct ObjectsMapEntry: Equatable { internal var tombstone: Bool? // OME2a internal var timeserial: String? // OME2b internal var data: ObjectData // OME2c internal var serialTimestamp: Date? // OME2d } -internal struct ObjectsMap { +internal struct ObjectsMap: Equatable { internal var semantics: WireEnum // OMP3a internal var entries: [String: ObjectsMapEntry]? // OMP3b } -internal struct ObjectState { +internal struct ObjectState: Equatable { internal var objectId: String // OST2a internal var siteTimeserials: [String: String] // OST2b internal var tombstone: Bool // OST2c @@ -148,13 +147,13 @@ internal extension ObjectOperation { wireObjectOperation: WireObjectOperation, format: AblyPlugin.EncodingFormat ) throws(InternalError) { - // Decode the objectId first since it's not part of PartialObjectOperation + // Decode the action and objectId first they're not part of PartialObjectOperation + action = wireObjectOperation.action objectId = wireObjectOperation.objectId // Delegate to PartialObjectOperation for decoding let partialOperation = try PartialObjectOperation( partialWireObjectOperation: PartialWireObjectOperation( - action: wireObjectOperation.action, mapOp: wireObjectOperation.mapOp, counterOp: wireObjectOperation.counterOp, map: wireObjectOperation.map, @@ -166,7 +165,6 @@ internal extension ObjectOperation { ) // Copy the decoded values - action = partialOperation.action mapOp = partialOperation.mapOp counterOp = partialOperation.counterOp map = partialOperation.map @@ -181,7 +179,6 @@ internal extension ObjectOperation { /// - format: The format to use when applying the encoding rules of OD4. func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectOperation { let partialWireOperation = PartialObjectOperation( - action: action, mapOp: mapOp, counterOp: counterOp, map: map, @@ -190,9 +187,9 @@ internal extension ObjectOperation { initialValue: initialValue, ).toWire(format: format) - // Create WireObjectOperation from PartialWireObjectOperation and add objectId + // Create WireObjectOperation from PartialWireObjectOperation and add action and objectId return WireObjectOperation( - action: partialWireOperation.action, + action: action, objectId: objectId, mapOp: partialWireOperation.mapOp, counterOp: partialWireOperation.counterOp, @@ -214,7 +211,6 @@ internal extension PartialObjectOperation { partialWireObjectOperation: PartialWireObjectOperation, format: AblyPlugin.EncodingFormat ) throws(InternalError) { - action = partialWireObjectOperation.action mapOp = try partialWireObjectOperation.mapOp.map { wireObjectsMapOp throws(InternalError) in try .init(wireObjectsMapOp: wireObjectsMapOp, format: format) } @@ -236,7 +232,6 @@ internal extension PartialObjectOperation { /// - format: The format to use when applying the encoding rules of OD4. func toWire(format: AblyPlugin.EncodingFormat) -> PartialWireObjectOperation { .init( - action: action, mapOp: mapOp?.toWire(format: format), counterOp: counterOp, map: map?.toWire(format: format), diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index b0d7514c..d7316fc5 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -157,11 +157,10 @@ internal enum ObjectsMapSemantics: Int { case lww = 0 } -/// A partial version of `WireObjectOperation` that excludes the `objectId` property. Used for encoding initial values where the `objectId` is not yet known. +/// A partial version of `WireObjectOperation` that excludes the `action` and `objectId` property. Used for encoding initial values which don't include the `action` and where the `objectId` is not yet known. /// /// `WireObjectOperation` delegates its encoding and decoding to `PartialWireObjectOperation`. internal struct PartialWireObjectOperation { - internal var action: WireEnum // OOP3a internal var mapOp: WireObjectsMapOp? // OOP3c internal var counterOp: WireObjectsCounterOp? // OOP3d internal var map: WireObjectsMap? // OOP3e @@ -172,7 +171,6 @@ internal struct PartialWireObjectOperation { extension PartialWireObjectOperation: WireObjectCodable { internal enum WireKey: String { - case action case mapOp case counterOp case map @@ -182,7 +180,6 @@ extension PartialWireObjectOperation: WireObjectCodable { } internal init(wireObject: [String: WireValue]) throws(InternalError) { - action = try wireObject.wireEnumValueForKey(WireKey.action.rawValue) mapOp = try wireObject.optionalDecodableValueForKey(WireKey.mapOp.rawValue) counterOp = try wireObject.optionalDecodableValueForKey(WireKey.counterOp.rawValue) map = try wireObject.optionalDecodableValueForKey(WireKey.map.rawValue) @@ -195,9 +192,7 @@ extension PartialWireObjectOperation: WireObjectCodable { } internal var toWireObject: [String: WireValue] { - var result: [String: WireValue] = [ - WireKey.action.rawValue: .number(action.rawValue as NSNumber), - ] + var result: [String: WireValue] = [:] if let mapOp { result[WireKey.mapOp.rawValue] = .object(mapOp.toWireObject) @@ -235,18 +230,19 @@ internal struct WireObjectOperation { extension WireObjectOperation: WireObjectCodable { internal enum WireKey: String { + case action case objectId } internal init(wireObject: [String: WireValue]) throws(InternalError) { - // Decode the objectId first since it's not part of PartialWireObjectOperation + // Decode the action and objectId first since they're not part of PartialWireObjectOperation + action = try wireObject.wireEnumValueForKey(WireKey.action.rawValue) objectId = try wireObject.stringValueForKey(WireKey.objectId.rawValue) // Delegate to PartialWireObjectOperation for decoding let partialOperation = try PartialWireObjectOperation(wireObject: wireObject) // Copy the decoded values - action = partialOperation.action mapOp = partialOperation.mapOp counterOp = partialOperation.counterOp map = partialOperation.map @@ -257,7 +253,6 @@ extension WireObjectOperation: WireObjectCodable { internal var toWireObject: [String: WireValue] { var result = PartialWireObjectOperation( - action: action, mapOp: mapOp, counterOp: counterOp, map: map, @@ -267,6 +262,7 @@ extension WireObjectOperation: WireObjectCodable { ).toWireObject // Add the objectId field + result[WireKey.action.rawValue] = .number(action.rawValue as NSNumber) result[WireKey.objectId.rawValue] = .string(objectId) return result @@ -356,7 +352,7 @@ extension WireObjectsMapOp: WireObjectCodable { } } -internal struct WireObjectsCounterOp { +internal struct WireObjectsCounterOp: Equatable { internal var amount: NSNumber // OCO2a } @@ -410,7 +406,7 @@ extension WireObjectsMap: WireObjectCodable { } } -internal struct WireObjectsCounter { +internal struct WireObjectsCounter: Equatable { internal var count: NSNumber? // OCN2a } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift index 19c2bfc4..146d05e6 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -27,11 +27,11 @@ internal final class PublicDefaultLiveCounter: LiveCounter { } internal func increment(amount: Double) async throws(ARTErrorInfo) { - try await proxied.increment(amount: amount) + try await proxied.increment(amount: amount, coreSDK: coreSDK) } internal func decrement(amount: Double) async throws(ARTErrorInfo) { - try await proxied.decrement(amount: amount) + try await proxied.decrement(amount: amount, coreSDK: coreSDK) } internal func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> any SubscribeResponse { diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index 3569a4cc..52e207d2 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -76,11 +76,13 @@ internal final class PublicDefaultLiveMap: LiveMap { } internal func set(key: String, value: LiveMapValue) async throws(ARTErrorInfo) { - try await proxied.set(key: key, value: value) + let internalValue = InternalLiveMapValue(liveMapValue: value) + + try await proxied.set(key: key, value: internalValue, coreSDK: coreSDK) } internal func remove(key: String) async throws(ARTErrorInfo) { - try await proxied.remove(key: key) + try await proxied.remove(key: key, coreSDK: coreSDK) } internal func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> any SubscribeResponse { diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index 94be8e40..34b5abf7 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -36,19 +36,54 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { } internal func createMap(entries: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap { - try await proxied.createMap(entries: entries) + let internalEntries: [String: InternalLiveMapValue] = entries.mapValues { .init(liveMapValue: $0) } + let internalMap = try await proxied.createMap(entries: internalEntries, coreSDK: coreSDK) + + return PublicObjectsStore.shared.getOrCreateMap( + proxying: internalMap, + creationArgs: .init( + coreSDK: coreSDK, + delegate: proxied, + logger: logger, + ), + ) } internal func createMap() async throws(ARTErrorInfo) -> any LiveMap { - try await proxied.createMap() + let internalMap = try await proxied.createMap(coreSDK: coreSDK) + + return PublicObjectsStore.shared.getOrCreateMap( + proxying: internalMap, + creationArgs: .init( + coreSDK: coreSDK, + delegate: proxied, + logger: logger, + ), + ) } internal func createCounter(count: Double) async throws(ARTErrorInfo) -> any LiveCounter { - try await proxied.createCounter(count: count) + let internalCounter = try await proxied.createCounter(count: count, coreSDK: coreSDK) + + return PublicObjectsStore.shared.getOrCreateCounter( + proxying: internalCounter, + creationArgs: .init( + coreSDK: coreSDK, + logger: logger, + ), + ) } internal func createCounter() async throws(ARTErrorInfo) -> any LiveCounter { - try await proxied.createCounter() + let internalCounter = try await proxied.createCounter(coreSDK: coreSDK) + + return PublicObjectsStore.shared.getOrCreateCounter( + proxying: internalCounter, + creationArgs: .init( + coreSDK: coreSDK, + logger: logger, + ), + ) } internal func batch(callback: sending BatchCallback) async throws { diff --git a/Sources/AblyLiveObjects/Utility/Errors.swift b/Sources/AblyLiveObjects/Utility/Errors.swift index e44d47f9..941d3e8b 100644 --- a/Sources/AblyLiveObjects/Utility/Errors.swift +++ b/Sources/AblyLiveObjects/Utility/Errors.swift @@ -6,19 +6,26 @@ import Ably internal enum LiveObjectsError { // operationDescription should be a description of a method like "LiveCounter.value"; it will be interpolated into an error message case objectsOperationFailedInvalidChannelState(operationDescription: String, channelState: ARTRealtimeChannelState) + case counterInitialValueInvalid(value: Double) + case counterIncrementAmountInvalid(amount: Double) /// The ``ARTErrorInfo/code`` that should be returned for this error. internal var code: ARTErrorCode { switch self { case .objectsOperationFailedInvalidChannelState: .channelOperationFailedInvalidState + case .counterInitialValueInvalid, .counterIncrementAmountInvalid: + // RTO12f1, RTLC12e1 + .invalidParameterValue } } /// The ``ARTErrorInfo/statusCode`` that should be returned for this error. internal var statusCode: Int { switch self { - case .objectsOperationFailedInvalidChannelState: + case .objectsOperationFailedInvalidChannelState, + .counterInitialValueInvalid, + .counterIncrementAmountInvalid: 400 } } @@ -28,6 +35,10 @@ internal enum LiveObjectsError { switch self { case let .objectsOperationFailedInvalidChannelState(operationDescription: operationDescription, channelState: channelState): "\(operationDescription) operation failed (invalid channel state: \(channelState))" + case let .counterInitialValueInvalid(value: value): + "Invalid counter initial value (must be a finite number): \(value)" + case let .counterIncrementAmountInvalid(amount: amount): + "Invalid counter increment amount (must be a finite number): \(amount)" } } diff --git a/Sources/AblyLiveObjects/Utility/JSONValue.swift b/Sources/AblyLiveObjects/Utility/JSONValue.swift index 3030dcaa..48c33f3f 100644 --- a/Sources/AblyLiveObjects/Utility/JSONValue.swift +++ b/Sources/AblyLiveObjects/Utility/JSONValue.swift @@ -177,6 +177,24 @@ internal enum JSONObjectOrArray: Equatable { throw ConversionError.incompatibleJSONValue(jsonValue).toInternalError() } } + + // MARK: - Convenience getters for associated values + + /// If this `JSONObjectOrArray` has case `object`, this returns the associated value. Else, it returns `nil`. + internal var objectValue: [String: JSONValue]? { + if case let .object(value) = self { + return value + } + return nil + } + + /// If this `JSONObjectOrArray` has case `array`, this returns the associated value. Else, it returns `nil`. + internal var arrayValue: [JSONValue]? { + if case let .array(value) = self { + return value + } + return nil + } } extension JSONObjectOrArray: ExpressibleByDictionaryLiteral { diff --git a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift index 1a1445d8..c9cb540b 100644 --- a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -122,4 +122,67 @@ struct AblyLiveObjectsTests { #expect(invalidObjectThrownError.code == 92000) #expect(invalidObjectThrownError.message == "invalid object message: object operation required") } + + /// A basic test of the public API of the LiveObjects plugin. + @Test(arguments: [true, false]) + func smokeTest(useBinaryProtocol: Bool) async throws { + let client = try await ClientHelper.realtimeWithObjects(options: .init(useBinaryProtocol: useBinaryProtocol)) + let channel = client.channels.get(UUID().uuidString, options: ClientHelper.channelOptionsWithObjects()) + try await channel.attachAsync() + + let root = try await channel.objects.getRoot() + let rootSubscription = try root.updates() + + // Create a counter + let counter = try await channel.objects.createCounter(count: 52) + let counterSubscription = try counter.updates() + + // Create a map and check its initial entries + let map = try await channel.objects.createMap(entries: [ + "boolKey": .primitive(.bool(true)), + "numberKey": .primitive(.number(10)), + ]) + #expect( + try Dictionary(uniqueKeysWithValues: map.entries) == [ + "boolKey": .primitive(.bool(true)), + "numberKey": .primitive(.number(10)), + ], + ) + let mapSubscription = try map.updates() + + // Perform a `set` on the root and check it comes through on subscription + try await root.set(key: "mapKey", value: .liveMap(map)) + let rootUpdate = try #require(await rootSubscription.first { _ in true }) + #expect(rootUpdate.update == ["mapKey": .updated]) + #expect(try Dictionary(uniqueKeysWithValues: root.entries) == ["mapKey": .liveMap(map)]) + + // Perform a `set` on the map and check it comes through on subscription and that the map is updated + try await map.set(key: "counterKey", value: .liveCounter(counter)) + let mapUpdate = try #require(await mapSubscription.first { _ in true }) + #expect(mapUpdate.update == ["counterKey": .updated]) + #expect( + try Dictionary(uniqueKeysWithValues: map.entries) == [ + "boolKey": .primitive(.bool(true)), + "numberKey": .primitive(.number(10)), + "counterKey": .liveCounter(counter), + ], + ) + + // Perform an `increment` on the counter and check it comes through on subscription and that the counter is updated + try await counter.increment(amount: 30) + let counterUpdate = try #require(await counterSubscription.first { _ in true }) + #expect(counterUpdate.amount == 30) + #expect(try counter.value == 82) + + // Perform a `remove` on the map and check it comes through on subscription and that the map is updated + try await map.remove(key: "boolKey") + let mapRemoveUpdate = try #require(await mapSubscription.first { _ in true }) + #expect(mapRemoveUpdate.update == ["boolKey": .removed]) + #expect( + try Dictionary(uniqueKeysWithValues: map.entries) == [ + "numberKey": .primitive(.number(10)), + "counterKey": .liveCounter(counter), + ], + ) + } } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index 6ce42481..039ec517 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -422,4 +422,120 @@ struct InternalDefaultLiveCounterTests { #expect(subscriberInvocations.isEmpty) } } + + /// Tests for the `increment` method, covering RTLC12 specification points + struct IncrementTests { + // @spec RTLC12c + @Test(arguments: [.detached, .failed, .suspended] as [ARTRealtimeChannelState]) + func throwsErrorForInvalidChannelState(channelState: ARTRealtimeChannelState) async throws { + let logger = TestLogger() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: channelState) + + await #expect { + try await counter.increment(amount: 10, coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + + // @spec RTLC12e1 - The only part that is relevant in Swift's type system is the finiteness check + @Test(arguments: [ + Double.nan, + Double.infinity, + -Double.infinity, + ] as [Double]) + func throwsErrorForInvalidAmount(amount: Double) async throws { + let logger = TestLogger() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + await #expect { + try await counter.increment(amount: amount, coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 40003 && errorInfo.statusCode == 400 + } + } + + // @spec RTLC12e2 + // @spec RTLC12e3 + // @spec RTLC12e4 + // @spec RTLC12f + func publishesCorrectObjectMessage() async throws { + let logger = TestLogger() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + try await counter.increment(amount: 10.5, coreSDK: coreSDK) + + let expectedMessage = OutboundObjectMessage( + operation: ObjectOperation( + // RTLC12e2 + action: .known(.counterInc), + // RTLC12e3 + objectId: "counter:test@123", + // RTLC12e4 + counterOp: WireObjectsCounterOp(amount: NSNumber(value: 10.5)), + ), + ) + // RTLC12f + #expect(publishedMessages.count == 1) + #expect(publishedMessages[0] == expectedMessage) + } + + @Test + func throwsErrorWhenPublishFails() async throws { + let logger = TestLogger() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + coreSDK.setPublishHandler { _ throws(InternalError) in + throw InternalError.other(.generic(NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"]))) + } + + await #expect { + try await counter.increment(amount: 10, coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + return errorInfo.message.contains("Publish failed") + } + } + } + + /// Tests for the `decrement` method, covering RTLC13 specification points + struct DecrementTests { + // @spec RTLC13b + @Test + func isOppositeOfIncrement() async throws { + // This is just a smoke test; we assume that this just calls `increment`, which is tested elsewhere. + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:test@123", logger: TestLogger(), userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + try await counter.decrement(amount: 10.5, coreSDK: coreSDK) + + // RTLC12f + #expect(publishedMessages.count == 1) + #expect(publishedMessages[0].operation?.counterOp?.amount == -10.5 /* i.e. assert the amount gets negated */ ) + } + } } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index d68e7ccd..98982166 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -1235,4 +1235,181 @@ struct InternalDefaultLiveMapTests { #expect(subscriberInvocations.isEmpty) } } + + /// Tests for the `set` method, covering RTLM20 specification points + struct SetTests { + // @spec RTLM20c + @Test(arguments: [.detached, .failed, .suspended] as [ARTRealtimeChannelState]) + func throwsErrorForInvalidChannelState(channelState: ARTRealtimeChannelState) async throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: channelState) + + await #expect { + try await map.set(key: "test", value: .primitive(.string("value")), coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + + // @spec RTLM20e + // @specUntested RTLM20e1 - Not needed with Swift's type system + // @spec RTLM20e2 + // @spec RTLM20e3 + // @spec RTLM20e4 + // @spec RTLM20e5a + // @spec RTLM20e5b + // @spec RTLM20e5c + // @spec RTLM20e5d + // @spec RTLM20e5e + // @spec RTLM20e5f + // @spec RTLM20f + @Test(arguments: [ + // RTLM20e5a + (value: .liveMap(.createZeroValued(objectID: "map:test@123", logger: TestLogger(), userCallbackQueue: .main, clock: MockSimpleClock())), expectedData: .init(objectId: "map:test@123")), + (value: .liveCounter(.createZeroValued(objectID: "map:test@123", logger: TestLogger(), userCallbackQueue: .main, clock: MockSimpleClock())), expectedData: .init(objectId: "map:test@123")), + // RTLM20e5b + (value: .primitive(.jsonArray(["test"])), expectedData: .init(json: .array(["test"]))), + (value: .primitive(.jsonObject(["foo": "bar"])), expectedData: .init(json: .object(["foo": "bar"]))), + // RTLM20e5c + (value: .primitive(.string("test")), expectedData: .init(string: "test")), + // RTLM20e5d + (value: .primitive(.number(42.5)), expectedData: .init(number: NSNumber(value: 42.5))), + // RTLM20e5e + (value: .primitive(.bool(true)), expectedData: .init(boolean: true)), + // RTLM20e5f + (value: .primitive(.data(Data([0x01, 0x02]))), expectedData: .init(bytes: Data([0x01, 0x02]))), + ] as [(value: InternalLiveMapValue, expectedData: ObjectData)]) + func publishesCorrectObjectMessageForDifferentValueTypes(value: InternalLiveMapValue, expectedData: ObjectData) async throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + var publishedMessage: OutboundObjectMessage? + coreSDK.setPublishHandler { messages in + publishedMessage = messages.first + } + + try await map.set(key: "testKey", value: value, coreSDK: coreSDK) + + let expectedMessage = OutboundObjectMessage( + operation: ObjectOperation( + // RTLM20e2 + action: .known(.mapSet), + // RTLM20e3 + objectId: "map:test@123", + mapOp: ObjectsMapOp( + // RTLM20e4 + key: "testKey", + // RTLM20e5 + data: expectedData, + ), + ), + ) + // RTLM20f + let message = try #require(publishedMessage) + #expect(message == expectedMessage) + } + + @Test + func throwsErrorWhenPublishFails() async throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + coreSDK.setPublishHandler { _ throws(InternalError) in + throw NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"]).toInternalError() + } + + await #expect { + try await map.set(key: "testKey", value: .primitive(.string("testValue")), coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + return errorInfo.message.contains("Publish failed") + } + } + } + + /// Tests for the `remove` method, covering RTLM21 specification points + struct RemoveTests { + // @spec RTLM21c + @Test(arguments: [.detached, .failed, .suspended] as [ARTRealtimeChannelState]) + func throwsErrorForInvalidChannelState(channelState: ARTRealtimeChannelState) async throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: channelState) + + await #expect { + try await map.remove(key: "test", coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + + // @specUntested RTLM21e + // @specUntested RTLM21e1 - Not needed with Swift's type system + // @spec RTLM21e2 + // @spec RTLM21e3 + // @spec RTLM21e4 + // @spec RTLM21f + func publishesCorrectObjectMessage() async throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + try await map.remove(key: "testKey", coreSDK: coreSDK) + + let expectedMessage = OutboundObjectMessage( + operation: ObjectOperation( + // RTLM21e2 + action: .known(.mapRemove), + // RTLM21e3 + objectId: "map:test@123", + mapOp: ObjectsMapOp( + // RTLM21e4 + key: "testKey", + data: nil, + ), + ), + ) + // RTLM21f + #expect(publishedMessages.count == 1) + #expect(publishedMessages[0] == expectedMessage) + } + + @Test + func throwsErrorWhenPublishFails() async throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + coreSDK.setPublishHandler { _ throws(InternalError) in + throw NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"]).toInternalError() + } + + await #expect { + try await map.remove(key: "testKey", coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + return errorInfo.message.contains("Publish failed") + } + } + } } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index 82f177cb..fee10e3c 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -8,9 +8,9 @@ struct InternalDefaultRealtimeObjectsTests { // MARK: - Test Helpers /// Creates a InternalDefaultRealtimeObjects instance for testing - static func createDefaultRealtimeObjects() -> InternalDefaultRealtimeObjects { + static func createDefaultRealtimeObjects(clock: SimpleClock = MockSimpleClock()) -> InternalDefaultRealtimeObjects { let logger = TestLogger() - return InternalDefaultRealtimeObjects(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + return InternalDefaultRealtimeObjects(logger: logger, userCallbackQueue: .main, clock: clock) } /// Tests for `InternalDefaultRealtimeObjects.handleObjectSyncProtocolMessage`, covering RTO5 specification points. @@ -1040,4 +1040,261 @@ struct InternalDefaultRealtimeObjectsTests { } } } + + /// Tests for `InternalDefaultRealtimeObjects.createMap`, covering RTO11 specification points (these are largely a smoke test, the rest being tested in ObjectCreationHelpers tests) + struct CreateMapTests { + // @spec RTO11d + @Test(arguments: [.detached, .failed, .suspended] as [ARTRealtimeChannelState]) + func throwsIfChannelIsInInvalidState(channelState: ARTRealtimeChannelState) async throws { + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: channelState) + let entries: [String: InternalLiveMapValue] = ["testKey": .primitive(.string("testValue"))] + + await #expect { + _ = try await realtimeObjects.createMap(entries: entries, coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + + // @spec RTO11g + // @spec RTO11h3a + // @spec RTO11h3b + @Test + func publishesObjectMessageAndCreatesMap() async throws { + let clock = MockSimpleClock(currentTime: .init(timeIntervalSince1970: 1_754_042_434)) + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(clock: clock) + let coreSDK = MockCoreSDK(channelState: .attached) + + // Track published messages + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + // Call createMap + let returnedMap = try await realtimeObjects.createMap( + entries: [ + "stringKey": .primitive(.string("stringValue")), + ], + coreSDK: coreSDK, + ) + + // Verify ObjectMessage was published (RTO11g) + #expect(publishedMessages.count == 1) + let publishedMessage = publishedMessages[0] + + // Sense check of ObjectMessage structure per RTO11f9-13 + #expect(publishedMessage.operation?.action == .known(.mapCreate)) + let objectID = try #require(publishedMessage.operation?.objectId) + #expect(objectID.hasPrefix("map:")) + // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/50) + #expect(objectID.contains("1754042434000")) // check contains the mock clock's timestamp in milliseconds + #expect(publishedMessage.operation?.map?.entries == [ + "stringKey": .init(data: .init(string: "stringValue")), + ]) + + // Verify initial value was merged per RTO11h3a + #expect(returnedMap.testsOnly_data == ["stringKey": .init(data: .init(string: "stringValue"))]) + + // Verify object was added to pool per RTO11h3b + #expect(realtimeObjects.testsOnly_objectsPool.entries[objectID]?.mapValue === returnedMap) + } + + // @spec RTO11f4b + @Test + func withNoEntriesArgumentCreatesEmptyMap() async throws { + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) + + // Track published messages + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + // Call createMap with no entries + let result = try await realtimeObjects.createMap(entries: [:], coreSDK: coreSDK) + + // Verify ObjectMessage was published + #expect(publishedMessages.count == 1) + let publishedMessage = publishedMessages[0] + + // Verify map operation has empty entries per RTO11f4b + let mapOperation = publishedMessage.operation?.map + #expect(mapOperation?.entries?.isEmpty == true) + + // Verify LiveMap has expected entries + #expect(result.testsOnly_data.isEmpty) + } + + // @spec RTO11h2 + @Test + func returnsExistingObjectIfAlreadyInPool() async throws { + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) + + // Track published messages and the generated objectId + var publishedMessages: [OutboundObjectMessage] = [] + var maybeGeneratedObjectID: String? + var maybeExistingObject: AnyObject? + + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + + // Extract the generated objectId from the published message + if let objectID = messages.first?.operation?.objectId { + maybeGeneratedObjectID = objectID + + // Create an object with this exact ID in the pool + // This simulates the object already existing when createMap tries to get it, before the publish operation completes (e.g. because it has been populated by receipt of an OBJECT) + maybeExistingObject = realtimeObjects.testsOnly_createZeroValueLiveObject(forObjectID: objectID)?.mapValue + } + } + + // Call createMap - the publishHandler will create the object with the generated ID + let result = try await realtimeObjects.createMap(entries: ["testKey": .primitive(.string("testValue"))], coreSDK: coreSDK) + + // Verify ObjectMessage was published + #expect(publishedMessages.count == 1) + + // Extract the variables that we populated based on the generated object ID + let generatedObjectID = try #require(maybeGeneratedObjectID) + let existingObject = try #require(maybeExistingObject) + + // Verify the returned object is the same as the existing one + #expect(result === existingObject) + + // Check that the existing object has not been replaced in the pool + #expect(realtimeObjects.testsOnly_objectsPool.entries[generatedObjectID]?.mapValue === existingObject) + } + + /// Tests for `InternalDefaultRealtimeObjects.createCounter`, covering RTO12 specification points (these are largely a smoke test, the rest being tested in ObjectCreationHelpers tests) + struct CreateCounterTests { + // @spec RTO12d + @Test(arguments: [.detached, .failed, .suspended] as [ARTRealtimeChannelState]) + func throwsIfChannelIsInInvalidState(channelState: ARTRealtimeChannelState) async throws { + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: channelState) + + await #expect { + _ = try await realtimeObjects.createCounter(count: 10.5, coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + + // @spec RTO12g + // @spec RTO12h3a + // @spec RTO12h3b + @Test + func publishesObjectMessageAndCreatesCounter() async throws { + let clock = MockSimpleClock(currentTime: .init(timeIntervalSince1970: 1_754_042_434)) + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(clock: clock) + let coreSDK = MockCoreSDK(channelState: .attached) + + // Track published messages + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + // Call createCounter + let returnedCounter = try await realtimeObjects.createCounter(count: 10.5, coreSDK: coreSDK) + + // Verify ObjectMessage was published (RTO12g) + #expect(publishedMessages.count == 1) + let publishedMessage = publishedMessages[0] + + // Sense check of ObjectMessage structure per RTO12f7-11 + #expect(publishedMessage.operation?.action == .known(.counterCreate)) + let objectID = try #require(publishedMessage.operation?.objectId) + #expect(objectID.hasPrefix("counter:")) + // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/50) + #expect(objectID.contains("1754042434000")) // check contains the mock clock's timestamp in milliseconds + #expect(publishedMessage.operation?.counter?.count == 10.5) + + // Verify initial value was merged per RTO12h3a + #expect(try returnedCounter.value(coreSDK: coreSDK) == 10.5) + + // Verify object was added to pool per RTO12h3b + #expect(realtimeObjects.testsOnly_objectsPool.entries[objectID]?.counterValue === returnedCounter) + } + + // @spec RTO12f2a + @Test + func withNoEntriesArgumentCreatesWithZeroValue() async throws { + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) + + // Track published messages + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + // Call createCounter with no count + let result = try await realtimeObjects.createCounter(coreSDK: coreSDK) + + // Verify ObjectMessage was published + #expect(publishedMessages.count == 1) + let publishedMessage = publishedMessages[0] + + // Verify counter operation has zero count per RTO12f2a + let counterOperation = publishedMessage.operation?.counter + // swiftlint:disable:next empty_count + #expect(counterOperation?.count == 0) + + // Verify LiveCounter has zero value + #expect(try result.value(coreSDK: coreSDK) == 0) + } + + // @spec RTO12h2 + @Test + func returnsExistingObjectIfAlreadyInPool() async throws { + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) + + // Track published messages and the generated objectId + var publishedMessages: [OutboundObjectMessage] = [] + var maybeGeneratedObjectID: String? + var maybeExistingObject: AnyObject? + + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + + // Extract the generated objectId from the published message + if let objectID = messages.first?.operation?.objectId { + maybeGeneratedObjectID = objectID + + // Create an object with this exact ID in the pool + // This simulates the object already existing when createMap tries to get it, before the publish operation completes (e.g. because it has been populated by receipt of an OBJECT) + maybeExistingObject = realtimeObjects.testsOnly_createZeroValueLiveObject(forObjectID: objectID)?.counterValue + } + } + + // Call createCounter - the publishHandler will create the object with the generated ID + let result = try await realtimeObjects.createCounter(count: 10.5, coreSDK: coreSDK) + + // Verify ObjectMessage was published + #expect(publishedMessages.count == 1) + + // Extract the variables that we populated based on the generated object ID + let generatedObjectID = try #require(maybeGeneratedObjectID) + let existingObject = try #require(maybeExistingObject) + + // Verify the returned object is the same as the existing one + #expect(result === existingObject) + + // Check that the existing object has not been replaced in the pool + #expect(realtimeObjects.testsOnly_objectsPool.entries[generatedObjectID]?.counterValue === existingObject) + } + } + } } diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift index 4883717b..f01b59ea 100644 --- a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift +++ b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift @@ -6,13 +6,18 @@ final class MockCoreSDK: CoreSDK { private let mutex = NSLock() private nonisolated(unsafe) var _channelState: ARTRealtimeChannelState + private nonisolated(unsafe) var _publishHandler: (([OutboundObjectMessage]) async throws(InternalError) -> Void)? init(channelState: ARTRealtimeChannelState) { _channelState = channelState } - func publish(objectMessages _: [AblyLiveObjects.OutboundObjectMessage]) async throws(AblyLiveObjects.InternalError) { - protocolRequirementNotImplemented() + func publish(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { + if let handler = _publishHandler { + try await handler(objectMessages) + } else { + protocolRequirementNotImplemented() + } } var channelState: ARTRealtimeChannelState { @@ -27,4 +32,11 @@ final class MockCoreSDK: CoreSDK { } } } + + /// Sets a custom publish handler for testing + func setPublishHandler(_ handler: @escaping ([OutboundObjectMessage]) async throws(InternalError) -> Void) { + mutex.withLock { + _publishHandler = handler + } + } } diff --git a/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift b/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift new file mode 100644 index 00000000..126b9426 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift @@ -0,0 +1,216 @@ +@testable import AblyLiveObjects +import Foundation +import Testing + +struct ObjectCreationHelpersTests { + struct CreationOperationTests { + // @spec RTO11f4c + // @spec RTO11f4c1a + // @spec RTO11f4c1a + // @spec RTO11f4c1b + // @spec RTO11f4c1c + // @spec RTO11f4c1d + // @spec RTO11f4c1e + // @spec RTO11f4c1f + // @spec RTO11f4c2 + // @spec RTO11f9 + // @spec RTO11f11 + // @spec RTO11f12 + // @spec RTO11f13 + // @specOneOf(1/2) RTO13 + @available(iOS 16.0.0, tvOS 16.0.0, *) // because of using Regex + @Test + func map() throws { + // Given + let timestamp = Date(timeIntervalSince1970: 1_754_042_434) + let logger = TestLogger() + let clock = MockSimpleClock() + let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "referencedMapID", logger: logger, userCallbackQueue: .main, clock: clock) + let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "referencedCounterID", logger: logger, userCallbackQueue: .main, clock: clock) + + // When + let creationOperation = ObjectCreationHelpers.creationOperationForLiveMap( + entries: [ + // RTO11f4c1a + "mapRef": .liveMap(referencedMap), + // RTO11f4c1a + "counterRef": .liveCounter(referencedCounter), + // RTO11f4c1b + "jsonArrayKey": .primitive(.jsonArray([.string("arrayItem1"), .string("arrayItem2")])), + "jsonObjectKey": .primitive(.jsonObject(["nestedKey": .string("nestedValue")])), + // RTO11f4c1c + "stringKey": .primitive(.string("stringValue")), + // RTO11f4c1d + "numberKey": .primitive(.number(42.5)), + // RTO11f4c1e + "booleanKey": .primitive(.bool(true)), + // RTO11f4c1f + "dataKey": .primitive(.data(Data([0x01, 0x02, 0x03]))), + ], + timestamp: timestamp, + ) + + // Then + + // Check that the denormalized properties match those of the ObjectMessage + #expect(creationOperation.objectMessage.operation == creationOperation.operation) + #expect(creationOperation.objectMessage.operation?.map?.semantics == .known(creationOperation.semantics)) + + // Check that the initial value JSON is correctly populated on the initialValue property per RTO11f12, using the RTO11f4 partial ObjectOperation and correctly encoded per RTO13 + let initialValueString = try #require(creationOperation.operation.initialValue) + let deserializedInitialValue = try #require(try JSONObjectOrArray(jsonString: initialValueString).objectValue) + #expect(deserializedInitialValue == [ + "map": [ + // RTO11f4a + "semantics": .number(ObjectsMapSemantics.lww.rawValue as NSNumber), + "entries": [ + // RTO11f4c1a + "mapRef": [ + "data": [ + "objectId": "referencedMapID", + ], + ], + "counterRef": [ + "data": [ + "objectId": "referencedCounterID", + ], + ], + // RTO11f4c1b + "jsonArrayKey": [ + "data": [ + "json": #"["arrayItem1","arrayItem2"]"#, + ], + ], + "jsonObjectKey": [ + "data": [ + "json": #"{"nestedKey":"nestedValue"}"#, + ], + ], + // RTO11f4c1c + "stringKey": [ + "data": [ + "string": "stringValue", + ], + ], + // RTO11f4c1d + "numberKey": [ + "data": [ + "number": 42.5, + ], + ], + // RTO11f4c1e + "booleanKey": [ + "data": [ + "boolean": true, + ], + ], + // RTO11f4c1f + "dataKey": [ + "data": [ + "bytes": .string(Data([0x01, 0x02, 0x03]).base64EncodedString()), + ], + ], + ], + ], + ]) + + // Check that the partial ObjectOperation properties are set on the ObjectMessage, per RTO11f13 + + #expect(creationOperation.objectMessage.operation?.map?.semantics == .known(.lww)) + + let expectedEntries: [String: ObjectsMapEntry] = [ + "mapRef": .init(data: .init(objectId: "referencedMapID")), + "counterRef": .init(data: .init(objectId: "referencedCounterID")), + "jsonArrayKey": .init(data: .init(json: .array(["arrayItem1", "arrayItem2"]))), + "jsonObjectKey": .init(data: .init(json: .object(["nestedKey": "nestedValue"]))), + "stringKey": .init(data: .init(string: "stringValue")), + "numberKey": .init(data: .init(number: 42.5)), + "booleanKey": .init(data: .init(boolean: true)), + "dataKey": .init(data: .init(bytes: Data([0x01, 0x02, 0x03]))), + ] + #expect(creationOperation.objectMessage.operation?.map?.entries == expectedEntries) + + // Check the other ObjectMessage properties + + // RTO11f9 + #expect(creationOperation.operation.action == .known(.mapCreate)) + + // Check that objectId has been populated on ObjectMessage per RTO11f10, and do a quick sense check that its format is what we'd expect from RTO11f8 (RTO14 is properly tested elsewhere) + #expect(try /map:.*@1754042434000/.firstMatch(in: creationOperation.operation.objectId) != nil) + + // Check that nonce has been populated per RTO11f11 (we make no assertions about its format or randomness) + #expect(creationOperation.operation.nonce != nil) + } + + // @spec RTO12f2a + // @spec RTO12f10 + // @spec RTO12f6 + // @spec RTO12f8 + // @spec RTO12f9 + // @specOneOf(2/2) RTO13 + @Test + @available(iOS 16.0.0, tvOS 16.0.0, *) // because of using Regex + func counter() throws { + // Given + let timestamp = Date(timeIntervalSince1970: 1_754_042_434) + + // When + let creationOperation = ObjectCreationHelpers.creationOperationForLiveCounter( + count: 10.5, + timestamp: timestamp, + ) + + // Then + + // Check that the denormalized properties match those of the ObjectMessage + #expect(creationOperation.objectMessage.operation == creationOperation.operation) + + // Check that the initial value JSON is correctly populated on the initialValue property per RTO12f10, using the RTO12f2 partial ObjectOperation and correctly encoded per RTO13 + let initialValueString = try #require(creationOperation.operation.initialValue) + let deserializedInitialValue = try #require(try JSONObjectOrArray(jsonString: initialValueString).objectValue) + #expect(deserializedInitialValue == [ + "counter": [ + // RTO12f2a + "count": 10.5, + ], + ]) + + // Check that the partial ObjectOperation properties are set on the ObjectMessage, per RTO12f10 + + #expect(creationOperation.objectMessage.operation?.counter?.count == 10.5) + + // Check the other ObjectMessage properties + + // RTO12f7 + #expect(creationOperation.operation.action == .known(.counterCreate)) + + // Check that objectId has been populated on ObjectMessage per RTO12f8, and do a quick sense check that its format is what we'd expect from RTO12f6 (RTO14 is properly tested elsewhere) + #expect(try /counter:.*@1754042434000/.firstMatch(in: creationOperation.operation.objectId) != nil) + + // Check that nonce has been populated per RTO12f9 (we make no assertions about its format or randomness) + #expect(creationOperation.operation.nonce != nil) + } + } + + /// Tests for the RTO14 objectID generation. + struct ObjectIDTests { + // @spec RTO14 + // @spec RTO14b1 + // @spec RTO14b2 + // @spec RTO14c + @Test + func createObjectID() { + let objectID = ObjectCreationHelpers.testsOnly_createObjectID( + type: "counter", + initialValue: "arbitraryInitialValue", + nonce: "arbitraryNonceABC", // Chosen to provoke a Base64 encoding that contains Base64URL-prohibited characters +, /, and =; see below + timestamp: Date( + timeIntervalSince1970: 1_754_042_434, + ), + ) + + // (The Base64-encoded SHA-256 of "arbitraryInitialValue:arbitraryNonceABC" is X5cX5Wv32Wj84/tzyuj5XD/Qpa76E+JkjPPQMK5aouw=) + #expect(objectID == "counter:X5cX5Wv32Wj84_tzyuj5XD_Qpa76E-JkjPPQMK5aouw@1754042434000") + } + } +}