diff --git a/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved index 74c3fc8..7961d42 100644 --- a/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6a8d15fb1d326ac6e8a40c286c152332146d6f58c73123cb8083f68d483dd728", + "originHash" : "4a28f4c041628961c5cac754904f5f76c33935bbc8417b2e036e0ab6c33b7a0a", "pins" : [ { "identity" : "ably-cocoa", @@ -7,7 +7,7 @@ "location" : "https://github.com/ably/ably-cocoa", "state" : { "branch" : "main", - "revision" : "ccca241a8a7f08b22a93802161460c843d9b5bf3" + "revision" : "4856ba6a423788902a6ef680793e7f404ceb4a51" } }, { diff --git a/Example/AblyChatExample/Mocks/Misc.swift b/Example/AblyChatExample/Mocks/Misc.swift index e25cd8d..9a6b362 100644 --- a/Example/AblyChatExample/Mocks/Misc.swift +++ b/Example/AblyChatExample/Mocks/Misc.swift @@ -37,6 +37,10 @@ final class MockMessagesPaginatedResult: PaginatedResult { self.roomID = roomID self.numberOfMockMessages = numberOfMockMessages } + + static func == (_: MockMessagesPaginatedResult, _: MockMessagesPaginatedResult) -> Bool { + fatalError("Not implemented") + } } enum MockStrings { diff --git a/Example/AblyChatExample/Mocks/MockRealtime.swift b/Example/AblyChatExample/Mocks/MockRealtime.swift index e74923a..ae4a347 100644 --- a/Example/AblyChatExample/Mocks/MockRealtime.swift +++ b/Example/AblyChatExample/Mocks/MockRealtime.swift @@ -14,6 +14,10 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable { let channels = Channels() final class Channels: RealtimeChannelsProtocol { + func get(_: String, options _: ARTRealtimeChannelOptions) -> MockRealtime.Channel { + fatalError("Not implemented") + } + func get(_: String) -> Channel { fatalError("Not implemented") } diff --git a/Package.resolved b/Package.resolved index 03ddbb8..33b18ea 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "db24f2979451a46f504f45d35893eb8501f27488ae70e1412340139a0e7551e2", + "originHash" : "48d264bc362ab438d94b78732ce83d1971c3a1264d089085edd3814083479fc6", "pins" : [ { "identity" : "ably-cocoa", @@ -7,7 +7,7 @@ "location" : "https://github.com/ably/ably-cocoa", "state" : { "branch" : "main", - "revision" : "ccca241a8a7f08b22a93802161460c843d9b5bf3" + "revision" : "4856ba6a423788902a6ef680793e7f404ceb4a51" } }, { diff --git a/Package.swift b/Package.swift index 14f5e28..74824c3 100644 --- a/Package.swift +++ b/Package.swift @@ -40,6 +40,10 @@ let package = Package( name: "Ably", package: "ably-cocoa" ), + .product( + name: "AsyncAlgorithms", + package: "swift-async-algorithms" + ), ] ), .testTarget( diff --git a/Sources/AblyChat/ChatAPI.swift b/Sources/AblyChat/ChatAPI.swift new file mode 100644 index 0000000..7a610f1 --- /dev/null +++ b/Sources/AblyChat/ChatAPI.swift @@ -0,0 +1,143 @@ +import Ably + +internal final class ChatAPI: Sendable { + private let realtime: RealtimeClient + private let apiVersion = "/chat/v1" + + public init(realtime: RealtimeClient) { + self.realtime = realtime + } + + internal func getChannel(_ name: String) -> any RealtimeChannelProtocol { + realtime.getChannel(name) + } + + // (CHA-M6) Messages should be queryable from a paginated REST API. + internal func getMessages(roomId: String, params: QueryOptions) async throws -> any PaginatedResult { + let endpoint = "\(apiVersion)/rooms/\(roomId)/messages" + return try await makePaginatedRequest(endpoint, params: params.asQueryItems()) + } + + internal struct SendMessageResponse: Codable { + internal let timeserial: String + internal let createdAt: Int64 + } + + // (CHA-M3) Messages are sent to Ably via the Chat REST API, using the send method. + // (CHA-M3a) When a message is sent successfully, the caller shall receive a struct representing the Message in response (as if it were received via Realtime event). + internal func sendMessage(roomId: String, params: SendMessageParams) async throws -> Message { + guard let clientId = realtime.clientId else { + throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.") + } + + let endpoint = "\(apiVersion)/rooms/\(roomId)/messages" + var body: [String: Any] = ["text": params.text] + + // (CHA-M3b) A message may be sent without metadata or headers. When these are not specified by the user, they must be omitted from the REST payload. + if let metadata = params.metadata { + body["metadata"] = metadata + + // (CHA-M3c) metadata must not contain the key ably-chat. This is reserved for future internal use. If this key is present, the send call shall terminate by throwing an ErrorInfo with code 40001. + if metadata.contains(where: { $0.key == "ably-chat" }) { + throw ARTErrorInfo.create(withCode: 40001, message: "metadata must not contain the key `ably-chat`") + } + } + + if let headers = params.headers { + body["headers"] = headers + + // (CHA-M3d) headers must not contain a key prefixed with ably-chat. This is reserved for future internal use. If this key is present, the send call shall terminate by throwing an ErrorInfo with code 40001. + if headers.keys.contains(where: { keyString in + keyString.hasPrefix("ably-chat") + }) { + throw ARTErrorInfo.create(withCode: 40001, message: "headers must not contain any key with a prefix of `ably-chat`") + } + } + + let response: SendMessageResponse = try await makeRequest(endpoint, method: "POST", body: body) + + // response.createdAt is in milliseconds, convert it to seconds + let createdAtInSeconds = TimeInterval(Double(response.createdAt) / 1000) + + let message = Message( + timeserial: response.timeserial, + clientID: clientId, + roomID: roomId, + text: params.text, + createdAt: Date(timeIntervalSince1970: createdAtInSeconds), + metadata: params.metadata ?? [:], + headers: params.headers ?? [:] + ) + return message + } + + internal func getOccupancy(roomId: String) async throws -> OccupancyEvent { + let endpoint = "\(apiVersion)/rooms/\(roomId)/occupancy" + return try await makeRequest(endpoint, method: "GET") + } + + // TODO: https://github.com/ably-labs/ably-chat-swift/issues/84 - Improve how we're decoding via `JSONSerialization` within the `DictionaryDecoder` + private func makeRequest(_ url: String, method: String, body: [String: Any]? = nil) async throws -> Response { + try await withCheckedThrowingContinuation { continuation in + do { + try realtime.request(method, path: url, params: [:], body: body, headers: [:]) { paginatedResponse, error in + if let error { + // (CHA-M3e) If an error is returned from the REST API, its ErrorInfo representation shall be thrown as the result of the send call. + continuation.resume(throwing: ARTErrorInfo.create(from: error)) + return + } + + guard let firstItem = paginatedResponse?.items.first else { + continuation.resume(throwing: ChatError.noItemInResponse) + return + } + + do { + let decodedResponse = try DictionaryDecoder().decode(Response.self, from: firstItem) + continuation.resume(returning: decodedResponse) + } catch { + continuation.resume(throwing: error) + } + } + } catch { + continuation.resume(throwing: error) + } + } + } + + private func makePaginatedRequest( + _ url: String, + params: [String: String]? = nil, + body: [String: Any]? = nil + ) async throws -> any PaginatedResult { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation, _>) in + do { + try realtime.request("GET", path: url, params: params, body: nil, headers: [:]) { paginatedResponse, error in + ARTHTTPPaginatedCallbackWrapper(callbackResult: (paginatedResponse, error)).handleResponse(continuation: continuation) + } + } catch { + continuation.resume(throwing: error) + } + } + } + + internal enum ChatError: Error { + case noItemInResponse + } +} + +internal struct DictionaryDecoder { + private let decoder = JSONDecoder() + + // Function to decode from a dictionary + internal func decode(_: T.Type, from dictionary: NSDictionary) throws -> T { + let data = try JSONSerialization.data(withJSONObject: dictionary) + return try decoder.decode(T.self, from: data) + } + + // Function to decode from a dictionary array + internal func decode(_: T.Type, from dictionary: [NSDictionary]) throws -> T { + let data = try JSONSerialization.data(withJSONObject: dictionary) + return try decoder.decode(T.self, from: data) + } +} diff --git a/Sources/AblyChat/DefaultMessages.swift b/Sources/AblyChat/DefaultMessages.swift new file mode 100644 index 0000000..3329f8b --- /dev/null +++ b/Sources/AblyChat/DefaultMessages.swift @@ -0,0 +1,238 @@ +import Ably + +// Typealias for the timeserial used to sync message subscriptions with. This is a string representation of a timestamp. +private typealias TimeserialString = String + +// Wraps the MessageSubscription with the timeserial of when the subscription was attached or resumed. +private struct MessageSubscriptionWrapper { + let subscription: MessageSubscription + var timeserial: TimeserialString +} + +// TODO: Don't have a strong understanding of why @MainActor is needed here. Revisit as part of https://github.com/ably-labs/ably-chat-swift/issues/83 +@MainActor +internal final class DefaultMessages: Messages, EmitsDiscontinuities { + private let roomID: String + public let channel: RealtimeChannelProtocol + private let chatAPI: ChatAPI + private let clientID: String + + // TODO: https://github.com/ably-labs/ably-chat-swift/issues/36 - Handle unsubscribing in line with CHA-M4b + // UUID acts as a unique identifier for each listener/subscription. MessageSubscriptionWrapper houses the subscription and the timeserial of when it was attached or resumed. + private var subscriptionPoints: [UUID: MessageSubscriptionWrapper] = [:] + + internal nonisolated init(chatAPI: ChatAPI, roomID: String, clientID: String) async { + self.chatAPI = chatAPI + self.roomID = roomID + self.clientID = clientID + + // (CHA-M1) Chat messages for a Room are sent on a corresponding realtime channel ::$chat::$chatMessages. For example, if your room id is my-room then the messages channel will be my-room::$chat::$chatMessages. + let messagesChannelName = "\(roomID)::$chat::$chatMessages" + channel = chatAPI.getChannel(messagesChannelName) + + // Implicitly handles channel events and therefore listners within this class. Alternative is to explicitly call something like `DefaultMessages.start()` which makes the SDK more cumbersome to interact with. This class is useless without kicking off this flow so I think leaving it here is suitable. + // "Calls to instance method 'handleChannelEvents(roomId:)' from outside of its actor context are implicitly asynchronous" hence the `await` here. + await handleChannelEvents(roomId: roomID) + } + + // (CHA-M4) Messages can be received via a subscription in realtime. + internal func subscribe(bufferingPolicy: BufferingPolicy) async throws -> MessageSubscription { + let uuid = UUID() + let timeserial = try await resolveSubscriptionStart() + let messageSubscription = MessageSubscription( + bufferingPolicy: bufferingPolicy + ) { [weak self] queryOptions in + guard let self else { throw MessagesError.noReferenceToSelf } + return try await getBeforeSubscriptionStart(uuid, params: queryOptions) + } + + // (CHA-M4a) A subscription can be registered to receive incoming messages. Adding a subscription has no side effects on the status of the room or the underlying realtime channel. + subscriptionPoints[uuid] = .init(subscription: messageSubscription, timeserial: timeserial) + + // (CHA-M4c) When a realtime message with name set to message.created is received, it is translated into a message event, which contains a type field with the event type as well as a message field containing the Message Struct. This event is then broadcast to all subscribers. + // (CHA-M4d) If a realtime message with an unknown name is received, the SDK shall silently discard the message, though it may log at DEBUG or TRACE level. + // (CHA-M5d) Incoming realtime events that are malformed (unknown field should be ignored) shall not be emitted to subscribers. + channel.subscribe(MessageEvent.created.rawValue) { message in + Task { + // TODO: Revisit errors thrown as part of https://github.com/ably-labs/ably-chat-swift/issues/32 + guard let data = message.data as? [String: Any], + let text = data["text"] as? String + else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data or text") + } + + guard let extras = try message.extras?.toJSON() else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without extras") + } + + guard let timeserial = extras["timeserial"] as? String else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without timeserial") + } + + guard let clientID = message.clientId else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without clientId") + } + + let metadata = data["metadata"] as? Metadata + let headers = try message.extras?.toJSON()["headers"] as? Headers + + let message = Message( + timeserial: timeserial, + clientID: clientID, + roomID: self.roomID, + text: text, + createdAt: message.timestamp, + metadata: metadata ?? .init(), + headers: headers ?? .init() + ) + + messageSubscription.emit(message) + } + } + + return messageSubscription + } + + // (CHA-M6a) A method must be exposed that accepts the standard Ably REST API query parameters. It shall call the “REST API”#rest-fetching-messages and return a PaginatedResult containing messages, which can then be paginated through. + internal func get(options: QueryOptions) async throws -> any PaginatedResult { + try await chatAPI.getMessages(roomId: roomID, params: options) + } + + internal func send(params: SendMessageParams) async throws -> Message { + try await chatAPI.sendMessage(roomId: roomID, params: params) + } + + // TODO: (CHA-M7) Users may subscribe to discontinuity events to know when there’s been a break in messages that they need to resolve. Their listener will be called when a discontinuity event is triggered from the room lifecycle. - https://github.com/ably-labs/ably-chat-swift/issues/47 + internal nonisolated func subscribeToDiscontinuities() -> Subscription { + fatalError("not implemented") + } + + private func getBeforeSubscriptionStart(_ uuid: UUID, params: QueryOptions) async throws -> any PaginatedResult { + guard let subscriptionPoint = subscriptionPoints[uuid]?.timeserial else { + throw ARTErrorInfo.create( + withCode: 40000, + status: 400, + message: "cannot query history; listener has not been subscribed yet" + ) + } + + // (CHA-M5j) If the end parameter is specified and is more recent than the subscription point timeserial, the method must throw an ErrorInfo with code 40000. + let parseSerial = try? DefaultTimeserial.calculateTimeserial(from: subscriptionPoint) + if let end = params.end, dateToMilliseconds(end) > parseSerial?.timestamp ?? 0 { + throw ARTErrorInfo.create( + withCode: 40000, + status: 400, + message: "cannot query history; end time is after the subscription point of the listener" + ) + } + + // (CHA-M5f) This method must accept any of the standard history query options, except for direction, which must always be backwards. + var queryOptions = params + queryOptions.orderBy = .newestFirst // newestFirst is equivalent to backwards + + // (CHA-M5g) The subscribers subscription point must be additionally specified (internally, by us) in the fromSerial query parameter. + queryOptions.fromSerial = subscriptionPoint + + return try await chatAPI.getMessages(roomId: roomID, params: queryOptions) + } + + private func handleChannelEvents(roomId _: String) { + // (CHA-M5c) If a channel leaves the ATTACHED state and then re-enters ATTACHED with resumed=false, then it must be assumed that messages have been missed. The subscription point of any subscribers must be reset to the attachSerial. + channel.on(.attached) { [weak self] stateChange in + Task { + do { + try await self?.handleAttach(fromResume: stateChange.resumed) + } catch { + throw ARTErrorInfo.create(from: error) + } + } + } + + // (CHA-M4d) If a channel UPDATE event is received and resumed=false, then it must be assumed that messages have been missed. The subscription point of any subscribers must be reset to the attachSerial. + channel.on(.update) { [weak self] stateChange in + Task { + do { + try await self?.handleAttach(fromResume: stateChange.resumed) + } catch { + throw ARTErrorInfo.create(from: error) + } + } + } + } + + private func handleAttach(fromResume: Bool) async throws { + // Do nothing if we have resumed as there is no discontinuity in the message stream + if fromResume { + return + } + + do { + let timeserialOnChannelAttach = try await timeserialOnChannelAttach() + + for uuid in subscriptionPoints.keys { + subscriptionPoints[uuid]?.timeserial = timeserialOnChannelAttach + } + } catch { + throw ARTErrorInfo.create(from: error) + } + } + + private func resolveSubscriptionStart() async throws -> TimeserialString { + // (CHA-M5a) If a subscription is added when the underlying realtime channel is ATTACHED, then the subscription point is the current channelSerial of the realtime channel. + if channel.state == .attached { + if let channelSerial = channel.properties.channelSerial { + return channelSerial + } else { + throw ARTErrorInfo.create(withCode: 40000, status: 400, message: "channel is attached, but channelSerial is not defined") + } + } + + // (CHA-M5b) If a subscription is added when the underlying realtime channel is in any other state, then its subscription point becomes the attachSerial at the the point of channel attachment. + return try await timeserialOnChannelAttach() + } + + // Always returns the attachSerial and not the channelSerial to also serve (CHA-M5c) - If a channel leaves the ATTACHED state and then re-enters ATTACHED with resumed=false, then it must be assumed that messages have been missed. The subscription point of any subscribers must be reset to the attachSerial. + private func timeserialOnChannelAttach() async throws -> TimeserialString { + // If the state is already 'attached', return the attachSerial immediately + if channel.state == .attached { + if let attachSerial = channel.properties.attachSerial { + return attachSerial + } else { + throw ARTErrorInfo.create(withCode: 40000, status: 400, message: "Channel is attached, but attachSerial is not defined") + } + } + + // (CHA-M5b) If a subscription is added when the underlying realtime channel is in any other state, then its subscription point becomes the attachSerial at the the point of channel attachment. + return try await withCheckedThrowingContinuation { continuation in + channel.on { [weak self] stateChange in + guard let self else { + return + } + switch stateChange.current { + case .attached: + // Handle successful attachment + if let attachSerial = channel.properties.attachSerial { + continuation.resume(returning: attachSerial) + } else { + continuation.resume(throwing: ARTErrorInfo.create(withCode: 40000, status: 400, message: "Channel is attached, but attachSerial is not defined")) + } + case .failed, .suspended: + // TODO: Revisit as part of https://github.com/ably-labs/ably-chat-swift/issues/32 + continuation.resume( + throwing: ARTErrorInfo.create( + withCode: ErrorCode.messagesAttachmentFailed.rawValue, + status: ErrorCode.messagesAttachmentFailed.statusCode, + message: "Channel failed to attach" + ) + ) + default: + break + } + } + } + } + + internal enum MessagesError: Error { + case noReferenceToSelf + } +} diff --git a/Sources/AblyChat/Dependencies.swift b/Sources/AblyChat/Dependencies.swift index e0d1e18..ecf5edd 100644 --- a/Sources/AblyChat/Dependencies.swift +++ b/Sources/AblyChat/Dependencies.swift @@ -15,8 +15,25 @@ public protocol RealtimeChannelsProtocol: ARTRealtimeChannelsProtocol, Sendable associatedtype Channel: RealtimeChannelProtocol // It’s not clear to me why ARTRealtimeChannelsProtocol doesn’t include this property (https://github.com/ably/ably-cocoa/issues/1968). + func get(_ name: String, options: ARTRealtimeChannelOptions) -> Channel func get(_ name: String) -> Channel } /// Expresses the requirements of the object returned by ``RealtimeChannelsProtocol.get(_:)``. public protocol RealtimeChannelProtocol: ARTRealtimeChannelProtocol, Sendable {} + +internal extension RealtimeClientProtocol { + // Function to get the channel with merged options + func getChannel(_ name: String, opts: ARTRealtimeChannelOptions? = nil) -> any RealtimeChannelProtocol { + // Merge opts and defaultChannelOptions + let resolvedOptions = opts ?? ARTRealtimeChannelOptions() + + // Merge params if available, using defaultChannelOptions as fallback + resolvedOptions.params = opts?.params?.merging( + defaultChannelOptions.params ?? [:] + ) { _, new in new } + + // Return the resolved channel + return channels.get(name, options: resolvedOptions) + } +} diff --git a/Sources/AblyChat/Events.swift b/Sources/AblyChat/Events.swift new file mode 100644 index 0000000..cd2d5fb --- /dev/null +++ b/Sources/AblyChat/Events.swift @@ -0,0 +1,3 @@ +internal enum MessageEvent: String { + case created = "message.created" +} diff --git a/Sources/AblyChat/Headers.swift b/Sources/AblyChat/Headers.swift index 9735a7f..e64a12f 100644 --- a/Sources/AblyChat/Headers.swift +++ b/Sources/AblyChat/Headers.swift @@ -1,8 +1,8 @@ -import Foundation +// TODO: https://github.com/ably-labs/ably-chat-swift/issues/13 - try to improve this type -public enum HeadersValue: Sendable { +public enum HeadersValue: Sendable, Codable, Equatable { case string(String) - case number(NSNumber) + case number(Int) // Changed from NSNumber to Int to conform to Codable. Address in linked issue above. case bool(Bool) case null } diff --git a/Sources/AblyChat/Message.swift b/Sources/AblyChat/Message.swift index 92ce94f..d3b9b41 100644 --- a/Sources/AblyChat/Message.swift +++ b/Sources/AblyChat/Message.swift @@ -3,16 +3,20 @@ import Foundation public typealias MessageHeaders = Headers public typealias MessageMetadata = Metadata -public struct Message: Sendable { +// (CHA-M2) A Message corresponds to a single message in a chat room. This is analogous to a single user-specified message on an Ably channel (NOTE: not a ProtocolMessage). +public struct Message: Sendable, Codable, Identifiable, Equatable { + // id to meet Identifiable conformance. 2 messages in the same channel cannot have the same timeserial. + public var id: String { timeserial } + public var timeserial: String public var clientID: String public var roomID: String public var text: String - public var createdAt: Date + public var createdAt: Date? public var metadata: MessageMetadata public var headers: MessageHeaders - public init(timeserial: String, clientID: String, roomID: String, text: String, createdAt: Date, metadata: MessageMetadata, headers: MessageHeaders) { + public init(timeserial: String, clientID: String, roomID: String, text: String, createdAt: Date?, metadata: MessageMetadata, headers: MessageHeaders) { self.timeserial = timeserial self.clientID = clientID self.roomID = roomID @@ -22,15 +26,31 @@ public struct Message: Sendable { self.headers = headers } - public func isBefore(_: Message) -> Bool { - fatalError("Not yet implemented") + internal enum CodingKeys: String, CodingKey { + case timeserial + case clientID = "clientId" + case roomID = "roomId" + case text + case createdAt + case metadata + case headers + } + + // (CHA-M2a) A Message is considered before another Message in the global order if the timeserial of the corresponding realtime channel message comes first. + public func isBefore(_ otherMessage: Message) throws -> Bool { + let otherMessageTimeserial = try DefaultTimeserial.calculateTimeserial(from: otherMessage.timeserial) + return try DefaultTimeserial.calculateTimeserial(from: timeserial).before(otherMessageTimeserial) } - public func isAfter(_: Message) -> Bool { - fatalError("Not yet implemented") + // CHA-M2b) A Message is considered after another Message in the global order if the timeserial of the corresponding realtime channel message comes second. + public func isAfter(_ otherMessage: Message) throws -> Bool { + let otherMessageTimeserial = try DefaultTimeserial.calculateTimeserial(from: otherMessage.timeserial) + return try DefaultTimeserial.calculateTimeserial(from: timeserial).after(otherMessageTimeserial) } - public func isEqual(_: Message) -> Bool { - fatalError("Not yet implemented") + // (CHA-M2c) A Message is considered to be equal to another Message if they have the same timeserial. + public func isEqual(_ otherMessage: Message) throws -> Bool { + let otherMessageTimeserial = try DefaultTimeserial.calculateTimeserial(from: otherMessage.timeserial) + return try DefaultTimeserial.calculateTimeserial(from: timeserial).equal(otherMessageTimeserial) } } diff --git a/Sources/AblyChat/Messages.swift b/Sources/AblyChat/Messages.swift index a603a82..b880b83 100644 --- a/Sources/AblyChat/Messages.swift +++ b/Sources/AblyChat/Messages.swift @@ -1,7 +1,7 @@ import Ably public protocol Messages: AnyObject, Sendable, EmitsDiscontinuities { - func subscribe(bufferingPolicy: BufferingPolicy) async -> MessageSubscription + func subscribe(bufferingPolicy: BufferingPolicy) async throws -> MessageSubscription func get(options: QueryOptions) async throws -> any PaginatedResult func send(params: SendMessageParams) async throws -> Message var channel: RealtimeChannelProtocol { get } @@ -30,6 +30,9 @@ public struct QueryOptions: Sendable { public var limit: Int? public var orderBy: ResultOrder? + // (CHA-M5g) The subscribers subscription point must be additionally specified (internally, by us) in the fromSerial query parameter. + internal var fromSerial: String? + public init(start: Date? = nil, end: Date? = nil, limit: Int? = nil, orderBy: QueryOptions.ResultOrder? = nil) { self.start = start self.end = end @@ -38,15 +41,36 @@ public struct QueryOptions: Sendable { } } -public struct QueryOptionsWithoutDirection: Sendable { - public var start: Date? - public var end: Date? - public var limit: Int? +internal extension QueryOptions { + // Same as `ARTDataQuery.asQueryItems` from ably-cocoa. + func asQueryItems() -> [String: String] { + var dict: [String: String] = [:] + if let start { + dict["start"] = "\(dateToMilliseconds(start))" + } - public init(start: Date? = nil, end: Date? = nil, limit: Int? = nil) { - self.start = start - self.end = end - self.limit = limit + if let end { + dict["end"] = "\(dateToMilliseconds(end))" + } + + if let limit { + dict["limit"] = "\(limit)" + } + + if let orderBy { + switch orderBy { + case .oldestFirst: + dict["direction"] = "forwards" + case .newestFirst: + dict["direction"] = "backwards" + } + } + + if let fromSerial { + dict["fromSerial"] = fromSerial + } + + return dict } } @@ -56,26 +80,30 @@ public struct MessageSubscription: Sendable, AsyncSequence { private var subscription: Subscription - private var mockGetPreviousMessages: (@Sendable (QueryOptionsWithoutDirection) async throws -> any PaginatedResult)? + // can be set by either initialiser + private let getPreviousMessages: @Sendable (QueryOptions) async throws -> any PaginatedResult - internal init(bufferingPolicy: BufferingPolicy) { + // used internally + internal init( + bufferingPolicy: BufferingPolicy, + getPreviousMessages: @escaping @Sendable (QueryOptions) async throws -> any PaginatedResult + ) { subscription = .init(bufferingPolicy: bufferingPolicy) + self.getPreviousMessages = getPreviousMessages } - public init(mockAsyncSequence: T, mockGetPreviousMessages: @escaping @Sendable (QueryOptionsWithoutDirection) async throws -> any PaginatedResult) where T.Element == Element { + // used for testing + public init(mockAsyncSequence: T, mockGetPreviousMessages: @escaping @Sendable (QueryOptions) async throws -> any PaginatedResult) where T.Element == Element { subscription = .init(mockAsyncSequence: mockAsyncSequence) - self.mockGetPreviousMessages = mockGetPreviousMessages + getPreviousMessages = mockGetPreviousMessages } internal func emit(_ element: Element) { subscription.emit(element) } - public func getPreviousMessages(params: QueryOptionsWithoutDirection) async throws -> any PaginatedResult { - guard let mockImplementation = mockGetPreviousMessages else { - fatalError("Not yet implemented") - } - return try await mockImplementation(params) + public func getPreviousMessages(params: QueryOptions) async throws -> any PaginatedResult { + try await getPreviousMessages(params) } public struct AsyncIterator: AsyncIteratorProtocol { diff --git a/Sources/AblyChat/Metadata.swift b/Sources/AblyChat/Metadata.swift index e6f94f0..85bc686 100644 --- a/Sources/AblyChat/Metadata.swift +++ b/Sources/AblyChat/Metadata.swift @@ -1,2 +1,11 @@ -// TODO: (https://github.com/ably-labs/ably-chat-swift/issues/13): try to improve this type -public typealias Metadata = [String: (any Sendable)?] +// TODO: https://github.com/ably-labs/ably-chat-swift/issues/13 - try to improve this type +// I attempted to address this issue by making a struct conforming to Codable which would at least give us some safety in knowing items can be encoded and decoded. Gave up on it due to fixing other protocol requirements so gone for the same approach as Headers for now, we can investigate whether we need to be open to more types than this later. + +public enum MetadataValue: Sendable, Codable, Equatable { + case string(String) + case number(Int) // Changed from NSNumber to Int to conform to Codable. Address in linked issue above. + case bool(Bool) + case null +} + +public typealias Metadata = [String: MetadataValue?] diff --git a/Sources/AblyChat/Occupancy.swift b/Sources/AblyChat/Occupancy.swift index 52aaf4c..53d0d19 100644 --- a/Sources/AblyChat/Occupancy.swift +++ b/Sources/AblyChat/Occupancy.swift @@ -6,7 +6,7 @@ public protocol Occupancy: AnyObject, Sendable, EmitsDiscontinuities { var channel: RealtimeChannelProtocol { get } } -public struct OccupancyEvent: Sendable { +public struct OccupancyEvent: Sendable, Encodable, Decodable { public var connections: Int public var presenceMembers: Int diff --git a/Sources/AblyChat/PaginatedResult.swift b/Sources/AblyChat/PaginatedResult.swift index da3142d..ce7491a 100644 --- a/Sources/AblyChat/PaginatedResult.swift +++ b/Sources/AblyChat/PaginatedResult.swift @@ -1,4 +1,6 @@ -public protocol PaginatedResult: AnyObject, Sendable { +import Ably + +public protocol PaginatedResult: AnyObject, Sendable, Equatable { associatedtype T var items: [T] { get } @@ -9,3 +11,92 @@ public protocol PaginatedResult: AnyObject, Sendable { var first: any PaginatedResult { get async throws } var current: any PaginatedResult { get async throws } } + +/// Used internally to reduce the amount of duplicate code when interacting with `ARTHTTPPaginatedCallback`'s. The wrapper takes in the callback result from the caller e.g. `realtime.request` and either throws the appropriate error, or decodes and returns the response. +internal struct ARTHTTPPaginatedCallbackWrapper { + internal let callbackResult: (ARTHTTPPaginatedResponse?, ARTErrorInfo?) + + internal func handleResponse(continuation: CheckedContinuation, any Error>) { + let (paginatedResponse, error) = callbackResult + + // (CHA-M5i) If the REST API returns an error, then the method must throw its ErrorInfo representation. + // (CHA-M6b) If the REST API returns an error, then the method must throw its ErrorInfo representation. + if let error { + continuation.resume(throwing: ARTErrorInfo.create(from: error)) + return + } + + guard let paginatedResponse, paginatedResponse.statusCode == 200 else { + continuation.resume(throwing: PaginatedResultError.noErrorWithInvalidResponse) + return + } + + do { + let decodedResponse = try DictionaryDecoder().decode([Response].self, from: paginatedResponse.items) + let result = paginatedResponse.toPaginatedResult(items: decodedResponse) + continuation.resume(returning: result) + } catch { + continuation.resume(throwing: error) + } + } + + internal enum PaginatedResultError: Error { + case noErrorWithInvalidResponse + } +} + +/// `PaginatedResult` protocol implementation allowing access to the underlying items from a lower level paginated response object e.g. `ARTHTTPPaginatedResponse`, whilst succinctly handling errors through the use of `ARTHTTPPaginatedCallbackWrapper`. +internal final class PaginatedResultWrapper: PaginatedResult { + internal let items: [T] + internal let hasNext: Bool + internal let isLast: Bool + internal let paginatedResponse: ARTHTTPPaginatedResponse + + internal init(paginatedResponse: ARTHTTPPaginatedResponse, items: [T]) { + self.items = items + hasNext = paginatedResponse.hasNext + isLast = paginatedResponse.isLast + self.paginatedResponse = paginatedResponse + } + + /// Asynchronously fetch the next page if available + internal var next: (any PaginatedResult)? { + get async throws { + try await withCheckedThrowingContinuation { continuation in + paginatedResponse.next { paginatedResponse, error in + ARTHTTPPaginatedCallbackWrapper(callbackResult: (paginatedResponse, error)).handleResponse(continuation: continuation) + } + } + } + } + + /// Asynchronously fetch the first page + internal var first: any PaginatedResult { + get async throws { + try await withCheckedThrowingContinuation { continuation in + paginatedResponse.first { paginatedResponse, error in + ARTHTTPPaginatedCallbackWrapper(callbackResult: (paginatedResponse, error)).handleResponse(continuation: continuation) + } + } + } + } + + /// Asynchronously fetch the current page + internal var current: any PaginatedResult { + self + } + + internal static func == (lhs: PaginatedResultWrapper, rhs: PaginatedResultWrapper) -> Bool { + lhs.items == rhs.items && + lhs.hasNext == rhs.hasNext && + lhs.isLast == rhs.isLast && + lhs.paginatedResponse == rhs.paginatedResponse + } +} + +private extension ARTHTTPPaginatedResponse { + /// Converts an `ARTHTTPPaginatedResponse` to a `PaginatedResultWrapper` allowing for access to operations as per conformance to `PaginatedResult`. + func toPaginatedResult(items: [T]) -> PaginatedResultWrapper { + PaginatedResultWrapper(paginatedResponse: self, items: items) + } +} diff --git a/Sources/AblyChat/Room.swift b/Sources/AblyChat/Room.swift index e4660c8..72352e2 100644 --- a/Sources/AblyChat/Room.swift +++ b/Sources/AblyChat/Room.swift @@ -20,6 +20,9 @@ public protocol Room: AnyObject, Sendable { internal actor DefaultRoom: Room { internal nonisolated let roomID: String internal nonisolated let options: RoomOptions + private let chatAPI: ChatAPI + + public nonisolated let messages: any Messages // Exposed for testing. private nonisolated let realtime: RealtimeClient @@ -33,16 +36,23 @@ internal actor DefaultRoom: Room { private let _status: DefaultRoomStatus private let logger: InternalLogger - internal init(realtime: RealtimeClient, roomID: String, options: RoomOptions, logger: InternalLogger) { + internal init(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger) async throws { self.realtime = realtime self.roomID = roomID self.options = options self.logger = logger _status = .init(logger: logger) - } + self.chatAPI = chatAPI - public nonisolated var messages: any Messages { - fatalError("Not yet implemented") + guard let clientId = realtime.clientId else { + throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.") + } + + messages = await DefaultMessages( + chatAPI: chatAPI, + roomID: roomID, + clientID: clientId + ) } public nonisolated var presence: any Presence { diff --git a/Sources/AblyChat/RoomLifecycleManager.swift b/Sources/AblyChat/RoomLifecycleManager.swift index 0bc540a..4204160 100644 --- a/Sources/AblyChat/RoomLifecycleManager.swift +++ b/Sources/AblyChat/RoomLifecycleManager.swift @@ -1,8 +1,14 @@ -import Ably +@preconcurrency import Ably +import AsyncAlgorithms /// The interface that the lifecycle manager expects its contributing realtime channels to conform to. /// -/// We use this instead of the ``RealtimeChannel`` interface as its ``attach`` and ``detach`` methods are `async` instead of using callbacks. This makes it easier to write mocks for (since ``RealtimeChannel`` doesn’t express to the type system that the callbacks it receives need to be `Sendable`, it’s hard to, for example, create a mock that creates a `Task` and then calls the callback from inside this task). +/// We use this instead of the ``RealtimeChannelProtocol`` interface as: +/// +/// - its ``attach`` and ``detach`` methods are `async` instead of using callbacks +/// - it uses `AsyncSequence` to emit state changes instead of using callbacks +/// +/// This makes it easier to write mocks for (since ``RealtimeChannelProtocol`` doesn’t express to the type system that the callbacks it receives need to be `Sendable`, it’s hard to, for example, create a mock that creates a `Task` and then calls the callback from inside this task). /// /// We choose to also mark the channel’s mutable state as `async`. This is a way of highlighting at the call site of accessing this state that, since `ARTRealtimeChannel` mutates this state on a separate thread, it’s possible for this state to have changed since the last time you checked it, or since the last time you performed an operation that might have mutated it, or since the last time you recieved an event informing you that it changed. To be clear, marking these as `async` doesn’t _solve_ these issues; it just makes them a bit more visible. We’ll decide how to address them in https://github.com/ably-labs/ably-chat-swift/issues/49. internal protocol RoomLifecycleContributorChannel: Sendable { @@ -11,31 +17,90 @@ internal protocol RoomLifecycleContributorChannel: Sendable { var state: ARTRealtimeChannelState { get async } var errorReason: ARTErrorInfo? { get async } + + /// Equivalent to subscribing to a `RealtimeChannelProtocol` object’s state changes via its `on(_:)` method. The subscription should use the ``BufferingPolicy.unbounded`` buffering policy. + /// + /// It is marked as `async` purely to make it easier to write mocks for this method (i.e. to use an actor as a mock). + func subscribeToState() async -> Subscription } -internal actor RoomLifecycleManager { - /// A realtime channel that contributes to the room lifecycle. - internal struct Contributor { - /// The room feature that this contributor corresponds to. Used only for choosing which error to throw when a contributor operation fails. - internal var feature: RoomFeature +/// A realtime channel that contributes to the room lifecycle. +/// +/// The identity implied by the `Identifiable` conformance must distinguish each of the contributors passed to a given ``RoomLifecycleManager`` instance. +internal protocol RoomLifecycleContributor: Identifiable, Sendable { + associatedtype Channel: RoomLifecycleContributorChannel + + /// The room feature that this contributor corresponds to. Used only for choosing which error to throw when a contributor operation fails. + var feature: RoomFeature { get } + var channel: Channel { get } + + /// Informs the contributor that there has been a break in channel continuity, which it should inform library users about. + /// + /// It is marked as `async` purely to make it easier to write mocks for this method (i.e. to use an actor as a mock). + func emitDiscontinuity(_ error: ARTErrorInfo) async +} - internal var channel: Channel +internal actor RoomLifecycleManager { + /// Stores manager state relating to a given contributor. + private struct ContributorAnnotation { + // TODO: Not clear whether there can be multiple or just one (asked in https://github.com/ably/specification/pull/200/files#r1781927850) + var pendingDiscontinuityEvents: [ARTErrorInfo] = [] } internal private(set) var current: RoomLifecycle internal private(set) var error: ARTErrorInfo? + // TODO: This currently allows the the tests to inject a value in order to test the spec points that are predicated on whether “a channel lifecycle operation is in progress”. In https://github.com/ably-labs/ably-chat-swift/issues/52 we’ll set this property based on whether there actually is a lifecycle operation in progress. + private let hasOperationInProgress: Bool + /// Manager state that relates to individual contributors, keyed by contributors’ ``Contributor.id``. Stored separately from ``contributors`` so that the latter can be a `let`, to make it clear that the contributors remain fixed for the lifetime of the manager. + private var contributorAnnotations: ContributorAnnotations + + /// Provides a `Dictionary`-like interface for storing manager state about individual contributors. + private struct ContributorAnnotations { + private var storage: [Contributor.ID: ContributorAnnotation] + + init(contributors: [Contributor], pendingDiscontinuityEvents: [Contributor.ID: [ARTErrorInfo]]) { + storage = contributors.reduce(into: [:]) { result, contributor in + result[contributor.id] = .init(pendingDiscontinuityEvents: pendingDiscontinuityEvents[contributor.id] ?? []) + } + } + + /// It is a programmer error to call this subscript getter with a contributor that was not one of those passed to ``init(contributors:pendingDiscontinuityEvents)``. + subscript(_ contributor: Contributor) -> ContributorAnnotation { + get { + guard let annotation = storage[contributor.id] else { + preconditionFailure("Expected annotation for \(contributor)") + } + return annotation + } + + set { + storage[contributor.id] = newValue + } + } + + mutating func clearPendingDiscontinuityEvents() { + storage = storage.mapValues { annotation in + var newAnnotation = annotation + newAnnotation.pendingDiscontinuityEvents = [] + return newAnnotation + } + } + } private let logger: InternalLogger private let clock: SimpleClock private let contributors: [Contributor] + private var listenForStateChangesTask: Task! internal init( contributors: [Contributor], logger: InternalLogger, clock: SimpleClock - ) { - self.init( + ) async { + await self.init( current: nil, + hasOperationInProgress: nil, + pendingDiscontinuityEvents: [:], contributors: contributors, logger: logger, clock: clock @@ -45,12 +110,16 @@ internal actor RoomLifecycleManager { #if DEBUG internal init( testsOnly_current current: RoomLifecycle? = nil, + testsOnly_hasOperationInProgress hasOperationInProgress: Bool? = nil, + testsOnly_pendingDiscontinuityEvents pendingDiscontinuityEvents: [Contributor.ID: [ARTErrorInfo]]? = nil, contributors: [Contributor], logger: InternalLogger, clock: SimpleClock - ) { - self.init( + ) async { + await self.init( current: current, + hasOperationInProgress: hasOperationInProgress, + pendingDiscontinuityEvents: pendingDiscontinuityEvents, contributors: contributors, logger: logger, clock: clock @@ -60,16 +129,56 @@ internal actor RoomLifecycleManager { private init( current: RoomLifecycle?, + hasOperationInProgress: Bool?, + pendingDiscontinuityEvents: [Contributor.ID: [ARTErrorInfo]]?, contributors: [Contributor], logger: InternalLogger, clock: SimpleClock - ) { + ) async { self.current = current ?? .initialized + self.hasOperationInProgress = hasOperationInProgress ?? false self.contributors = contributors + contributorAnnotations = .init(contributors: contributors, pendingDiscontinuityEvents: pendingDiscontinuityEvents ?? [:]) self.logger = logger self.clock = clock + + // The idea here is to make sure that, before the initializer completes, we are already listening for state changes, so that e.g. tests don’t miss a state change. + let subscriptions = await withTaskGroup(of: (contributor: Contributor, subscription: Subscription).self) { group in + for contributor in contributors { + group.addTask { + await (contributor: contributor, subscription: contributor.channel.subscribeToState()) + } + } + + return await Array(group) + } + + // CHA-RL4: listen for state changes from our contributors + // TODO: Understand what happens when this task gets cancelled by `deinit`; I’m not convinced that the for-await loops will exit (https://github.com/ably-labs/ably-chat-swift/issues/29) + listenForStateChangesTask = Task { + await withTaskGroup(of: Void.self) { group in + for (contributor, subscription) in subscriptions { + // This `@Sendable` is to make the compiler error "'self'-isolated value of type '() async -> Void' passed as a strongly transferred parameter; later accesses could race" go away. I don’t hugely understand what it means, but given the "'self'-isolated value" I guessed it was something vaguely to do with the fact that `async` actor initializers are actor-isolated and thought that marking it as `@Sendable` would sever this isolation and make the error go away, which it did 🤷. But there are almost certainly consequences that I am incapable of reasoning about with my current level of Swift concurrency knowledge. + group.addTask { @Sendable [weak self] in + for await stateChange in subscription { + await self?.didReceiveStateChange(stateChange, forContributor: contributor) + } + } + } + } + } } + deinit { + listenForStateChangesTask.cancel() + } + + #if DEBUG + internal func testsOnly_pendingDiscontinuityEvents(for contributor: Contributor) -> [ARTErrorInfo] { + contributorAnnotations[contributor].pendingDiscontinuityEvents + } + #endif + // TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36) private var subscriptions: [Subscription] = [] @@ -79,6 +188,113 @@ internal actor RoomLifecycleManager { return subscription } + #if DEBUG + // TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36) + /// Supports the ``testsOnly_subscribeToHandledContributorStateChanges()`` method. + private var stateChangeHandledSubscriptions: [Subscription] = [] + + /// Returns a subscription which emits the contributor state changes that have been handled by the manager. + /// + /// A contributor state change is considered handled once the manager has performed all of the side effects that it will perform as a result of receiving this state change. Specifically, once: + /// + /// - the manager has recorded all pending discontinuity events provoked by the state change (you can retrieve these using ``testsOnly_pendingDiscontinuityEventsForContributor(at:)``) + /// - the manager has performed all status changes provoked by the state change + /// - the manager has performed all contributor actions provoked by the state change, namely calls to ``RoomLifecycleContributorChannel.detach()`` or ``RoomLifecycleContributor.emitDiscontinuity(_:)`` + internal func testsOnly_subscribeToHandledContributorStateChanges() -> Subscription { + let subscription = Subscription(bufferingPolicy: .unbounded) + stateChangeHandledSubscriptions.append(subscription) + return subscription + } + #endif + + /// Implements CHA-RL4b’s contributor state change handling. + private func didReceiveStateChange(_ stateChange: ARTChannelStateChange, forContributor contributor: Contributor) async { + logger.log(message: "Got state change \(stateChange) for contributor \(contributor)", level: .info) + + // TODO: The spec, which is written for a single-threaded environment, is presumably operating on the assumption that the channel is currently in the state given by `stateChange.current` (https://github.com/ably-labs/ably-chat-swift/issues/49) + switch stateChange.event { + case .update: + // CHA-RL4a1 — if RESUMED then no-op + guard !stateChange.resumed else { + break + } + + guard let reason = stateChange.reason else { + // TODO: Decide the right thing to do here (https://github.com/ably-labs/ably-chat-swift/issues/74) + preconditionFailure("State change event with resumed == false should have a reason") + } + + if hasOperationInProgress { + // CHA-RL4a3 + logger.log(message: "Recording pending discontinuity event for contributor \(contributor)", level: .info) + + contributorAnnotations[contributor].pendingDiscontinuityEvents.append(reason) + } else { + // CHA-RL4a4 + logger.log(message: "Emitting discontinuity event for contributor \(contributor)", level: .info) + + await contributor.emitDiscontinuity(reason) + } + case .attached: + if hasOperationInProgress { + if !stateChange.resumed { + // CHA-RL4b1 + logger.log(message: "Recording pending discontinuity event for contributor \(contributor)", level: .info) + + guard let reason = stateChange.reason else { + // TODO: Decide the right thing to do here (https://github.com/ably-labs/ably-chat-swift/issues/74) + preconditionFailure("State change event with resumed == false should have a reason") + } + + contributorAnnotations[contributor].pendingDiscontinuityEvents.append(reason) + } + } else if current != .attached { + if await (contributors.async.map { await $0.channel.state }.allSatisfy { $0 == .attached }) { + // CHA-RL4b8 + logger.log(message: "Now that all contributors are ATTACHED, transitioning room to ATTACHED", level: .info) + changeStatus(to: .attached) + } + } + case .failed: + if !hasOperationInProgress { + // CHA-RL4b5 + guard let reason = stateChange.reason else { + // TODO: Decide the right thing to do here (https://github.com/ably-labs/ably-chat-swift/issues/74) + preconditionFailure("FAILED state change event should have a reason") + } + + changeStatus(to: .failed, error: reason) + + // TODO: CHA-RL4b5 is a bit unclear about how to handle failure, and whether they can be detached concurrently (asked in https://github.com/ably/specification/pull/200/files#r1777471810) + for contributor in contributors { + do { + try await contributor.channel.detach() + } catch { + logger.log(message: "Failed to detach contributor \(contributor), error \(error)", level: .info) + } + } + } + case .suspended: + if !hasOperationInProgress { + // CHA-RL4b9 + guard let reason = stateChange.reason else { + // TODO: Decide the right thing to do here (https://github.com/ably-labs/ably-chat-swift/issues/74) + preconditionFailure("SUSPENDED state change event should have a reason") + } + + changeStatus(to: .suspended, error: reason) + } + default: + break + } + + #if DEBUG + for subscription in stateChangeHandledSubscriptions { + subscription.emit(stateChange) + } + #endif + } + /// Updates ``current`` and ``error`` and emits a status change event. private func changeStatus(to new: RoomLifecycle, error: ARTErrorInfo? = nil) { logger.log(message: "Transitioning from \(current) to \(new), error \(String(describing: error))", level: .info) @@ -150,6 +366,23 @@ internal actor RoomLifecycleManager { // CHA-RL1g1 changeStatus(to: .attached) + + // CHA-RL1g2 + await emitPendingDiscontinuityEvents() + } + + /// Implements CHA-RL1g2’s emitting of pending discontinuity events. + private func emitPendingDiscontinuityEvents() async { + // Emit all pending discontinuity events + logger.log(message: "Emitting pending discontinuity events", level: .info) + for contributor in contributors { + for pendingDiscontinuityEvent in contributorAnnotations[contributor].pendingDiscontinuityEvents { + logger.log(message: "Emitting pending discontinuity event \(pendingDiscontinuityEvent) to contributor \(contributor)", level: .info) + await contributor.emitDiscontinuity(pendingDiscontinuityEvent) + } + } + + contributorAnnotations.clearPendingDiscontinuityEvents() } /// Implements CHA-RL1h5’s "detach all channels that are not in the FAILED state". diff --git a/Sources/AblyChat/Rooms.swift b/Sources/AblyChat/Rooms.swift index 87f1d7c..b286b2c 100644 --- a/Sources/AblyChat/Rooms.swift +++ b/Sources/AblyChat/Rooms.swift @@ -8,6 +8,7 @@ public protocol Rooms: AnyObject, Sendable { internal actor DefaultRooms: Rooms { private nonisolated let realtime: RealtimeClient + private let chatAPI: ChatAPI #if DEBUG internal nonisolated var testsOnly_realtime: RealtimeClient { @@ -26,9 +27,10 @@ internal actor DefaultRooms: Rooms { self.realtime = realtime self.clientOptions = clientOptions self.logger = logger + chatAPI = ChatAPI(realtime: realtime) } - internal func get(roomID: String, options: RoomOptions) throws -> any Room { + internal func get(roomID: String, options: RoomOptions) async throws -> any Room { // CHA-RC1b if let existingRoom = rooms[roomID] { if existingRoom.options != options { @@ -39,7 +41,7 @@ internal actor DefaultRooms: Rooms { return existingRoom } else { - let room = DefaultRoom(realtime: realtime, roomID: roomID, options: options, logger: logger) + let room = try await DefaultRoom(realtime: realtime, chatAPI: chatAPI, roomID: roomID, options: options, logger: logger) rooms[roomID] = room return room } diff --git a/Sources/AblyChat/Timeserial.swift b/Sources/AblyChat/Timeserial.swift new file mode 100644 index 0000000..97aadb7 --- /dev/null +++ b/Sources/AblyChat/Timeserial.swift @@ -0,0 +1,94 @@ +import Foundation + +internal protocol Timeserial: Sendable { + var seriesId: String { get } + var timestamp: Int { get } + var counter: Int { get } + var index: Int? { get } + + func before(_ timeserial: Timeserial) -> Bool + func after(_ timeserial: Timeserial) -> Bool + func equal(_ timeserial: Timeserial) -> Bool +} + +internal struct DefaultTimeserial: Timeserial { + internal let seriesId: String + internal let timestamp: Int + internal let counter: Int + internal let index: Int? + + private init(seriesId: String, timestamp: Int, counter: Int, index: Int?) { + self.seriesId = seriesId + self.timestamp = timestamp + self.counter = counter + self.index = index + } + + // Static method to parse a timeserial string + internal static func calculateTimeserial(from timeserial: String) throws -> DefaultTimeserial { + let components = timeserial.split(separator: "@") + guard components.count == 2, let rest = components.last else { + throw TimeserialError.invalidFormat + } + + let seriesId = String(components[0]) + let parts = rest.split(separator: "-") + guard parts.count == 2 else { + throw TimeserialError.invalidFormat + } + + let timestamp = Int(parts[0]) ?? 0 + let counterAndIndex = parts[1].split(separator: ":") + let counter = Int(counterAndIndex[0]) ?? 0 + let index = counterAndIndex.count > 1 ? Int(counterAndIndex[1]) : nil + + return DefaultTimeserial(seriesId: seriesId, timestamp: timestamp, counter: counter, index: index) + } + + // Compare timeserials + private func timeserialCompare(_ other: Timeserial) -> Int { + // Compare timestamps + let timestampDiff = timestamp - other.timestamp + if timestampDiff != 0 { + return timestampDiff + } + + // Compare counters + let counterDiff = counter - other.counter + if counterDiff != 0 { + return counterDiff + } + + // Compare seriesId lexicographically + if seriesId != other.seriesId { + return seriesId < other.seriesId ? -1 : 1 + } + + // Compare index if present + if let idx1 = index, let idx2 = other.index { + return idx1 - idx2 + } + + return 0 + } + + // Check if this timeserial is before the given timeserial + internal func before(_ timeserial: Timeserial) -> Bool { + timeserialCompare(timeserial) < 0 + } + + // Check if this timeserial is after the given timeserial + internal func after(_ timeserial: Timeserial) -> Bool { + timeserialCompare(timeserial) > 0 + } + + // Check if this timeserial is equal to the given timeserial + internal func equal(_ timeserial: Timeserial) -> Bool { + timeserialCompare(timeserial) == 0 + } + + // TODO: Revisit as part of https://github.com/ably-labs/ably-chat-swift/issues/32 (should we only throw ARTErrors?) + internal enum TimeserialError: Error { + case invalidFormat + } +} diff --git a/Sources/AblyChat/Version.swift b/Sources/AblyChat/Version.swift new file mode 100644 index 0000000..98dab3c --- /dev/null +++ b/Sources/AblyChat/Version.swift @@ -0,0 +1,17 @@ +import Ably + +// TODO: Just copied chat-js implementation for now to send up agent info. https://github.com/ably-labs/ably-chat-swift/issues/76 + +// Update this when you release a new version +// Version information +public let version = "0.1.0" + +// Channel options agent string +public let channelOptionsAgentString = "chat-ios/\(version)" + +// Default channel options +public var defaultChannelOptions: ARTRealtimeChannelOptions { + let options = ARTRealtimeChannelOptions() + options.params = ["agent": channelOptionsAgentString] + return options +} diff --git a/Tests/AblyChatTests/ChatAPITests.swift b/Tests/AblyChatTests/ChatAPITests.swift new file mode 100644 index 0000000..761232e --- /dev/null +++ b/Tests/AblyChatTests/ChatAPITests.swift @@ -0,0 +1,218 @@ +import Ably +@testable import AblyChat +import Testing + +struct ChatAPITests { + // MARK: getChannel Tests + + // @spec CHA-M1 + @Test + func getChannel_returnsChannel() { + // Given + let realtime = MockRealtime.create( + channels: .init(channels: [.init(name: "basketball::$chat::$chatMessages")]) + ) + let chatAPI = ChatAPI(realtime: realtime) + + // When + let channel = chatAPI.getChannel("basketball::$chat::$chatMessages") + + // Then + #expect(channel.name == "basketball::$chat::$chatMessages") + } + + // MARK: sendMessage Tests + + // @spec CHA-M3c + @Test + func sendMessage_whenMetadataHasAblyChatAsKey_throws40001() async { + // Given + let realtime = MockRealtime.create() + let chatAPI = ChatAPI(realtime: realtime) + let roomId = "basketball::$chat::$chatMessages" + let expectedError = ARTErrorInfo.create(withCode: 40001, message: "metadata must not contain the key `ably-chat`") + + await #expect( + performing: { + // When + try await chatAPI.sendMessage(roomId: roomId, params: .init(text: "hello", metadata: ["ably-chat": .null])) + }, throws: { error in + // Then + error as? ARTErrorInfo == expectedError + } + ) + } + + // @specOneOf(1/2) CHA-M3d + @Test + func sendMessage_whenHeadersHasAnyKeyWithPrefixOfAblyChat_throws40001() async { + // Given + let realtime = MockRealtime.create { + (MockHTTPPaginatedResponse.successSendMessage, nil) + } + let chatAPI = ChatAPI(realtime: realtime) + let roomId = "basketball::$chat::$chatMessages" + let expectedError = ARTErrorInfo.create(withCode: 40001, message: "headers must not contain any key with a prefix of `ably-chat`") + + await #expect( + performing: { + // When + try await chatAPI.sendMessage(roomId: roomId, params: .init(text: "hello", headers: ["ably-chat123": .null])) + }, throws: { error in + // then + error as? ARTErrorInfo == expectedError + } + ) + } + + // @specOneOf(2/2) CHA-M3d + @Test + func sendMessage_whenHeadersHasAnyKeyWithSuffixOfAblyChat_doesNotThrowAnyError() async { + // Given + let realtime = MockRealtime.create { + (MockHTTPPaginatedResponse.successSendMessage, nil) + } + let chatAPI = ChatAPI(realtime: realtime) + let roomId = "basketball::$chat::$chatMessages" + + // Then + await #expect(throws: Never.self, performing: { + // When + try await chatAPI.sendMessage(roomId: roomId, params: .init(text: "hello", headers: ["123ably-chat": .null])) + }) + } + + @Test + func sendMessage_whenSendMessageReturnsNoItems_throwsNoItemInResponse() async { + // Given + let realtime = MockRealtime.create { + (MockHTTPPaginatedResponse.successSendMessageWithNoItems, nil) + } + let chatAPI = ChatAPI(realtime: realtime) + let roomId = "basketball::$chat::$chatMessages" + + await #expect( + performing: { + // When + try await chatAPI.sendMessage(roomId: roomId, params: .init(text: "hello", headers: [:])) + }, throws: { error in + // Then + error as? ChatAPI.ChatError == ChatAPI.ChatError.noItemInResponse + } + ) + } + + // @spec CHA-M3a + @Test + func sendMessage_returnsMessage() async throws { + // Given + let realtime = MockRealtime.create { + (MockHTTPPaginatedResponse.successSendMessage, nil) + } + let chatAPI = ChatAPI(realtime: realtime) + let roomId = "basketball::$chat::$chatMessages" + + // When + let message = try await chatAPI.sendMessage(roomId: roomId, params: .init(text: "hello", headers: [:])) + + // Then + let expectedMessage = Message( + timeserial: "3446456", + clientID: "mockClientId", + roomID: roomId, + text: "hello", + createdAt: Date(timeIntervalSince1970: 1_631_840_000), + metadata: [:], + headers: [:] + ) + #expect(message == expectedMessage) + } + + // MARK: getMessages Tests + + // @specOneOf(1/2) CHA-M6 + @Test + func getMessages_whenGetMessagesReturnsNoItems_returnsEmptyPaginatedResult() async { + // Given + let paginatedResponse = MockHTTPPaginatedResponse.successGetMessagesWithNoItems + let realtime = MockRealtime.create { + (paginatedResponse, nil) + } + let chatAPI = ChatAPI(realtime: realtime) + let roomId = "basketball::$chat::$chatMessages" + let expectedPaginatedResult = PaginatedResultWrapper( + paginatedResponse: paginatedResponse, + items: [] + ) + + // When + let getMessages = try? await chatAPI.getMessages(roomId: roomId, params: .init()) as? PaginatedResultWrapper + + // Then + #expect(getMessages == expectedPaginatedResult) + } + + // @specOneOf(2/2) CHA-M6 + @Test + func getMessages_whenGetMessagesReturnsItems_returnsItemsInPaginatedResult() async { + // Given + let paginatedResponse = MockHTTPPaginatedResponse.successGetMessagesWithItems + let realtime = MockRealtime.create { + (paginatedResponse, nil) + } + let chatAPI = ChatAPI(realtime: realtime) + let roomId = "basketball::$chat::$chatMessages" + let expectedPaginatedResult = PaginatedResultWrapper( + paginatedResponse: paginatedResponse, + items: [ + Message( + timeserial: "3446456", + clientID: "random", + roomID: roomId, + text: "hello", + createdAt: nil, + metadata: [:], + headers: [:] + ), + Message( + timeserial: "3446457", + clientID: "random", + roomID: roomId, + text: "hello response", + createdAt: nil, + metadata: [:], + headers: [:] + ), + ] + ) + + // When + let getMessages = try? await chatAPI.getMessages(roomId: roomId, params: .init()) as? PaginatedResultWrapper + + // Then + #expect(getMessages == expectedPaginatedResult) + } + + // @spec CHA-M5i + @Test + func getMessages_whenGetMessagesReturnsServerError_throwsARTError() async { + // Given + let paginatedResponse = MockHTTPPaginatedResponse.successGetMessagesWithNoItems + let artError = ARTErrorInfo.create(withCode: 50000, message: "Internal server error") + let realtime = MockRealtime.create { + (paginatedResponse, artError) + } + let chatAPI = ChatAPI(realtime: realtime) + let roomId = "basketball::$chat::$chatMessages" + + await #expect( + performing: { + // When + try await chatAPI.getMessages(roomId: roomId, params: .init()) as? PaginatedResultWrapper + }, throws: { error in + // Then + error as? ARTErrorInfo == artError + } + ) + } +} diff --git a/Tests/AblyChatTests/DefaultMessagesTests.swift b/Tests/AblyChatTests/DefaultMessagesTests.swift new file mode 100644 index 0000000..5f9a718 --- /dev/null +++ b/Tests/AblyChatTests/DefaultMessagesTests.swift @@ -0,0 +1,90 @@ +import Ably +@testable import AblyChat +import Testing + +struct DefaultMessagesTests { + // @spec CHA-M1 + @Test + func init_channelNameIsSetAsMessagesChannelName() async throws { + // clientID value is arbitrary + + // Given + let realtime = MockRealtime.create(channels: .init(channels: [.init(name: "basketball::$chat::$chatMessages")])) + let chatAPI = ChatAPI(realtime: realtime) + + // When + let defaultMessages = await DefaultMessages(chatAPI: chatAPI, roomID: "basketball", clientID: "clientId") + + // Then + await #expect(defaultMessages.channel.name == "basketball::$chat::$chatMessages") + } + + @Test + func subscribe_whenChannelIsAttachedAndNoChannelSerial_throwsError() async throws { + // roomId and clientId values are arbitrary + + // Given + let realtime = MockRealtime.create(channels: .init(channels: [.init(name: "basketball::$chat::$chatMessages")])) + let chatAPI = ChatAPI(realtime: realtime) + let defaultMessages = await DefaultMessages(chatAPI: chatAPI, roomID: "basketball", clientID: "clientId") + + // Then + await #expect(throws: ARTErrorInfo.create(withCode: 40000, status: 400, message: "channel is attached, but channelSerial is not defined"), performing: { + // When + try await defaultMessages.subscribe(bufferingPolicy: .unbounded) + }) + } + + @Test + func get_getMessagesIsExposedFromChatAPI() async throws { + // Message response of succcess with no items, and roomId are arbitrary + + // Given + let realtime = MockRealtime.create( + channels: .init(channels: [.init(name: "basketball::$chat::$chatMessages")]) + ) { (MockHTTPPaginatedResponse.successGetMessagesWithNoItems, nil) } + let chatAPI = ChatAPI(realtime: realtime) + let defaultMessages = await DefaultMessages(chatAPI: chatAPI, roomID: "basketball", clientID: "clientId") + + // Then + await #expect(throws: Never.self, performing: { + // When + // `_ =` is required to avoid needing iOS 16 to run this test + // Error: Runtime support for parameterized protocol types is only available in iOS 16.0.0 or newer + _ = try await defaultMessages.get(options: .init()) + }) + } + + @Test + func subscribe_returnsSubscription() async throws { + // all setup values here are arbitrary + + // Given + let realtime = MockRealtime.create( + channels: .init( + channels: [ + .init( + name: "basketball::$chat::$chatMessages", + properties: .init( + attachSerial: "001", + channelSerial: "001" + ) + ), + ] + ) + ) { (MockHTTPPaginatedResponse.successGetMessagesWithNoItems, nil) } + let chatAPI = ChatAPI(realtime: realtime) + let defaultMessages = await DefaultMessages(chatAPI: chatAPI, roomID: "basketball", clientID: "clientId") + let subscription = try await defaultMessages.subscribe(bufferingPolicy: .unbounded) + let expectedPaginatedResult = PaginatedResultWrapper( + paginatedResponse: MockHTTPPaginatedResponse.successGetMessagesWithNoItems, + items: [] + ) + + // When + let previousMessages = try await subscription.getPreviousMessages(params: .init()) as? PaginatedResultWrapper + + // Then + #expect(previousMessages == expectedPaginatedResult) + } +} diff --git a/Tests/AblyChatTests/DefaultRoomTests.swift b/Tests/AblyChatTests/DefaultRoomTests.swift index 8956a7e..d797e6f 100644 --- a/Tests/AblyChatTests/DefaultRoomTests.swift +++ b/Tests/AblyChatTests/DefaultRoomTests.swift @@ -17,7 +17,7 @@ struct DefaultRoomTests { ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) - let room = DefaultRoom(realtime: realtime, roomID: "basketball", options: .init(), logger: TestLogger()) + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger()) let subscription = await room.status.onChange(bufferingPolicy: .unbounded) async let attachedStatusChange = subscription.first { $0.current == .attached } @@ -50,7 +50,7 @@ struct DefaultRoomTests { ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) - let room = DefaultRoom(realtime: realtime, roomID: "basketball", options: .init(), logger: TestLogger()) + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger()) // When: `attach` is called on the room let roomAttachError: Error? @@ -79,7 +79,7 @@ struct DefaultRoomTests { ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) - let room = DefaultRoom(realtime: realtime, roomID: "basketball", options: .init(), logger: TestLogger()) + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger()) let subscription = await room.status.onChange(bufferingPolicy: .unbounded) async let detachedStatusChange = subscription.first { $0.current == .detached } @@ -112,7 +112,7 @@ struct DefaultRoomTests { ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) - let room = DefaultRoom(realtime: realtime, roomID: "basketball", options: .init(), logger: TestLogger()) + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger()) // When: `detach` is called on the room let roomDetachError: Error? diff --git a/Tests/AblyChatTests/DefaultRoomsTests.swift b/Tests/AblyChatTests/DefaultRoomsTests.swift index adf9faf..13ca7eb 100644 --- a/Tests/AblyChatTests/DefaultRoomsTests.swift +++ b/Tests/AblyChatTests/DefaultRoomsTests.swift @@ -1,12 +1,13 @@ @testable import AblyChat import Testing +// The channel name of basketball::$chat::$chatMessages is passed in to these tests due to `DefaultRoom` kicking off the `DefaultMessages` initialization. This in turn needs a valid `roomId` or else the `MockChannels` class will throw an error as it would be expecting a channel with the name \(roomID)::$chat::$chatMessages to exist (where `roomId` is the property passed into `rooms.get`). struct DefaultRoomsTests { // @spec CHA-RC1a @Test func get_returnsRoomWithGivenID() async throws { // Given: an instance of DefaultRooms - let realtime = MockRealtime.create() + let realtime = MockRealtime.create(channels: .init(channels: [.init(name: "basketball::$chat::$chatMessages")])) let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger()) // When: get(roomID:options:) is called @@ -25,7 +26,7 @@ struct DefaultRoomsTests { @Test func get_returnsExistingRoomWithGivenID() async throws { // Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID - let realtime = MockRealtime.create() + let realtime = MockRealtime.create(channels: .init(channels: [.init(name: "basketball::$chat::$chatMessages")])) let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger()) let roomID = "basketball" @@ -43,7 +44,7 @@ struct DefaultRoomsTests { @Test func get_throwsErrorWhenOptionsDoNotMatch() async throws { // Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID and options - let realtime = MockRealtime.create() + let realtime = MockRealtime.create(channels: .init(channels: [.init(name: "basketball::$chat::$chatMessages")])) let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger()) let roomID = "basketball" diff --git a/Tests/AblyChatTests/MessageSubscriptionTests.swift b/Tests/AblyChatTests/MessageSubscriptionTests.swift index 5babe7b..fbea280 100644 --- a/Tests/AblyChatTests/MessageSubscriptionTests.swift +++ b/Tests/AblyChatTests/MessageSubscriptionTests.swift @@ -2,7 +2,7 @@ import AsyncAlgorithms import Testing -private final class MockPaginatedResult: PaginatedResult { +private final class MockPaginatedResult: PaginatedResult { var items: [T] { fatalError("Not implemented") } var hasNext: Bool { fatalError("Not implemented") } @@ -16,6 +16,12 @@ private final class MockPaginatedResult: PaginatedResult { var current: any AblyChat.PaginatedResult { fatalError("Not implemented") } init() {} + + static func == (lhs: MockPaginatedResult, rhs: MockPaginatedResult) -> Bool { + lhs.items == rhs.items && + lhs.hasNext == rhs.hasNext && + lhs.isLast == rhs.isLast + } } struct MessageSubscriptionTests { @@ -32,7 +38,7 @@ struct MessageSubscriptionTests { @Test func emit() async { - let subscription = MessageSubscription(bufferingPolicy: .unbounded) + let subscription = MessageSubscription(bufferingPolicy: .unbounded) { _ in fatalError("Not implemented") } async let emittedElements = Array(subscription.prefix(2)) diff --git a/Tests/AblyChatTests/MessageTests.swift b/Tests/AblyChatTests/MessageTests.swift new file mode 100644 index 0000000..bbb88a5 --- /dev/null +++ b/Tests/AblyChatTests/MessageTests.swift @@ -0,0 +1,109 @@ +@testable import AblyChat +import Testing + +struct MessageTests { + let earlierMessage = Message( + timeserial: "ABC123@1631840000000-5:2", + clientID: "testClientID", + roomID: "roomId", + text: "hello", + createdAt: nil, + metadata: [:], + headers: [:] + ) + + let laterMessage = Message( + timeserial: "ABC123@1631840000001-5:2", + clientID: "testClientID", + roomID: "roomId", + text: "hello", + createdAt: nil, + metadata: [:], + headers: [:] + ) + + let invalidMessage = Message( + timeserial: "invalid", + clientID: "testClientID", + roomID: "roomId", + text: "hello", + createdAt: nil, + metadata: [:], + headers: [:] + ) + + // MARK: isBefore Tests + + // @specOneOf(1/3) CHA-M2a + @Test + func isBefore_WhenMessageIsBefore_ReturnsTrue() async throws { + #expect(try earlierMessage.isBefore(laterMessage)) + } + + // @specOneOf(2/3) CHA-M2a + @Test + func isBefore_WhenMessageIsNotBefore_ReturnsFalse() async throws { + #expect(try !laterMessage.isBefore(earlierMessage)) + } + + // @specOneOf(3/3) CHA-M2a + @Test + func isBefore_whenTimeserialIsInvalid_throwsInvalidMessage() async throws { + #expect(throws: DefaultTimeserial.TimeserialError.invalidFormat, performing: { + try earlierMessage.isBefore(invalidMessage) + }) + } + + // MARK: isAfter Tests + + // @specOneOf(1/3) CHA-M2b + @Test + func isAfter_whenMessageIsAfter_ReturnsTrue() async throws { + #expect(try laterMessage.isAfter(earlierMessage)) + } + + // @specOneOf(2/3) CHA-M2b + @Test + func isAfter_whenMessageIsNotAfter_ReturnsFalse() async throws { + #expect(try !earlierMessage.isAfter(laterMessage)) + } + + // @specOneOf(3/3) CHA-M2b + @Test + func isAfter_whenTimeserialIsInvalid_throwsInvalidMessage() async throws { + #expect(throws: DefaultTimeserial.TimeserialError.invalidFormat, performing: { + try earlierMessage.isAfter(invalidMessage) + }) + } + + // MARK: isEqual Tests + + // @specOneOf(1/3) CHA-M2c + @Test + func isEqual_whenMessageIsEqual_ReturnsTrue() async throws { + let duplicateOfEarlierMessage = Message( + timeserial: "ABC123@1631840000000-5:2", + clientID: "random", + roomID: "", + text: "", + createdAt: nil, + metadata: [:], + headers: [:] + ) + #expect(try earlierMessage.isEqual(duplicateOfEarlierMessage)) + } + + // @specOneOf(2/3) CHA-M2c + @Test + func isEqual_whenMessageIsNotEqual_ReturnsFalse() async throws { + #expect(try !earlierMessage.isEqual(laterMessage)) + } + + // @specOneOf(3/3) CHA-M2c + @Test + func isEqual_whenTimeserialIsInvalid_throwsInvalidMessage() async throws { + #expect(throws: DefaultTimeserial.TimeserialError.invalidFormat, performing: { + try earlierMessage.isEqual(invalidMessage) + }) + } +} diff --git a/Tests/AblyChatTests/Mocks/MockChannels.swift b/Tests/AblyChatTests/Mocks/MockChannels.swift index 6cbf82b..68f6a2e 100644 --- a/Tests/AblyChatTests/Mocks/MockChannels.swift +++ b/Tests/AblyChatTests/Mocks/MockChannels.swift @@ -16,6 +16,10 @@ final class MockChannels: RealtimeChannelsProtocol, Sendable { return channel } + func get(_ name: String, options _: ARTRealtimeChannelOptions) -> MockRealtimeChannel { + get(name) + } + func exists(_: String) -> Bool { fatalError("Not implemented") } diff --git a/Tests/AblyChatTests/Mocks/MockHTTPPaginatedResponse.swift b/Tests/AblyChatTests/Mocks/MockHTTPPaginatedResponse.swift new file mode 100644 index 0000000..cbf2ed3 --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockHTTPPaginatedResponse.swift @@ -0,0 +1,144 @@ +import Ably + +final class MockHTTPPaginatedResponse: ARTHTTPPaginatedResponse, @unchecked Sendable { + private let _items: [NSDictionary] + private let _statusCode: Int + private let _headers: [String: String] + private let _hasNext: Bool + private let _isLast: Bool + + init( + items: [NSDictionary], + statusCode: Int = 200, + headers: [String: String] = [:], + hasNext: Bool = false, + isLast: Bool = true + ) { + _items = items + _statusCode = statusCode + _headers = headers + _hasNext = hasNext + _isLast = isLast + super.init() + } + + override var items: [NSDictionary] { + _items + } + + override var statusCode: Int { + _statusCode + } + + override var headers: [String: String] { + _headers + } + + override var success: Bool { + (statusCode >= 200) && (statusCode < 300) + } + + override var hasNext: Bool { + _hasNext + } + + override var isLast: Bool { + _isLast + } + + override func next(_ callback: @escaping ARTHTTPPaginatedCallback) { + callback(hasNext ? MockHTTPPaginatedResponse.nextPage : nil, nil) + } + + override func first(_ callback: @escaping ARTHTTPPaginatedCallback) { + callback(self, nil) + } +} + +// MARK: ChatAPI.sendMessage mocked responses + +extension MockHTTPPaginatedResponse { + static let successSendMessage = MockHTTPPaginatedResponse( + items: [ + [ + "timeserial": "3446456", + "createdAt": 1_631_840_000_000, + "text": "hello", + ], + ], + statusCode: 500, + headers: [:] + ) + + static let failedSendMessage = MockHTTPPaginatedResponse( + items: [], + statusCode: 400, + headers: [:] + ) + + static let successSendMessageWithNoItems = MockHTTPPaginatedResponse( + items: [], + statusCode: 200, + headers: [:] + ) +} + +// MARK: ChatAPI.getMessages mocked responses + +extension MockHTTPPaginatedResponse { + private static let messagesRoomId = "basketball::$chat::$chatMessages" + + static let successGetMessagesWithNoItems = MockHTTPPaginatedResponse( + items: [], + statusCode: 200, + headers: [:] + ) + + static let successGetMessagesWithItems = MockHTTPPaginatedResponse( + items: [ + [ + "clientId": "random", + "timeserial": "3446456", + "roomId": "basketball::$chat::$chatMessages", + "text": "hello", + "metadata": [:], + "headers": [:], + ], + [ + "clientId": "random", + "timeserial": "3446457", + "roomId": "basketball::$chat::$chatMessages", + "text": "hello response", + "metadata": [:], + "headers": [:], + ], + ], + statusCode: 200, + headers: [:] + ) +} + +// MARK: Mock next page + +extension MockHTTPPaginatedResponse { + static let nextPage = MockHTTPPaginatedResponse( + items: [ + [ + "timeserial": "3446450", + "roomId": "basketball::$chat::$chatMessages", + "text": "previous message", + "metadata": [:], + "headers": [:], + ], + [ + "timeserial": "3446451", + "roomId": "basketball::$chat::$chatMessages", + "text": "previous response", + "metadata": [:], + "headers": [:], + ], + ], + statusCode: 200, + headers: [:] + ) +} diff --git a/Tests/AblyChatTests/Mocks/MockRealtime.swift b/Tests/AblyChatTests/Mocks/MockRealtime.swift index 6d0309d..a89dd0d 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtime.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtime.swift @@ -4,39 +4,50 @@ import Foundation /// A mock implementation of `ARTRealtimeProtocol`. We’ll figure out how to do mocking in tests properly in https://github.com/ably-labs/ably-chat-swift/issues/5. final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable { + let channels: MockChannels + let paginatedCallback: (@Sendable () -> (ARTHTTPPaginatedResponse?, ARTErrorInfo?))? + var device: ARTLocalDevice { fatalError("Not implemented") } var clientId: String? { - fatalError("Not implemented") + "mockClientId" + } + + init( + channels: MockChannels = .init(channels: []), + paginatedCallback: (@Sendable () -> (ARTHTTPPaginatedResponse?, ARTErrorInfo?))? = nil + ) { + self.channels = channels + self.paginatedCallback = paginatedCallback } required init(options _: ARTClientOptions) { channels = .init(channels: []) + paginatedCallback = nil } required init(key _: String) { channels = .init(channels: []) + paginatedCallback = nil } required init(token _: String) { channels = .init(channels: []) + paginatedCallback = nil } - init(channels: MockChannels = .init(channels: [])) { - self.channels = channels - } - - let channels: MockChannels - /** Creates an instance of MockRealtime. This exists to give a convenient way to create an instance, because `init` is marked as unavailable in `ARTRealtimeProtocol`. */ - static func create(channels: MockChannels = MockChannels(channels: [])) -> MockRealtime { - MockRealtime(channels: channels) + static func create( + channels: MockChannels = MockChannels(channels: []), + paginatedCallback: (@Sendable () -> (ARTHTTPPaginatedResponse?, ARTErrorInfo?))? = nil + ) -> MockRealtime { + MockRealtime(channels: channels, paginatedCallback: paginatedCallback) } func time(_: @escaping ARTDateTimeCallback) { @@ -63,7 +74,11 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable { fatalError("Not implemented") } - func request(_: String, path _: String, params _: [String: String]?, body _: Any?, headers _: [String: String]?, callback _: @escaping ARTHTTPPaginatedCallback) throws { - fatalError("Not implemented") + func request(_: String, path _: String, params _: [String: String]?, body _: Any?, headers _: [String: String]?, callback: @escaping ARTHTTPPaginatedCallback) throws { + guard let paginatedCallback else { + fatalError("Paginated callback not set") + } + let (paginatedResponse, error) = paginatedCallback() + callback(paginatedResponse, error) } } diff --git a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift index 5d556ae..67213d1 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift @@ -2,16 +2,24 @@ import Ably import AblyChat final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { + private let attachSerial: String? + private let channelSerial: String? private let _name: String? + var properties: ARTChannelProperties { .init(attachSerial: attachSerial, channelSerial: channelSerial) } + init( name: String? = nil, + properties: ARTChannelProperties = .init(), + state _: ARTRealtimeChannelState = .suspended, attachResult: AttachOrDetachResult? = nil, detachResult: AttachOrDetachResult? = nil ) { _name = name self.attachResult = attachResult self.detachResult = detachResult + attachSerial = properties.attachSerial + channelSerial = properties.channelSerial } /// A threadsafe counter that starts at zero. @@ -43,7 +51,7 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { } var state: ARTRealtimeChannelState { - fatalError("Not implemented") + .attached } var errorReason: ARTErrorInfo? { @@ -54,10 +62,6 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { fatalError("Not implemented") } - var properties: ARTChannelProperties { - fatalError("Not implemented") - } - func attach() { fatalError("Not implemented") } @@ -117,7 +121,7 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { } func subscribe(_: String, callback _: @escaping ARTMessageCallback) -> ARTEventListener? { - fatalError("Not implemented") + ARTEventListener() } func subscribe(_: String, onAttach _: ARTCallback?, callback _: @escaping ARTMessageCallback) -> ARTEventListener? { @@ -145,7 +149,7 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { } func on(_: ARTChannelEvent, callback _: @escaping (ARTChannelStateChange) -> Void) -> ARTEventListener { - fatalError("Not implemented") + ARTEventListener() } func on(_: @escaping (ARTChannelStateChange) -> Void) -> ARTEventListener { diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributor.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributor.swift new file mode 100644 index 0000000..e008fd2 --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributor.swift @@ -0,0 +1,18 @@ +import Ably +@testable import AblyChat + +actor MockRoomLifecycleContributor: RoomLifecycleContributor { + nonisolated let feature: RoomFeature + nonisolated let channel: MockRoomLifecycleContributorChannel + + private(set) var emitDiscontinuityArguments: [ARTErrorInfo] = [] + + init(feature: RoomFeature, channel: MockRoomLifecycleContributorChannel) { + self.feature = feature + self.channel = channel + } + + func emitDiscontinuity(_ error: ARTErrorInfo) async { + emitDiscontinuityArguments.append(error) + } +} diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift index d23539a..3ef81e5 100644 --- a/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift +++ b/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift @@ -1,4 +1,4 @@ -import Ably +@preconcurrency import Ably @testable import AblyChat final actor MockRoomLifecycleContributorChannel: RoomLifecycleContributorChannel { @@ -7,6 +7,8 @@ final actor MockRoomLifecycleContributorChannel: RoomLifecycleContributorChannel var state: ARTRealtimeChannelState var errorReason: ARTErrorInfo? + // TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36) + private var subscriptions: [Subscription] = [] private(set) var attachCallCount = 0 private(set) var detachCallCount = 0 @@ -92,4 +94,16 @@ final actor MockRoomLifecycleContributorChannel: RoomLifecycleContributorChannel throw error } } + + func subscribeToState() -> Subscription { + let subscription = Subscription(bufferingPolicy: .unbounded) + subscriptions.append(subscription) + return subscription + } + + func emitStateChange(_ stateChange: ARTChannelStateChange) { + for subscription in subscriptions { + subscription.emit(stateChange) + } + } } diff --git a/Tests/AblyChatTests/RoomLifecycleManagerTests.swift b/Tests/AblyChatTests/RoomLifecycleManagerTests.swift index 6ac1e54..14804d2 100644 --- a/Tests/AblyChatTests/RoomLifecycleManagerTests.swift +++ b/Tests/AblyChatTests/RoomLifecycleManagerTests.swift @@ -1,4 +1,4 @@ -import Ably +@preconcurrency import Ably @testable import AblyChat import Testing @@ -29,11 +29,15 @@ struct RoomLifecycleManagerTests { private func createManager( forTestingWhatHappensWhenCurrentlyIn current: RoomLifecycle? = nil, - contributors: [RoomLifecycleManager.Contributor] = [], + forTestingWhatHappensWhenHasOperationInProgress hasOperationInProgress: Bool? = nil, + forTestingWhatHappensWhenHasPendingDiscontinuityEvents pendingDiscontinuityEvents: [MockRoomLifecycleContributor.ID: [ARTErrorInfo]]? = nil, + contributors: [MockRoomLifecycleContributor] = [], clock: SimpleClock = MockSimpleClock() - ) -> RoomLifecycleManager { - .init( + ) async -> RoomLifecycleManager { + await .init( testsOnly_current: current, + testsOnly_hasOperationInProgress: hasOperationInProgress, + testsOnly_pendingDiscontinuityEvents: pendingDiscontinuityEvents, contributors: contributors, logger: TestLogger(), clock: clock @@ -45,7 +49,7 @@ struct RoomLifecycleManagerTests { feature: RoomFeature = .messages, // Arbitrarily chosen, its value only matters in test cases where we check which error is thrown attachBehavior: MockRoomLifecycleContributorChannel.AttachOrDetachBehavior? = nil, detachBehavior: MockRoomLifecycleContributorChannel.AttachOrDetachBehavior? = nil - ) -> RoomLifecycleManager.Contributor { + ) -> MockRoomLifecycleContributor { .init( feature: feature, channel: .init( @@ -56,20 +60,28 @@ struct RoomLifecycleManagerTests { ) } + /// Given a room lifecycle manager and a channel state change, this method will return once the manager has performed all of the side effects that it will perform as a result of receiving this state change. You can provide a function which will be called after ``waitForManager`` has started listening for the manager’s “state change handled” notifications. + func waitForManager(_ manager: RoomLifecycleManager, toHandleContributorStateChange stateChange: ARTChannelStateChange, during action: () async -> Void) async { + let subscription = await manager.testsOnly_subscribeToHandledContributorStateChanges() + async let handledSignal = subscription.first { $0 === stateChange } + await action() + _ = await handledSignal + } + // MARK: - Initial state // @spec CHA-RS2a // @spec CHA-RS3 @Test func current_startsAsInitialized() async { - let manager = createManager() + let manager = await createManager() #expect(await manager.current == .initialized) } @Test func error_startsAsNil() async { - let manager = createManager() + let manager = await createManager() #expect(await manager.error == nil) } @@ -81,7 +93,7 @@ struct RoomLifecycleManagerTests { func attach_whenAlreadyAttached() async throws { // Given: A RoomLifecycleManager in the ATTACHED state let contributor = createContributor() - let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .attached, contributors: [contributor]) + let manager = await createManager(forTestingWhatHappensWhenCurrentlyIn: .attached, contributors: [contributor]) // When: `performAttachOperation()` is called on the lifecycle manager try await manager.performAttachOperation() @@ -94,7 +106,7 @@ struct RoomLifecycleManagerTests { @Test func attach_whenReleasing() async throws { // Given: A RoomLifecycleManager in the RELEASING state - let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .releasing) + let manager = await createManager(forTestingWhatHappensWhenCurrentlyIn: .releasing) // When: `performAttachOperation()` is called on the lifecycle manager // Then: It throws a roomIsReleasing error @@ -109,7 +121,7 @@ struct RoomLifecycleManagerTests { @Test func attach_whenReleased() async throws { // Given: A RoomLifecycleManager in the RELEASED state - let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .released) + let manager = await createManager(forTestingWhatHappensWhenCurrentlyIn: .released) // When: `performAttachOperation()` is called on the lifecycle manager // Then: It throws a roomIsReleased error @@ -126,7 +138,7 @@ struct RoomLifecycleManagerTests { // Given: A RoomLifecycleManager, with a contributor on whom calling `attach()` will not complete until after the "Then" part of this test (the motivation for this is to suppress the room from transitioning to ATTACHED, so that we can assert its current state as being ATTACHING) let contributorAttachOperation = SignallableChannelOperation() - let manager = createManager(contributors: [createContributor(attachBehavior: contributorAttachOperation.behavior)]) + let manager = await createManager(contributors: [createContributor(attachBehavior: contributorAttachOperation.behavior)]) let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) async let statusChange = statusChangeSubscription.first { _ in true } @@ -148,7 +160,7 @@ struct RoomLifecycleManagerTests { func attach_attachesAllContributors_andWhenTheyAllAttachSuccessfully_transitionsToAttached() async throws { // Given: A RoomLifecycleManager, all of whose contributors’ calls to `attach` succeed let contributors = (1 ... 3).map { _ in createContributor(attachBehavior: .complete(.success)) } - let manager = createManager(contributors: contributors) + let manager = await createManager(contributors: contributors) let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) async let attachedStatusChange = statusChangeSubscription.first { $0.current == .attached } @@ -165,6 +177,40 @@ struct RoomLifecycleManagerTests { try #require(await manager.current == .attached) } + // @spec CHA-RL1g2 + @Test + func attach_uponSuccess_emitsPendingDiscontinuityEvents() async throws { + // Given: A RoomLifecycleManager, all of whose contributors’ calls to `attach` succeed + let contributors = (1 ... 3).map { _ in createContributor(attachBehavior: .complete(.success)) } + let pendingDiscontinuityEvents: [MockRoomLifecycleContributor.ID: [ARTErrorInfo]] = [ + contributors[1].id: [.init(domain: "SomeDomain", code: 123) /* arbitrary */ ], + contributors[2].id: [.init(domain: "SomeDomain", code: 456) /* arbitrary */ ], + ] + let manager = await createManager( + forTestingWhatHappensWhenHasPendingDiscontinuityEvents: pendingDiscontinuityEvents, + contributors: contributors + ) + + // When: `performAttachOperation()` is called on the lifecycle manager + try await manager.performAttachOperation() + + // Then: It: + // - emits all pending discontinuities to its contributors + // - clears all pending discontinuity events (TODO: I assume this is the intended behaviour, but confirm; have asked in https://github.com/ably/specification/pull/200/files#r1781917231) + for contributor in contributors { + let expectedPendingDiscontinuityEvents = pendingDiscontinuityEvents[contributor.id] ?? [] + let emitDiscontinuityArguments = await contributor.emitDiscontinuityArguments + try #require(emitDiscontinuityArguments.count == expectedPendingDiscontinuityEvents.count) + for (emitDiscontinuityArgument, expectedArgument) in zip(emitDiscontinuityArguments, expectedPendingDiscontinuityEvents) { + #expect(emitDiscontinuityArgument === expectedArgument) + } + } + + for contributor in contributors { + #expect(await manager.testsOnly_pendingDiscontinuityEvents(for: contributor).isEmpty) + } + } + // @spec CHA-RL1h2 // @specOneOf(1/2) CHA-RL1h1 - tests that an error gets thrown when channel attach fails due to entering SUSPENDED (TODO: but I don’t yet fully understand the meaning of CHA-RL1h1; outstanding question https://github.com/ably/specification/pull/200/files#r1765476610) // @specPartial CHA-RL1h3 - Have tested the failure of the operation and the error that’s thrown. Have not yet implemented the "enter the recovery loop" (TODO: https://github.com/ably-labs/ably-chat-swift/issues/50) @@ -180,7 +226,7 @@ struct RoomLifecycleManagerTests { } } - let manager = createManager(contributors: contributors) + let manager = await createManager(contributors: contributors) let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) async let maybeSuspendedStatusChange = statusChangeSubscription.first { $0.current == .suspended } @@ -231,7 +277,7 @@ struct RoomLifecycleManagerTests { } } - let manager = createManager(contributors: contributors) + let manager = await createManager(contributors: contributors) let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) async let maybeFailedStatusChange = statusChangeSubscription.first { $0.current == .failed } @@ -282,7 +328,7 @@ struct RoomLifecycleManagerTests { ), ] - let manager = createManager(contributors: contributors) + let manager = await createManager(contributors: contributors) // When: `performAttachOperation()` is called on the lifecycle manager try? await manager.performAttachOperation() @@ -324,7 +370,7 @@ struct RoomLifecycleManagerTests { ), ] - let manager = createManager(contributors: contributors) + let manager = await createManager(contributors: contributors) // When: `performAttachOperation()` is called on the lifecycle manager try? await manager.performAttachOperation() @@ -340,7 +386,7 @@ struct RoomLifecycleManagerTests { func detach_whenAlreadyDetached() async throws { // Given: A RoomLifecycleManager in the DETACHED state let contributor = createContributor() - let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .detached, contributors: [contributor]) + let manager = await createManager(forTestingWhatHappensWhenCurrentlyIn: .detached, contributors: [contributor]) // When: `performDetachOperation()` is called on the lifecycle manager try await manager.performDetachOperation() @@ -353,7 +399,7 @@ struct RoomLifecycleManagerTests { @Test func detach_whenReleasing() async throws { // Given: A RoomLifecycleManager in the RELEASING state - let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .releasing) + let manager = await createManager(forTestingWhatHappensWhenCurrentlyIn: .releasing) // When: `performDetachOperation()` is called on the lifecycle manager // Then: It throws a roomIsReleasing error @@ -368,7 +414,7 @@ struct RoomLifecycleManagerTests { @Test func detach_whenReleased() async throws { // Given: A RoomLifecycleManager in the RELEASED state - let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .released) + let manager = await createManager(forTestingWhatHappensWhenCurrentlyIn: .released) // When: `performAttachOperation()` is called on the lifecycle manager // Then: It throws a roomIsReleased error @@ -383,7 +429,7 @@ struct RoomLifecycleManagerTests { @Test func detach_whenFailed() async throws { // Given: A RoomLifecycleManager in the FAILED state - let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .failed) + let manager = await createManager(forTestingWhatHappensWhenCurrentlyIn: .failed) // When: `performAttachOperation()` is called on the lifecycle manager // Then: It throws a roomInFailedState error @@ -400,7 +446,7 @@ struct RoomLifecycleManagerTests { // Given: A RoomLifecycleManager, with a contributor on whom calling `detach()` will not complete until after the "Then" part of this test (the motivation for this is to suppress the room from transitioning to DETACHED, so that we can assert its current state as being DETACHING) let contributorDetachOperation = SignallableChannelOperation() - let manager = createManager(contributors: [createContributor(detachBehavior: contributorDetachOperation.behavior)]) + let manager = await createManager(contributors: [createContributor(detachBehavior: contributorDetachOperation.behavior)]) let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) async let statusChange = statusChangeSubscription.first { _ in true } @@ -421,7 +467,7 @@ struct RoomLifecycleManagerTests { func detach_detachesAllContributors_andWhenTheyAllDetachSuccessfully_transitionsToDetached() async throws { // Given: A RoomLifecycleManager, all of whose contributors’ calls to `detach` succeed let contributors = (1 ... 3).map { _ in createContributor(detachBehavior: .complete(.success)) } - let manager = createManager(contributors: contributors) + let manager = await createManager(contributors: contributors) let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) async let detachedStatusChange = statusChangeSubscription.first { $0.current == .detached } @@ -458,7 +504,7 @@ struct RoomLifecycleManagerTests { createContributor(feature: .typing, detachBehavior: .success), ] - let manager = createManager(contributors: contributors) + let manager = await createManager(contributors: contributors) let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) async let maybeFailedStatusChange = statusChangeSubscription.first { $0.current == .failed } @@ -505,7 +551,7 @@ struct RoomLifecycleManagerTests { let contributor = createContributor(initialState: .attached, detachBehavior: .fromFunction(detachImpl)) let clock = MockSimpleClock() - let manager = createManager(contributors: [contributor], clock: clock) + let manager = await createManager(contributors: [contributor], clock: clock) let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) async let asyncLetStatusChanges = Array(statusChangeSubscription.prefix(2)) @@ -529,7 +575,7 @@ struct RoomLifecycleManagerTests { func release_whenAlreadyReleased() async { // Given: A RoomLifecycleManager in the RELEASED state let contributor = createContributor() - let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .released, contributors: [contributor]) + let manager = await createManager(forTestingWhatHappensWhenCurrentlyIn: .released, contributors: [contributor]) // When: `performReleaseOperation()` is called on the lifecycle manager await manager.performReleaseOperation() @@ -543,7 +589,7 @@ struct RoomLifecycleManagerTests { func release_whenDetached() async throws { // Given: A RoomLifecycleManager in the DETACHED state let contributor = createContributor() - let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .detached, contributors: [contributor]) + let manager = await createManager(forTestingWhatHappensWhenCurrentlyIn: .detached, contributors: [contributor]) let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) async let statusChange = statusChangeSubscription.first { _ in true } @@ -563,7 +609,7 @@ struct RoomLifecycleManagerTests { // Given: A RoomLifecycleManager, with a contributor on whom calling `detach()` will not complete until after the "Then" part of this test (the motivation for this is to suppress the room from transitioning to RELEASED, so that we can assert its current state as being RELEASING) let contributorDetachOperation = SignallableChannelOperation() - let manager = createManager(contributors: [createContributor(detachBehavior: contributorDetachOperation.behavior)]) + let manager = await createManager(contributors: [createContributor(detachBehavior: contributorDetachOperation.behavior)]) let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) async let statusChange = statusChangeSubscription.first { _ in true } @@ -593,7 +639,7 @@ struct RoomLifecycleManagerTests { createContributor(initialState: .detached /* arbitrary non-FAILED */, detachBehavior: .complete(.success)), ] - let manager = createManager(contributors: contributors) + let manager = await createManager(contributors: contributors) let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) async let releasedStatusChange = statusChangeSubscription.first { $0.current == .released } @@ -633,7 +679,7 @@ struct RoomLifecycleManagerTests { let clock = MockSimpleClock() - let manager = createManager(contributors: [contributor], clock: clock) + let manager = await createManager(contributors: [contributor], clock: clock) // Then: When `performReleaseOperation()` is called on the manager await manager.performReleaseOperation() @@ -653,7 +699,7 @@ struct RoomLifecycleManagerTests { let clock = MockSimpleClock() - let manager = createManager(contributors: [contributor], clock: clock) + let manager = await createManager(contributors: [contributor], clock: clock) let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) async let releasedStatusChange = statusChangeSubscription.first { $0.current == .released } @@ -675,4 +721,249 @@ struct RoomLifecycleManagerTests { #expect(await manager.current == .released) } + + // MARK: - Handling contributor UPDATE events + + // @spec CHA-RL4a1 + @Test + func contributorUpdate_withResumedTrue_doesNothing() async throws { + // Given: A RoomLifecycleManager + let contributor = createContributor() + let manager = await createManager(contributors: [contributor]) + + // When: A contributor emits an UPDATE event with `resumed` flag set to true + let contributorStateChange = ARTChannelStateChange( + current: .attached, // arbitrary + previous: .attached, // arbitrary + event: .update, + reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary + resumed: true + ) + + await waitForManager(manager, toHandleContributorStateChange: contributorStateChange) { + await contributor.channel.emitStateChange(contributorStateChange) + } + + // Then: The manager does not record a pending discontinuity event for this contributor, nor does it call `emitDiscontinuity` on the contributor (this is my interpretation of "no action should be taken" in CHA-RL4a1; i.e. that the actions described in CHA-RL4a2 and CHA-RL4a3 shouldn’t happen) (TODO: get clarification; have asked in https://github.com/ably/specification/pull/200#discussion_r1777385499) + #expect(await manager.testsOnly_pendingDiscontinuityEvents(for: contributor).isEmpty) + #expect(await contributor.emitDiscontinuityArguments.isEmpty) + } + + // @spec CHA-RL4a3 + @Test + func contributorUpdate_withResumedFalse_withOperationInProgress_recordsPendingDiscontinuityEvent() async throws { + // Given: A RoomLifecycleManager, with a room lifecycle operation in progress + let contributor = createContributor() + let manager = await createManager(forTestingWhatHappensWhenHasOperationInProgress: true, contributors: [contributor]) + + // When: A contributor emits an UPDATE event with `resumed` flag set to false + let contributorStateChange = ARTChannelStateChange( + current: .attached, // arbitrary + previous: .attached, // arbitrary + event: .update, + reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary + resumed: false + ) + + await waitForManager(manager, toHandleContributorStateChange: contributorStateChange) { + await contributor.channel.emitStateChange(contributorStateChange) + } + + // Then: The manager records a pending discontinuity event for this contributor, and this discontinuity event has error equal to the contributor UPDATE event’s `reason` + let pendingDiscontinuityEvents = await manager.testsOnly_pendingDiscontinuityEvents(for: contributor) + try #require(pendingDiscontinuityEvents.count == 1) + + let pendingDiscontinuityEvent = pendingDiscontinuityEvents[0] + #expect(pendingDiscontinuityEvent === contributorStateChange.reason) + } + + // @spec CHA-RL4a4 + @Test + func contributorUpdate_withResumedTrue_withNoOperationInProgress_emitsDiscontinuityEvent() async throws { + // Given: A RoomLifecycleManager, with no room lifecycle operation in progress + let contributor = createContributor() + let manager = await createManager(forTestingWhatHappensWhenHasOperationInProgress: false, contributors: [contributor]) + + // When: A contributor emits an UPDATE event with `resumed` flag set to false + let contributorStateChange = ARTChannelStateChange( + current: .attached, // arbitrary + previous: .attached, // arbitrary + event: .update, + reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary + resumed: false + ) + + await waitForManager(manager, toHandleContributorStateChange: contributorStateChange) { + await contributor.channel.emitStateChange(contributorStateChange) + } + + // Then: The manager calls `emitDiscontinuity` on the contributor, with error equal to the contributor UPDATE event’s `reason` + let emitDiscontinuityArguments = await contributor.emitDiscontinuityArguments + try #require(emitDiscontinuityArguments.count == 1) + + let discontinuity = emitDiscontinuityArguments[0] + #expect(discontinuity === contributorStateChange.reason) + } + + // @specPartial CHA-RL4b1 - I don’t know the meaning of "and the particular contributor has been attached previously" so haven’t implemented that part of the spec point (TODO: asked in https://github.com/ably/specification/pull/200/files#r1775552624) + @Test + func contributorAttachEvent_withResumeFalse_withOperationInProgress_recordsPendingDiscontinuityEvent() async throws { + // Given: A RoomLifecycleManager, with a room lifecycle operation in progress + let contributor = createContributor() + let manager = await createManager(forTestingWhatHappensWhenHasOperationInProgress: true, contributors: [contributor]) + + // When: A contributor emits an ATTACHED event with `resumed` flag set to false + let contributorStateChange = ARTChannelStateChange( + current: .attached, + previous: .attaching, // arbitrary + event: .attached, + reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary + resumed: false + ) + + await waitForManager(manager, toHandleContributorStateChange: contributorStateChange) { + await contributor.channel.emitStateChange(contributorStateChange) + } + + // Then: The manager records a pending discontinuity event for this contributor, and this discontinuity event has error equal to the contributor ATTACHED event’s `reason` + let pendingDiscontinuityEvents = await manager.testsOnly_pendingDiscontinuityEvents(for: contributor) + try #require(pendingDiscontinuityEvents.count == 1) + + let pendingDiscontinuityEvent = pendingDiscontinuityEvents[0] + #expect(pendingDiscontinuityEvent === contributorStateChange.reason) + } + + // @specPartial CHA-RL4b5 - Haven’t implemented the part that refers to "transient disconnect timeouts"; TODO do this (https://github.com/ably-labs/ably-chat-swift/issues/48) + @Test + func contributorFailedEvent_withNoOperationInProgress() async throws { + // Given: A RoomLifecycleManager, with no room lifecycle operation in progress + let contributors = [ + // TODO: The .success is currently arbitrary since the spec doesn’t say what to do if detach fails (have asked in https://github.com/ably/specification/pull/200#discussion_r1777471810) + createContributor(detachBehavior: .success), + createContributor(detachBehavior: .success), + ] + let manager = await createManager(forTestingWhatHappensWhenHasOperationInProgress: false, contributors: contributors) + + let roomStatusSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let failedStatusChange = roomStatusSubscription.first { $0.current == .failed } + + // When: A contributor emits an FAILED event + let contributorStateChange = ARTChannelStateChange( + current: .failed, + previous: .attached, // arbitrary + event: .failed, + reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary + resumed: false // arbitrary + ) + + await waitForManager(manager, toHandleContributorStateChange: contributorStateChange) { + await contributors[0].channel.emitStateChange(contributorStateChange) + } + + // Then: + // - the room status transitions to failed, with the error of the status change being the `reason` of the contributor FAILED event + // - and it calls `detach` on all contributors + _ = try #require(await failedStatusChange) + #expect(await manager.current == .failed) + + for contributor in contributors { + #expect(await contributor.channel.detachCallCount == 1) + } + } + + // @specOneOf(1/2) CHA-RL4b8 + @Test + func contributorAttachedEvent_withNoOperationInProgress_roomNotAttached_allContributorsAttached() async throws { + // Given: A RoomLifecycleManager, not in the ATTACHED state, all of whose contributors are in the ATTACHED state (to satisfy the condition of CHA-RL4b8; for the purposes of this test I don’t care that they’re in this state even _before_ the state change of the When) + let contributors = [ + createContributor(initialState: .attached), + createContributor(initialState: .attached), + ] + + let manager = await createManager( + forTestingWhatHappensWhenCurrentlyIn: .initialized, // arbitrary non-ATTACHED + forTestingWhatHappensWhenHasOperationInProgress: false, + contributors: contributors + ) + + let roomStatusSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let maybeAttachedRoomStatusChange = roomStatusSubscription.first { $0.current == .attached } + + // When: A contributor emits a state change to ATTACHED + let contributorStateChange = ARTChannelStateChange( + current: .attached, + previous: .attaching, // arbitrary + event: .attached, + reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary + resumed: false // arbitrary + ) + + await contributors[0].channel.emitStateChange(contributorStateChange) + + // Then: The room status transitions to ATTACHED + _ = try #require(await maybeAttachedRoomStatusChange) + #expect(await manager.current == .attached) + } + + // @specOneOf(2/2) CHA-RL4b8 - Tests that the specified side effect doesn’t happen if part of the condition (i.e. all contributors now being ATTACHED) is not met + @Test + func contributorAttachedEvent_withNoOperationInProgress_roomNotAttached_notAllContributorsAttached() async throws { + // Given: A RoomLifecycleManager, not in the ATTACHED state, one of whose contributors is not in the ATTACHED state state (to simulate the condition of CHA-RL4b8 not being met; for the purposes of this test I don’t care that they’re in this state even _before_ the state change of the When) + let contributors = [ + createContributor(initialState: .attached), + createContributor(initialState: .detached), + ] + + let initialManagerState = RoomLifecycle.initialized // arbitrary non-ATTACHED + let manager = await createManager( + forTestingWhatHappensWhenCurrentlyIn: initialManagerState, + forTestingWhatHappensWhenHasOperationInProgress: false, + contributors: contributors + ) + + // When: A contributor emits a state change to ATTACHED + let contributorStateChange = ARTChannelStateChange( + current: .attached, + previous: .attaching, // arbitrary + event: .attached, + reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary + resumed: false // arbitrary + ) + + await waitForManager(manager, toHandleContributorStateChange: contributorStateChange) { + await contributors[0].channel.emitStateChange(contributorStateChange) + } + + // Then: The room status does not change + #expect(await manager.current == initialManagerState) + } + + // @specPartial CHA-RL4b9 - Haven’t implemented the part that refers to "transient disconnect timeouts"; TODO do this (https://github.com/ably-labs/ably-chat-swift/issues/48). Nor have I implemented "the room enters the RETRY loop"; TODO do this (https://github.com/ably-labs/ably-chat-swift/issues/51) + @Test + func contributorSuspendedEvent_withNoOperationInProgress() async throws { + // Given: A RoomLifecycleManager with no lifecycle operation in progress + let contributor = createContributor() + let manager = await createManager(forTestingWhatHappensWhenHasOperationInProgress: false, contributors: [contributor]) + + let roomStatusSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let maybeSuspendedRoomStatusChange = roomStatusSubscription.first { $0.current == .suspended } + + // When: A contributor emits a state change to SUSPENDED + let contributorStateChange = ARTChannelStateChange( + current: .suspended, + previous: .attached, // arbitrary + event: .suspended, + reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary + resumed: false // arbitrary + ) + + await contributor.channel.emitStateChange(contributorStateChange) + + // Then: The room transitions to SUSPENDED, and this state change has error equal to the contributor state change’s `reason` + let suspendedRoomStatusChange = try #require(await maybeSuspendedRoomStatusChange) + #expect(suspendedRoomStatusChange.error === contributorStateChange.reason) + + #expect(await manager.current == .suspended) + #expect(await manager.error === contributorStateChange.reason) + } }