From ac92127bc5f0368353d213c4b657a5eb62a89dd8 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 7 Nov 2024 11:48:04 -0300 Subject: [PATCH 1/7] Integrate lifecycle manager into existing room operations Replace the existing temporary implementations of room attach / detach / status with those provided by the room lifecycle manager. Part of #47. --- Sources/AblyChat/ChatClient.swift | 3 +- .../DefaultRoomLifecycleContributor.swift | 48 ++++ Sources/AblyChat/Room.swift | 66 +++--- Sources/AblyChat/RoomFeature.swift | 6 +- Sources/AblyChat/RoomLifecycleManager.swift | 52 ++++- Sources/AblyChat/Rooms.swift | 10 +- Sources/AblyChat/SimpleClock.swift | 6 + .../DefaultChatClientTests.swift | 2 +- Tests/AblyChatTests/DefaultRoomTests.swift | 216 ++++++++---------- Tests/AblyChatTests/DefaultRoomsTests.swift | 14 +- .../Mocks/MockRoomLifecycleManager.swift | 53 +++++ .../MockRoomLifecycleManagerFactory.swift | 13 ++ 12 files changed, 302 insertions(+), 187 deletions(-) create mode 100644 Sources/AblyChat/DefaultRoomLifecycleContributor.swift create mode 100644 Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift create mode 100644 Tests/AblyChatTests/Mocks/MockRoomLifecycleManagerFactory.swift diff --git a/Sources/AblyChat/ChatClient.swift b/Sources/AblyChat/ChatClient.swift index 917a22f1..2e657eee 100644 --- a/Sources/AblyChat/ChatClient.swift +++ b/Sources/AblyChat/ChatClient.swift @@ -20,7 +20,8 @@ public actor DefaultChatClient: ChatClient { self.realtime = realtime self.clientOptions = clientOptions ?? .init() logger = DefaultInternalLogger(logHandler: self.clientOptions.logHandler, logLevel: self.clientOptions.logLevel) - rooms = DefaultRooms(realtime: realtime, clientOptions: self.clientOptions, logger: logger) + let roomLifecycleManagerFactory = DefaultRoomLifecycleManagerFactory() + rooms = DefaultRooms(realtime: realtime, clientOptions: self.clientOptions, logger: logger, lifecycleManagerFactory: roomLifecycleManagerFactory) } public nonisolated var connection: any Connection { diff --git a/Sources/AblyChat/DefaultRoomLifecycleContributor.swift b/Sources/AblyChat/DefaultRoomLifecycleContributor.swift new file mode 100644 index 00000000..a236061b --- /dev/null +++ b/Sources/AblyChat/DefaultRoomLifecycleContributor.swift @@ -0,0 +1,48 @@ +import Ably + +internal actor DefaultRoomLifecycleContributor: RoomLifecycleContributor { + internal let channel: DefaultRoomLifecycleContributorChannel + internal let feature: RoomFeature + + internal init(channel: DefaultRoomLifecycleContributorChannel, feature: RoomFeature) { + self.channel = channel + self.feature = feature + } + + // MARK: - Discontinuities + + internal func emitDiscontinuity(_: ARTErrorInfo) { + // TODO: https://github.com/ably-labs/ably-chat-swift/issues/47 + } +} + +internal final class DefaultRoomLifecycleContributorChannel: RoomLifecycleContributorChannel { + private let underlyingChannel: any RealtimeChannelProtocol + + internal init(underlyingChannel: any RealtimeChannelProtocol) { + self.underlyingChannel = underlyingChannel + } + + internal func attach() async throws(ARTErrorInfo) { + try await underlyingChannel.attachAsync() + } + + internal func detach() async throws(ARTErrorInfo) { + try await underlyingChannel.detachAsync() + } + + internal var state: ARTRealtimeChannelState { + underlyingChannel.state + } + + internal var errorReason: ARTErrorInfo? { + underlyingChannel.errorReason + } + + internal func subscribeToState() async -> Subscription { + // TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36) + let subscription = Subscription(bufferingPolicy: .unbounded) + underlyingChannel.on { subscription.emit($0) } + return subscription + } +} diff --git a/Sources/AblyChat/Room.swift b/Sources/AblyChat/Room.swift index f096f3d7..6aa09379 100644 --- a/Sources/AblyChat/Room.swift +++ b/Sources/AblyChat/Room.swift @@ -19,7 +19,7 @@ public protocol Room: AnyObject, Sendable { var options: RoomOptions { get } } -public struct RoomStatusChange: Sendable { +public struct RoomStatusChange: Sendable, Equatable { public var current: RoomStatus public var previous: RoomStatus @@ -29,7 +29,7 @@ public struct RoomStatusChange: Sendable { } } -internal actor DefaultRoom: Room { +internal actor DefaultRoom: Room where LifecycleManagerFactory.Contributor == DefaultRoomLifecycleContributor { internal nonisolated let roomID: String internal nonisolated let options: RoomOptions private let chatAPI: ChatAPI @@ -39,8 +39,7 @@ internal actor DefaultRoom: Room { // Exposed for testing. private nonisolated let realtime: RealtimeClient - /// The channels that contribute to this room. - private let channels: [RoomFeature: RealtimeChannelProtocol] + private let lifecycleManager: any RoomLifecycleManager #if DEBUG internal nonisolated var testsOnly_realtime: RealtimeClient { @@ -48,12 +47,9 @@ internal actor DefaultRoom: Room { } #endif - internal private(set) var status: RoomStatus = .initialized - // TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36) - private var statusSubscriptions: [Subscription] = [] private let logger: InternalLogger - internal init(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger) async throws { + internal init(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger, lifecycleManagerFactory: LifecycleManagerFactory) async throws { self.realtime = realtime self.roomID = roomID self.options = options @@ -64,7 +60,13 @@ internal actor DefaultRoom: Room { throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.") } - channels = Self.createChannels(roomID: roomID, realtime: realtime) + let channels = Self.createChannels(roomID: roomID, realtime: realtime) + let contributors = Self.createContributors(channels: channels) + + lifecycleManager = await lifecycleManagerFactory.createManager( + contributors: contributors, + logger: logger + ) messages = await DefaultMessages( channel: channels[.messages]!, @@ -75,12 +77,20 @@ internal actor DefaultRoom: Room { } private static func createChannels(roomID: String, realtime: RealtimeClient) -> [RoomFeature: RealtimeChannelProtocol] { - .init(uniqueKeysWithValues: [RoomFeature.messages, RoomFeature.typing, RoomFeature.reactions].map { feature in + .init(uniqueKeysWithValues: [RoomFeature.messages].map { feature in let channel = realtime.getChannel(feature.channelNameForRoomID(roomID)) + return (feature, channel) }) } + private static func createContributors(channels: [RoomFeature: RealtimeChannelProtocol]) -> [DefaultRoomLifecycleContributor] { + channels.map { entry in + let (feature, channel) = entry + return .init(channel: .init(underlyingChannel: channel), feature: feature) + } + } + public nonisolated var presence: any Presence { fatalError("Not yet implemented") } @@ -98,44 +108,22 @@ internal actor DefaultRoom: Room { } public func attach() async throws { - for channel in channels.map(\.value) { - do { - try await channel.attachAsync() - } catch { - logger.log(message: "Failed to attach channel \(channel), error \(error)", level: .error) - throw error - } - } - transition(to: .attached) + try await lifecycleManager.performAttachOperation() } public func detach() async throws { - for channel in channels.map(\.value) { - do { - try await channel.detachAsync() - } catch { - logger.log(message: "Failed to detach channel \(channel), error \(error)", level: .error) - throw error - } - } - transition(to: .detached) + try await lifecycleManager.performDetachOperation() } // MARK: - Room status - internal func onStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription { - let subscription: Subscription = .init(bufferingPolicy: bufferingPolicy) - statusSubscriptions.append(subscription) - return subscription + internal func onStatusChange(bufferingPolicy: BufferingPolicy) async -> Subscription { + await lifecycleManager.onChange(bufferingPolicy: bufferingPolicy) } - /// Sets ``status`` to the given status, and emits a status change to all subscribers added via ``onStatusChange(bufferingPolicy:)``. - internal func transition(to newStatus: RoomStatus) { - logger.log(message: "Transitioning to \(newStatus)", level: .debug) - let statusChange = RoomStatusChange(current: newStatus, previous: status) - status = newStatus - for subscription in statusSubscriptions { - subscription.emit(statusChange) + internal var status: RoomStatus { + get async { + await lifecycleManager.roomStatus } } } diff --git a/Sources/AblyChat/RoomFeature.swift b/Sources/AblyChat/RoomFeature.swift index 2a570196..e2fb70fc 100644 --- a/Sources/AblyChat/RoomFeature.swift +++ b/Sources/AblyChat/RoomFeature.swift @@ -15,11 +15,7 @@ internal enum RoomFeature { case .messages: // (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. "chatMessages" - case .typing: - "typingIndicators" - case .reactions: - "reactions" - case .presence, .occupancy: + case .typing, .reactions, .presence, .occupancy: // We’ll add these, with reference to the relevant spec points, as we implement these features fatalError("Don’t know channel name suffix for room feature \(self)") } diff --git a/Sources/AblyChat/RoomLifecycleManager.swift b/Sources/AblyChat/RoomLifecycleManager.swift index 7b0e8e59..44aec8ee 100644 --- a/Sources/AblyChat/RoomLifecycleManager.swift +++ b/Sources/AblyChat/RoomLifecycleManager.swift @@ -40,7 +40,37 @@ internal protocol RoomLifecycleContributor: Identifiable, Sendable { func emitDiscontinuity(_ error: ARTErrorInfo) async } -internal protocol RoomLifecycleManager: Sendable {} +internal protocol RoomLifecycleManager: Sendable { + func performAttachOperation() async throws + func performDetachOperation() async throws + var roomStatus: RoomStatus { get async } + func onChange(bufferingPolicy: BufferingPolicy) async -> Subscription +} + +internal protocol RoomLifecycleManagerFactory: Sendable { + associatedtype Contributor: RoomLifecycleContributor + associatedtype Manager: RoomLifecycleManager + + func createManager( + contributors: [Contributor], + logger: InternalLogger + ) async -> Manager +} + +internal final class DefaultRoomLifecycleManagerFactory: RoomLifecycleManagerFactory { + private let clock = DefaultSimpleClock() + + internal func createManager( + contributors: [DefaultRoomLifecycleContributor], + logger: InternalLogger + ) async -> DefaultRoomLifecycleManager { + await .init( + contributors: contributors, + logger: logger, + clock: clock + ) + } +} internal actor DefaultRoomLifecycleManager: RoomLifecycleManager { // MARK: - Constant properties @@ -615,11 +645,19 @@ internal actor DefaultRoomLifecycleManager: Rooms where LifecycleManagerFactory.Contributor == DefaultRoomLifecycleContributor { private nonisolated let realtime: RealtimeClient private let chatAPI: ChatAPI @@ -19,14 +19,16 @@ internal actor DefaultRooms: Rooms { internal nonisolated let clientOptions: ClientOptions private let logger: InternalLogger + private let lifecycleManagerFactory: LifecycleManagerFactory /// The set of rooms, keyed by room ID. - private var rooms: [String: DefaultRoom] = [:] + private var rooms: [String: DefaultRoom] = [:] - internal init(realtime: RealtimeClient, clientOptions: ClientOptions, logger: InternalLogger) { + internal init(realtime: RealtimeClient, clientOptions: ClientOptions, logger: InternalLogger, lifecycleManagerFactory: LifecycleManagerFactory) { self.realtime = realtime self.clientOptions = clientOptions self.logger = logger + self.lifecycleManagerFactory = lifecycleManagerFactory chatAPI = ChatAPI(realtime: realtime) } @@ -41,7 +43,7 @@ internal actor DefaultRooms: Rooms { return existingRoom } else { - let room = try await DefaultRoom(realtime: realtime, chatAPI: chatAPI, roomID: roomID, options: options, logger: logger) + let room = try await DefaultRoom(realtime: realtime, chatAPI: chatAPI, roomID: roomID, options: options, logger: logger, lifecycleManagerFactory: lifecycleManagerFactory) rooms[roomID] = room return room } diff --git a/Sources/AblyChat/SimpleClock.swift b/Sources/AblyChat/SimpleClock.swift index a0218fdb..e563ffbd 100644 --- a/Sources/AblyChat/SimpleClock.swift +++ b/Sources/AblyChat/SimpleClock.swift @@ -7,3 +7,9 @@ internal protocol SimpleClock: Sendable { /// Behaves like `Task.sleep(nanoseconds:)`. Uses seconds instead of nanoseconds for readability at call site (we have no need for that level of precision). func sleep(timeInterval: TimeInterval) async throws } + +internal final class DefaultSimpleClock: SimpleClock { + internal func sleep(timeInterval: TimeInterval) async throws { + try await Task.sleep(nanoseconds: UInt64(timeInterval * Double(NSEC_PER_SEC))) + } +} diff --git a/Tests/AblyChatTests/DefaultChatClientTests.swift b/Tests/AblyChatTests/DefaultChatClientTests.swift index 569322c4..e433e837 100644 --- a/Tests/AblyChatTests/DefaultChatClientTests.swift +++ b/Tests/AblyChatTests/DefaultChatClientTests.swift @@ -22,7 +22,7 @@ struct DefaultChatClientTests { // Then: Its `rooms` property returns an instance of DefaultRooms with the same realtime client and client options let rooms = client.rooms - let defaultRooms = try #require(rooms as? DefaultRooms) + let defaultRooms = try #require(rooms as? DefaultRooms) #expect(defaultRooms.testsOnly_realtime === realtime) #expect(defaultRooms.clientOptions.isEqualForTestPurposes(options)) } diff --git a/Tests/AblyChatTests/DefaultRoomTests.swift b/Tests/AblyChatTests/DefaultRoomTests.swift index 35b98e2f..88135491 100644 --- a/Tests/AblyChatTests/DefaultRoomTests.swift +++ b/Tests/AblyChatTests/DefaultRoomTests.swift @@ -11,12 +11,10 @@ struct DefaultRoomTests { // Given: a DefaultRoom instance let channelsList = [ MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", attachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .success), ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) - let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(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(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory()) // Then #expect(room.messages.channel.name == "basketball::$chat::$chatMessages") @@ -24,174 +22,144 @@ struct DefaultRoomTests { // MARK: - Attach - @Test - func attach_attachesAllChannels_andSucceedsIfAllSucceed() async throws { - // Given: a DefaultRoom instance with ID "basketball", with a Realtime client for which `attach(_:)` completes successfully if called on the following channels: - // - // - basketball::$chat::$chatMessages - // - basketball::$chat::$typingIndicators - // - basketball::$chat::$reactions + @Test( + arguments: [ + .success(()), + .failure(ARTErrorInfo.createUnknownError() /* arbitrary */ ), + ] as[Result] + ) + func attach(managerAttachResult: Result) async throws { + // Given: a DefaultRoom instance let channelsList = [ MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", attachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .success), ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) - let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger()) - let subscription = await room.onStatusChange(bufferingPolicy: .unbounded) - async let attachedStatusChange = subscription.first { $0.current == .attached } + let lifecycleManager = MockRoomLifecycleManager(attachResult: managerAttachResult) + let lifecycleManagerFactory = MockRoomLifecycleManagerFactory(manager: lifecycleManager) - // When: `attach` is called on the room - try await room.attach() + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger(), lifecycleManagerFactory: lifecycleManagerFactory) - // Then: `attach(_:)` is called on each of the channels, the room `attach` call succeeds, and the room transitions to ATTACHED - for channel in channelsList { - #expect(channel.attachCallCounter.isNonZero) + // When: `attach()` is called on the room + let result = await Result { () async throws(ARTErrorInfo) in + do { + try await room.attach() + } catch { + // swiftlint:disable:next force_cast + throw error as! ARTErrorInfo + } } - #expect(await room.status == .attached) - #expect(try #require(await attachedStatusChange).current == .attached) + // Then: It calls through to the `performAttachOperation()` method on the room lifecycle manager + #expect(Result.areIdentical(result, managerAttachResult)) + #expect(await lifecycleManager.attachCallCount == 1) } - @Test - func attach_attachesAllChannels_andFailsIfOneFails() async throws { - // Given: a DefaultRoom instance, with a Realtime client for which `attach(_:)` completes successfully if called on the following channels: - // - // - basketball::$chat::$chatMessages - // - basketball::$chat::$typingIndicators - // - // and fails when called on channel basketball::$chat::$reactions - let channelAttachError = ARTErrorInfo.createUnknownError() // arbitrary + // MARK: - Detach + + @Test( + arguments: [ + .success(()), + .failure(ARTErrorInfo.createUnknownError() /* arbitrary */ ), + ] as[Result] + ) + func detach(managerDetachResult: Result) async throws { + // Given: a DefaultRoom instance let channelsList = [ - MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", attachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .failure(channelAttachError)), + MockRealtimeChannel(name: "basketball::$chat::$chatMessages", detachResult: .success), ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) - 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? - do { - try await room.attach() - roomAttachError = nil - } catch { - roomAttachError = error + let lifecycleManager = MockRoomLifecycleManager(detachResult: managerDetachResult) + let lifecycleManagerFactory = MockRoomLifecycleManagerFactory(manager: lifecycleManager) + + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger(), lifecycleManagerFactory: lifecycleManagerFactory) + + // When: `detach()` is called on the room + let result = await Result { () async throws(ARTErrorInfo) in + do { + try await room.detach() + } catch { + // swiftlint:disable:next force_cast + throw error as! ARTErrorInfo + } } - // Then: the room `attach` call fails with the same error as the channel `attach(_:)` call - #expect(try #require(roomAttachError as? ARTErrorInfo) === channelAttachError) + // Then: It calls through to the `performDetachOperation()` method on the room lifecycle manager + #expect(Result.areIdentical(result, managerDetachResult)) + #expect(await lifecycleManager.detachCallCount == 1) } - // MARK: - Detach + // MARK: - Room status @Test - func detach_detachesAllChannels_andSucceedsIfAllSucceed() async throws { - // Given: a DefaultRoom instance with ID "basketball", with a Realtime client for which `detach(_:)` completes successfully if called on the following channels: - // - // - basketball::$chat::$chatMessages - // - basketball::$chat::$typingIndicators - // - basketball::$chat::$reactions + func status() async throws { + // Given: a DefaultRoom instance let channelsList = [ MockRealtimeChannel(name: "basketball::$chat::$chatMessages", detachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", detachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$reactions", detachResult: .success), ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) - let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger()) - let subscription = await room.onStatusChange(bufferingPolicy: .unbounded) - async let detachedStatusChange = subscription.first { $0.current == .detached } + let lifecycleManagerRoomStatus = RoomStatus.attached // arbitrary - // When: `detach` is called on the room - try await room.detach() + let lifecycleManager = MockRoomLifecycleManager(roomStatus: lifecycleManagerRoomStatus) + let lifecycleManagerFactory = MockRoomLifecycleManagerFactory(manager: lifecycleManager) - // Then: `detach(_:)` is called on each of the channels, the room `detach` call succeeds, and the room transitions to DETACHED - for channel in channelsList { - #expect(channel.detachCallCounter.isNonZero) - } + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger(), lifecycleManagerFactory: lifecycleManagerFactory) - #expect(await room.status == .detached) - #expect(try #require(await detachedStatusChange).current == .detached) + // Then: The `status` property returns that of the room lifecycle manager + #expect(await room.status == lifecycleManagerRoomStatus) } @Test - func detach_detachesAllChannels_andFailsIfOneFails() async throws { - // Given: a DefaultRoom instance, with a Realtime client for which `detach(_:)` completes successfully if called on the following channels: - // - // - basketball::$chat::$chatMessages - // - basketball::$chat::$typingIndicators - // - // and fails when called on channel basketball::$chat::$reactions - let channelDetachError = ARTErrorInfo.createUnknownError() // arbitrary + func onStatusChange() async throws { + // Given: a DefaultRoom instance let channelsList = [ MockRealtimeChannel(name: "basketball::$chat::$chatMessages", detachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", detachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$reactions", detachResult: .failure(channelDetachError)), ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) - 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? - do { - try await room.detach() - roomDetachError = nil - } catch { - roomDetachError = error - } + let lifecycleManager = MockRoomLifecycleManager() + let lifecycleManagerFactory = MockRoomLifecycleManagerFactory(manager: lifecycleManager) - // Then: the room `detach` call fails with the same error as the channel `detach(_:)` call - #expect(try #require(roomDetachError as? ARTErrorInfo) === channelDetachError) - } + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger(), lifecycleManagerFactory: lifecycleManagerFactory) - // MARK: - Room status + // When: The room lifecycle manager emits a status change through `subscribeToState` + let managerStatusChange = RoomStatusChange(current: .detached, previous: .detaching) // arbitrary + let roomStatusSubscription = await room.onStatusChange(bufferingPolicy: .unbounded) + await lifecycleManager.emitStatusChange(managerStatusChange) - @Test - func status_startsAsInitialized() async throws { - let channelsList = [ - MockRealtimeChannel(name: "basketball::$chat::$chatMessages"), - MockRealtimeChannel(name: "basketball::$chat::$typingIndicators"), - MockRealtimeChannel(name: "basketball::$chat::$reactions"), - ] - let realtime = MockRealtime.create(channels: .init(channels: channelsList)) - let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger()) - #expect(await room.status == .initialized) + // Then: The room emits this status change through `onStatusChange` + let roomStatusChange = try #require(await roomStatusSubscription.first { _ in true }) + #expect(roomStatusChange == managerStatusChange) } +} - @Test - func transition() async throws { - // Given: A DefaultRoom - let channelsList = [ - MockRealtimeChannel(name: "basketball::$chat::$chatMessages"), - MockRealtimeChannel(name: "basketball::$chat::$typingIndicators"), - MockRealtimeChannel(name: "basketball::$chat::$reactions"), - ] - let realtime = MockRealtime.create(channels: .init(channels: channelsList)) - let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger()) - let originalStatus = await room.status - let newStatus = RoomStatus.attached // arbitrary - - let subscription1 = await room.onStatusChange(bufferingPolicy: .unbounded) - let subscription2 = await room.onStatusChange(bufferingPolicy: .unbounded) - - async let statusChange1 = subscription1.first { $0.current == newStatus } - async let statusChange2 = subscription2.first { $0.current == newStatus } - - // When: transition(to:) is called - await room.transition(to: newStatus) - - // Then: It emits a status change to all subscribers added via onChange(bufferingPolicy:), and updates its `status` property to the new state - for statusChange in try await [#require(statusChange1), #require(statusChange2)] { - #expect(statusChange.previous == originalStatus) - #expect(statusChange.current == newStatus) +private extension Result { + /// An async equivalent of the initializer of the same name in the standard library. + init(catching body: () async throws(Failure) -> Success) async { + do { + let success = try await body() + self = .success(success) + } catch { + self = .failure(error) } + } +} - #expect(await room.status == .attached) +private extension Result where Success == Void, Failure == ARTErrorInfo { + static func areIdentical(_ lhs: Result, _ rhs: Result) -> Bool { + switch (lhs, rhs) { + case (.success, .success): + true + case let (.failure(lhsError), .failure(rhsError)): + lhsError === rhsError + default: + fatalError("Mis-implemented") + } } } diff --git a/Tests/AblyChatTests/DefaultRoomsTests.swift b/Tests/AblyChatTests/DefaultRoomsTests.swift index d3200fbc..69167d41 100644 --- a/Tests/AblyChatTests/DefaultRoomsTests.swift +++ b/Tests/AblyChatTests/DefaultRoomsTests.swift @@ -9,10 +9,8 @@ struct DefaultRoomsTests { // Given: an instance of DefaultRooms let realtime = MockRealtime.create(channels: .init(channels: [ .init(name: "basketball::$chat::$chatMessages"), - .init(name: "basketball::$chat::$typingIndicators"), - .init(name: "basketball::$chat::$reactions"), ])) - let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger()) + let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory()) // When: get(roomID:options:) is called let roomID = "basketball" @@ -20,7 +18,7 @@ struct DefaultRoomsTests { let room = try await rooms.get(roomID: roomID, options: options) // Then: It returns a DefaultRoom instance that uses the same Realtime instance, with the given ID and options - let defaultRoom = try #require(room as? DefaultRoom) + let defaultRoom = try #require(room as? DefaultRoom) #expect(defaultRoom.testsOnly_realtime === realtime) #expect(defaultRoom.roomID == roomID) #expect(defaultRoom.options == options) @@ -32,10 +30,8 @@ struct DefaultRoomsTests { // Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID let realtime = MockRealtime.create(channels: .init(channels: [ .init(name: "basketball::$chat::$chatMessages"), - .init(name: "basketball::$chat::$typingIndicators"), - .init(name: "basketball::$chat::$reactions"), ])) - let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger()) + let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory()) let roomID = "basketball" let options = RoomOptions() @@ -54,10 +50,8 @@ struct DefaultRoomsTests { // Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID and options let realtime = MockRealtime.create(channels: .init(channels: [ .init(name: "basketball::$chat::$chatMessages"), - .init(name: "basketball::$chat::$typingIndicators"), - .init(name: "basketball::$chat::$reactions"), ])) - let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger()) + let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory()) let roomID = "basketball" let options = RoomOptions() diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift new file mode 100644 index 00000000..429e0d78 --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift @@ -0,0 +1,53 @@ +import Ably +@testable import AblyChat + +actor MockRoomLifecycleManager: RoomLifecycleManager { + private let attachResult: Result? + private(set) var attachCallCount = 0 + private let detachResult: Result? + private(set) var detachCallCount = 0 + private let _roomStatus: RoomStatus? + // TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36) + private var subscriptions: [Subscription] = [] + + init(attachResult: Result? = nil, detachResult: Result? = nil, roomStatus: RoomStatus? = nil) { + self.attachResult = attachResult + self.detachResult = detachResult + _roomStatus = roomStatus + } + + func performAttachOperation() async throws { + attachCallCount += 1 + guard let attachResult else { + fatalError("In order to call performAttachOperation, attachResult must be passed to the initializer") + } + try attachResult.get() + } + + func performDetachOperation() async throws { + detachCallCount += 1 + guard let detachResult else { + fatalError("In order to call performDetachOperation, detachResult must be passed to the initializer") + } + try detachResult.get() + } + + var roomStatus: RoomStatus { + guard let roomStatus = _roomStatus else { + fatalError("In order to call roomStatus, roomStatus must be passed to the initializer") + } + return roomStatus + } + + func onChange(bufferingPolicy: BufferingPolicy) async -> Subscription { + let subscription = Subscription(bufferingPolicy: bufferingPolicy) + subscriptions.append(subscription) + return subscription + } + + func emitStatusChange(_ statusChange: RoomStatusChange) { + for subscription in subscriptions { + subscription.emit(statusChange) + } + } +} diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleManagerFactory.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManagerFactory.swift new file mode 100644 index 00000000..b93e9bf2 --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManagerFactory.swift @@ -0,0 +1,13 @@ +@testable import AblyChat + +actor MockRoomLifecycleManagerFactory: RoomLifecycleManagerFactory { + private let manager: MockRoomLifecycleManager + + init(manager: MockRoomLifecycleManager = .init()) { + self.manager = manager + } + + func createManager(contributors _: [DefaultRoomLifecycleContributor], logger _: any InternalLogger) async -> MockRoomLifecycleManager { + manager + } +} From c51fd92941e1089c236b625562eab34668fdf7bd Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 11 Nov 2024 15:15:20 -0300 Subject: [PATCH 2/7] =?UTF-8?q?Allow=20Rooms=E2=80=99s=20rooms=20to=20be?= =?UTF-8?q?=20mocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We want to be able to mock out the upcoming `Room.release()` method. --- Sources/AblyChat/ChatClient.swift | 4 +- Sources/AblyChat/Room.swift | 27 +++++++--- Sources/AblyChat/Rooms.swift | 12 ++--- .../DefaultChatClientTests.swift | 2 +- Tests/AblyChatTests/DefaultRoomsTests.swift | 29 ++++++----- Tests/AblyChatTests/Mocks/MockRoom.swift | 49 +++++++++++++++++++ .../AblyChatTests/Mocks/MockRoomFactory.swift | 20 ++++++++ 7 files changed, 117 insertions(+), 26 deletions(-) create mode 100644 Tests/AblyChatTests/Mocks/MockRoom.swift create mode 100644 Tests/AblyChatTests/Mocks/MockRoomFactory.swift diff --git a/Sources/AblyChat/ChatClient.swift b/Sources/AblyChat/ChatClient.swift index 2e657eee..3827deb6 100644 --- a/Sources/AblyChat/ChatClient.swift +++ b/Sources/AblyChat/ChatClient.swift @@ -20,8 +20,8 @@ public actor DefaultChatClient: ChatClient { self.realtime = realtime self.clientOptions = clientOptions ?? .init() logger = DefaultInternalLogger(logHandler: self.clientOptions.logHandler, logLevel: self.clientOptions.logLevel) - let roomLifecycleManagerFactory = DefaultRoomLifecycleManagerFactory() - rooms = DefaultRooms(realtime: realtime, clientOptions: self.clientOptions, logger: logger, lifecycleManagerFactory: roomLifecycleManagerFactory) + let roomFactory = DefaultRoomFactory() + rooms = DefaultRooms(realtime: realtime, clientOptions: self.clientOptions, logger: logger, roomFactory: roomFactory) } public nonisolated var connection: any Connection { diff --git a/Sources/AblyChat/Room.swift b/Sources/AblyChat/Room.swift index 6aa09379..7831af5f 100644 --- a/Sources/AblyChat/Room.swift +++ b/Sources/AblyChat/Room.swift @@ -29,6 +29,27 @@ public struct RoomStatusChange: Sendable, Equatable { } } +internal protocol RoomFactory: Sendable { + associatedtype Room: AblyChat.Room + + func createRoom(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger) async throws -> Room +} + +internal final class DefaultRoomFactory: Sendable, RoomFactory { + private let lifecycleManagerFactory = DefaultRoomLifecycleManagerFactory() + + internal func createRoom(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger) async throws -> DefaultRoom { + try await DefaultRoom( + realtime: realtime, + chatAPI: chatAPI, + roomID: roomID, + options: options, + logger: logger, + lifecycleManagerFactory: lifecycleManagerFactory + ) + } +} + internal actor DefaultRoom: Room where LifecycleManagerFactory.Contributor == DefaultRoomLifecycleContributor { internal nonisolated let roomID: String internal nonisolated let options: RoomOptions @@ -41,12 +62,6 @@ internal actor DefaultRoom private let lifecycleManager: any RoomLifecycleManager - #if DEBUG - internal nonisolated var testsOnly_realtime: RealtimeClient { - realtime - } - #endif - private let logger: InternalLogger internal init(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger, lifecycleManagerFactory: LifecycleManagerFactory) async throws { diff --git a/Sources/AblyChat/Rooms.swift b/Sources/AblyChat/Rooms.swift index 4a775923..1fc6179c 100644 --- a/Sources/AblyChat/Rooms.swift +++ b/Sources/AblyChat/Rooms.swift @@ -6,7 +6,7 @@ public protocol Rooms: AnyObject, Sendable { var clientOptions: ClientOptions { get } } -internal actor DefaultRooms: Rooms where LifecycleManagerFactory.Contributor == DefaultRoomLifecycleContributor { +internal actor DefaultRooms: Rooms { private nonisolated let realtime: RealtimeClient private let chatAPI: ChatAPI @@ -19,16 +19,16 @@ internal actor DefaultRooms] = [:] + private var rooms: [String: RoomFactory.Room] = [:] - internal init(realtime: RealtimeClient, clientOptions: ClientOptions, logger: InternalLogger, lifecycleManagerFactory: LifecycleManagerFactory) { + internal init(realtime: RealtimeClient, clientOptions: ClientOptions, logger: InternalLogger, roomFactory: RoomFactory) { self.realtime = realtime self.clientOptions = clientOptions self.logger = logger - self.lifecycleManagerFactory = lifecycleManagerFactory + self.roomFactory = roomFactory chatAPI = ChatAPI(realtime: realtime) } @@ -43,7 +43,7 @@ internal actor DefaultRooms) + let defaultRooms = try #require(rooms as? DefaultRooms) #expect(defaultRooms.testsOnly_realtime === realtime) #expect(defaultRooms.clientOptions.isEqualForTestPurposes(options)) } diff --git a/Tests/AblyChatTests/DefaultRoomsTests.swift b/Tests/AblyChatTests/DefaultRoomsTests.swift index 69167d41..7ccec55b 100644 --- a/Tests/AblyChatTests/DefaultRoomsTests.swift +++ b/Tests/AblyChatTests/DefaultRoomsTests.swift @@ -10,18 +10,23 @@ struct DefaultRoomsTests { let realtime = MockRealtime.create(channels: .init(channels: [ .init(name: "basketball::$chat::$chatMessages"), ])) - let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory()) + let options = RoomOptions() + let roomToReturn = MockRoom(options: options) + let roomFactory = MockRoomFactory(room: roomToReturn) + let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), roomFactory: roomFactory) // When: get(roomID:options:) is called let roomID = "basketball" - let options = RoomOptions() let room = try await rooms.get(roomID: roomID, options: options) - // Then: It returns a DefaultRoom instance that uses the same Realtime instance, with the given ID and options - let defaultRoom = try #require(room as? DefaultRoom) - #expect(defaultRoom.testsOnly_realtime === realtime) - #expect(defaultRoom.roomID == roomID) - #expect(defaultRoom.options == options) + // Then: It returns a room that uses the same Realtime instance, with the given ID and options + let mockRoom = try #require(room as? MockRoom) + #expect(mockRoom === roomToReturn) + + let createRoomArguments = try #require(await roomFactory.createRoomArguments) + #expect(createRoomArguments.realtime === realtime) + #expect(createRoomArguments.roomID == roomID) + #expect(createRoomArguments.options == options) } // @spec CHA-RC1b @@ -31,10 +36,11 @@ struct DefaultRoomsTests { let realtime = MockRealtime.create(channels: .init(channels: [ .init(name: "basketball::$chat::$chatMessages"), ])) - let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory()) + let options = RoomOptions() + let roomToReturn = MockRoom(options: options) + let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), roomFactory: MockRoomFactory(room: roomToReturn)) let roomID = "basketball" - let options = RoomOptions() let firstRoom = try await rooms.get(roomID: roomID, options: options) // When: get(roomID:options:) is called with the same room ID @@ -51,10 +57,11 @@ struct DefaultRoomsTests { let realtime = MockRealtime.create(channels: .init(channels: [ .init(name: "basketball::$chat::$chatMessages"), ])) - let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory()) + let options = RoomOptions() + let roomToReturn = MockRoom(options: options) + let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), roomFactory: MockRoomFactory(room: roomToReturn)) let roomID = "basketball" - let options = RoomOptions() _ = try await rooms.get(roomID: roomID, options: options) // When: get(roomID:options:) is called with the same ID but different options diff --git a/Tests/AblyChatTests/Mocks/MockRoom.swift b/Tests/AblyChatTests/Mocks/MockRoom.swift new file mode 100644 index 00000000..c2b389a4 --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockRoom.swift @@ -0,0 +1,49 @@ +@testable import AblyChat + +actor MockRoom: Room { + let options: RoomOptions + + init(options: RoomOptions) { + self.options = options + } + + nonisolated var roomID: String { + fatalError("Not implemented") + } + + nonisolated var messages: any Messages { + fatalError("Not implemented") + } + + nonisolated var presence: any Presence { + fatalError("Not implemented") + } + + nonisolated var reactions: any RoomReactions { + fatalError("Not implemented") + } + + nonisolated var typing: any Typing { + fatalError("Not implemented") + } + + nonisolated var occupancy: any Occupancy { + fatalError("Not implemented") + } + + var status: AblyChat.RoomStatus { + fatalError("Not implemented") + } + + func onStatusChange(bufferingPolicy _: BufferingPolicy) async -> Subscription { + fatalError("Not implemented") + } + + func attach() async throws { + fatalError("Not implemented") + } + + func detach() async throws { + fatalError("Not implemented") + } +} diff --git a/Tests/AblyChatTests/Mocks/MockRoomFactory.swift b/Tests/AblyChatTests/Mocks/MockRoomFactory.swift new file mode 100644 index 00000000..804fd605 --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockRoomFactory.swift @@ -0,0 +1,20 @@ +@testable import AblyChat + +actor MockRoomFactory: RoomFactory { + private let room: MockRoom? + private(set) var createRoomArguments: (realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: any InternalLogger)? + + init(room: MockRoom? = nil) { + self.room = room + } + + func createRoom(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: any InternalLogger) async throws -> MockRoom { + createRoomArguments = (realtime: realtime, chatAPI: chatAPI, roomID: roomID, options: options, logger: logger) + + guard let room else { + fatalError("MockRoomFactory.createRoom called, but the mock factory has not been set up with a room to return") + } + + return room + } +} From 8daa1910738bb1a2e3aaf861fbb8d17d9475fa7b Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 7 Nov 2024 11:51:14 -0300 Subject: [PATCH 3/7] Implement room release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We use the implementation of the RELEASE operation provided by the room lifecycle manager, and implement the spec points relating to room map bookkeeping and releasing the underlying realtime channels. Based on [1] at 6f0740a. I have not implemented the spec points that relate to making sure that a room fetch waits for any previous room with the same ID to finish releasing; this is a part of the spec which is in flux (currently implemented via the INITIALIZING status, which was added to the spec after we started implementing the room lifecycle manager and hasn’t been implemented in this SDK yet, and soon to be further changed in the spec by making room-getting async). We can look at the current state of things when we come to do #66. Part of #47. [1] https://github.com/ably/specification/pull/200 --- Sources/AblyChat/Room.swift | 21 ++++++++-- Sources/AblyChat/RoomLifecycleManager.swift | 11 ++++- Sources/AblyChat/Rooms.swift | 19 ++++++++- Tests/AblyChatTests/DefaultRoomTests.swift | 27 ++++++++++++ Tests/AblyChatTests/DefaultRoomsTests.swift | 41 +++++++++++++++++++ Tests/AblyChatTests/IntegrationTests.swift | 11 +++++ Tests/AblyChatTests/Mocks/MockChannels.swift | 19 +++++++-- Tests/AblyChatTests/Mocks/MockRoom.swift | 15 ++++++- .../AblyChatTests/Mocks/MockRoomFactory.swift | 6 ++- .../Mocks/MockRoomLifecycleManager.swift | 5 +++ 10 files changed, 163 insertions(+), 12 deletions(-) diff --git a/Sources/AblyChat/Room.swift b/Sources/AblyChat/Room.swift index 7831af5f..9967dfad 100644 --- a/Sources/AblyChat/Room.swift +++ b/Sources/AblyChat/Room.swift @@ -19,6 +19,11 @@ public protocol Room: AnyObject, Sendable { var options: RoomOptions { get } } +/// A ``Room`` that exposes additional functionality for use within the SDK. +internal protocol InternalRoom: Room { + func release() async +} + public struct RoomStatusChange: Sendable, Equatable { public var current: RoomStatus public var previous: RoomStatus @@ -30,7 +35,7 @@ public struct RoomStatusChange: Sendable, Equatable { } internal protocol RoomFactory: Sendable { - associatedtype Room: AblyChat.Room + associatedtype Room: AblyChat.InternalRoom func createRoom(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger) async throws -> Room } @@ -50,7 +55,7 @@ internal final class DefaultRoomFactory: Sendable, RoomFactory { } } -internal actor DefaultRoom: Room where LifecycleManagerFactory.Contributor == DefaultRoomLifecycleContributor { +internal actor DefaultRoom: InternalRoom where LifecycleManagerFactory.Contributor == DefaultRoomLifecycleContributor { internal nonisolated let roomID: String internal nonisolated let options: RoomOptions private let chatAPI: ChatAPI @@ -61,6 +66,7 @@ internal actor DefaultRoom private nonisolated let realtime: RealtimeClient private let lifecycleManager: any RoomLifecycleManager + private let channels: [RoomFeature: any RealtimeChannelProtocol] private let logger: InternalLogger @@ -75,7 +81,7 @@ internal actor DefaultRoom throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.") } - let channels = Self.createChannels(roomID: roomID, realtime: realtime) + channels = Self.createChannels(roomID: roomID, realtime: realtime) let contributors = Self.createContributors(channels: channels) lifecycleManager = await lifecycleManagerFactory.createManager( @@ -130,6 +136,15 @@ internal actor DefaultRoom try await lifecycleManager.performDetachOperation() } + internal func release() async { + await lifecycleManager.performReleaseOperation() + + // CHA-RL3h + for channel in channels.values { + realtime.channels.release(channel.name) + } + } + // MARK: - Room status internal func onStatusChange(bufferingPolicy: BufferingPolicy) async -> Subscription { diff --git a/Sources/AblyChat/RoomLifecycleManager.swift b/Sources/AblyChat/RoomLifecycleManager.swift index 44aec8ee..a6dc72bc 100644 --- a/Sources/AblyChat/RoomLifecycleManager.swift +++ b/Sources/AblyChat/RoomLifecycleManager.swift @@ -43,6 +43,7 @@ internal protocol RoomLifecycleContributor: Identifiable, Sendable { internal protocol RoomLifecycleManager: Sendable { func performAttachOperation() async throws func performDetachOperation() async throws + func performReleaseOperation() async var roomStatus: RoomStatus { get async } func onChange(bufferingPolicy: BufferingPolicy) async -> Subscription } @@ -864,11 +865,19 @@ internal actor DefaultRoomLifecycleManager: Rooms { } } - internal func release(roomID _: String) async throws { - fatalError("Not yet implemented") + #if DEBUG + internal func testsOnly_hasExistingRoomWithID(_ roomID: String) -> Bool { + rooms[roomID] != nil + } + #endif + + internal func release(roomID: String) async throws { + guard let room = rooms[roomID] else { + // TODO: what to do here? (https://github.com/ably/specification/pull/200/files#r1837154563) — Andy replied that it’s a no-op but that this is going to be specified in an upcoming PR when we make room-getting async + return + } + + // CHA-RC1d + rooms.removeValue(forKey: roomID) + + // CHA-RL1e + await room.release() } } diff --git a/Tests/AblyChatTests/DefaultRoomTests.swift b/Tests/AblyChatTests/DefaultRoomTests.swift index 88135491..eec8e94b 100644 --- a/Tests/AblyChatTests/DefaultRoomTests.swift +++ b/Tests/AblyChatTests/DefaultRoomTests.swift @@ -92,6 +92,33 @@ struct DefaultRoomTests { #expect(await lifecycleManager.detachCallCount == 1) } + // MARK: - Release + + // @spec CHA-RL3h - I haven’t explicitly tested that `performReleaseOperation()` happens _before_ releasing the channels (i.e. the “upon operation completion” part of the spec point), because it would require me to spend extra time on mock-writing which I can’t really afford to spend right now. I think we can live with it at least for the time being; I’m pretty sure there are other tests where the spec mentions or requires an order where I also haven’t tested the order. + @Test + func release() async throws { + // Given: a DefaultRoom instance + let channelsList = [ + MockRealtimeChannel(name: "basketball::$chat::$chatMessages"), + ] + let channels = MockChannels(channels: channelsList) + let realtime = MockRealtime.create(channels: channels) + + let lifecycleManager = MockRoomLifecycleManager() + let lifecycleManagerFactory = MockRoomLifecycleManagerFactory(manager: lifecycleManager) + + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger(), lifecycleManagerFactory: lifecycleManagerFactory) + + // When: `release()` is called on the room + await room.release() + + // Then: It: + // 1. calls `performReleaseOperation()` on the room lifecycle manager + // 2. calls `channels.release()` with the name of each of the features’ channels + #expect(await lifecycleManager.releaseCallCount == 1) + #expect(Set(channels.releaseArguments) == Set(channelsList.map(\.name))) + } + // MARK: - Room status @Test diff --git a/Tests/AblyChatTests/DefaultRoomsTests.swift b/Tests/AblyChatTests/DefaultRoomsTests.swift index 7ccec55b..4cebc330 100644 --- a/Tests/AblyChatTests/DefaultRoomsTests.swift +++ b/Tests/AblyChatTests/DefaultRoomsTests.swift @@ -3,6 +3,8 @@ 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 { + // MARK: - Get a room + // @spec CHA-RC1a @Test func get_returnsRoomWithGivenID() async throws { @@ -78,4 +80,43 @@ struct DefaultRoomsTests { // Then: It throws an inconsistentRoomOptions error #expect(isChatError(caughtError, withCode: .inconsistentRoomOptions)) } + + // MARK: - Release a room + + // @spec CHA-RC1d + // @spec CHA-RC1e + @Test + func release() async throws { + // Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID + let realtime = MockRealtime.create(channels: .init(channels: [ + .init(name: "basketball::$chat::$chatMessages"), + ])) + let options = RoomOptions() + let hasExistingRoomAtMomentRoomReleaseCalledStreamComponents = AsyncStream.makeStream(of: Bool.self) + let roomFactory = MockRoomFactory() + let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), roomFactory: roomFactory) + + let roomID = "basketball" + + let roomToReturn = MockRoom(options: options) { + await hasExistingRoomAtMomentRoomReleaseCalledStreamComponents.continuation.yield(rooms.testsOnly_hasExistingRoomWithID(roomID)) + } + await roomFactory.setRoom(roomToReturn) + + _ = try await rooms.get(roomID: roomID, options: .init()) + try #require(await rooms.testsOnly_hasExistingRoomWithID(roomID)) + + // When: `release(roomID:)` is called with this room ID + _ = try await rooms.release(roomID: roomID) + + // Then: + // 1. first, the room is removed from the room map + // 2. next, `release` is called on the room + + // These two lines are convoluted because the #require macro has a hard time with stuff of type Bool? and emits warnings about ambiguity unless you jump through the hoops it tells you to + let hasExistingRoomAtMomentRoomReleaseCalled = await hasExistingRoomAtMomentRoomReleaseCalledStreamComponents.stream.first { _ in true } + #expect(try !#require(hasExistingRoomAtMomentRoomReleaseCalled as Bool?)) + + #expect(await roomToReturn.releaseCallCount == 1) + } } diff --git a/Tests/AblyChatTests/IntegrationTests.swift b/Tests/AblyChatTests/IntegrationTests.swift index 8712fe14..fa975d70 100644 --- a/Tests/AblyChatTests/IntegrationTests.swift +++ b/Tests/AblyChatTests/IntegrationTests.swift @@ -74,5 +74,16 @@ struct IntegrationTests { // (11) Check that we received a DETACHED status change as a result of detaching the room _ = try #require(await rxRoomStatusSubscription.first { $0.current == .detached }) #expect(await rxRoom.status == .detached) + + // (12) Release the room + try await rxClient.rooms.release(roomID: roomID) + + // (13) Check that we received a RELEASED status change as a result of releasing the room + _ = try #require(await rxRoomStatusSubscription.first { $0.current == .released }) + #expect(await rxRoom.status == .released) + + // (14) Fetch the room we just released and check it’s a new object + let postReleaseRxRoom = try await rxClient.rooms.get(roomID: roomID, options: .init()) + #expect(postReleaseRxRoom !== rxRoom) } } diff --git a/Tests/AblyChatTests/Mocks/MockChannels.swift b/Tests/AblyChatTests/Mocks/MockChannels.swift index d7398051..8afb6f00 100644 --- a/Tests/AblyChatTests/Mocks/MockChannels.swift +++ b/Tests/AblyChatTests/Mocks/MockChannels.swift @@ -1,8 +1,11 @@ import Ably import AblyChat -final class MockChannels: RealtimeChannelsProtocol, Sendable { +final class MockChannels: RealtimeChannelsProtocol, @unchecked Sendable { private let channels: [MockRealtimeChannel] + private let mutex = NSLock() + /// Access must be synchronized via ``mutex``. + private(set) var _releaseArguments: [String] = [] init(channels: [MockRealtimeChannel]) { self.channels = channels @@ -24,7 +27,17 @@ final class MockChannels: RealtimeChannelsProtocol, Sendable { fatalError("Not implemented") } - func release(_: String) { - fatalError("Not implemented") + func release(_ name: String) { + mutex.lock() + defer { mutex.unlock() } + _releaseArguments.append(name) + } + + var releaseArguments: [String] { + let result: [String] + mutex.lock() + result = _releaseArguments + mutex.unlock() + return result } } diff --git a/Tests/AblyChatTests/Mocks/MockRoom.swift b/Tests/AblyChatTests/Mocks/MockRoom.swift index c2b389a4..7039e218 100644 --- a/Tests/AblyChatTests/Mocks/MockRoom.swift +++ b/Tests/AblyChatTests/Mocks/MockRoom.swift @@ -1,10 +1,13 @@ @testable import AblyChat -actor MockRoom: Room { +actor MockRoom: InternalRoom { let options: RoomOptions + private(set) var releaseCallCount = 0 + let releaseImplementation: (@Sendable () async -> Void)? - init(options: RoomOptions) { + init(options: RoomOptions, releaseImplementation: (@Sendable () async -> Void)? = nil) { self.options = options + self.releaseImplementation = releaseImplementation } nonisolated var roomID: String { @@ -46,4 +49,12 @@ actor MockRoom: Room { func detach() async throws { fatalError("Not implemented") } + + func release() async { + releaseCallCount += 1 + guard let releaseImplementation else { + fatalError("releaseImplementation must be set before calling `release`") + } + await releaseImplementation() + } } diff --git a/Tests/AblyChatTests/Mocks/MockRoomFactory.swift b/Tests/AblyChatTests/Mocks/MockRoomFactory.swift index 804fd605..f24ad3d1 100644 --- a/Tests/AblyChatTests/Mocks/MockRoomFactory.swift +++ b/Tests/AblyChatTests/Mocks/MockRoomFactory.swift @@ -1,13 +1,17 @@ @testable import AblyChat actor MockRoomFactory: RoomFactory { - private let room: MockRoom? + private var room: MockRoom? private(set) var createRoomArguments: (realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: any InternalLogger)? init(room: MockRoom? = nil) { self.room = room } + func setRoom(_ room: MockRoom) { + self.room = room + } + func createRoom(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: any InternalLogger) async throws -> MockRoom { createRoomArguments = (realtime: realtime, chatAPI: chatAPI, roomID: roomID, options: options, logger: logger) diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift index 429e0d78..ad1e004f 100644 --- a/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift +++ b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift @@ -6,6 +6,7 @@ actor MockRoomLifecycleManager: RoomLifecycleManager { private(set) var attachCallCount = 0 private let detachResult: Result? private(set) var detachCallCount = 0 + private(set) var releaseCallCount = 0 private let _roomStatus: RoomStatus? // TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36) private var subscriptions: [Subscription] = [] @@ -32,6 +33,10 @@ actor MockRoomLifecycleManager: RoomLifecycleManager { try detachResult.get() } + func performReleaseOperation() async { + releaseCallCount += 1 + } + var roomStatus: RoomStatus { guard let roomStatus = _roomStatus else { fatalError("In order to call roomStatus, roomStatus must be passed to the initializer") From b1860c203504cb6023aed9c9c07e0a481437c981 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 7 Nov 2024 11:43:59 -0300 Subject: [PATCH 4/7] Emit discontinuities via Messages Based on same spec as 8daa191. Resolves #47. --- Sources/AblyChat/DefaultMessages.swift | 16 ++++++---- .../DefaultRoomLifecycleContributor.swift | 16 ++++++++-- Sources/AblyChat/Room.swift | 19 +++++------- Sources/AblyChat/RoomFeature.swift | 21 ++++++++++++++ .../AblyChatTests/DefaultMessagesTests.swift | 29 +++++++++++++++++-- .../Mocks/MockFeatureChannel.swift | 24 +++++++++++++++ 6 files changed, 101 insertions(+), 24 deletions(-) create mode 100644 Tests/AblyChatTests/Mocks/MockFeatureChannel.swift diff --git a/Sources/AblyChat/DefaultMessages.swift b/Sources/AblyChat/DefaultMessages.swift index d11b283d..0f5d6e66 100644 --- a/Sources/AblyChat/DefaultMessages.swift +++ b/Sources/AblyChat/DefaultMessages.swift @@ -13,7 +13,7 @@ private struct MessageSubscriptionWrapper { @MainActor internal final class DefaultMessages: Messages, EmitsDiscontinuities { private let roomID: String - public nonisolated let channel: RealtimeChannelProtocol + public nonisolated let featureChannel: FeatureChannel private let chatAPI: ChatAPI private let clientID: String @@ -21,8 +21,8 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { // 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(channel: RealtimeChannelProtocol, chatAPI: ChatAPI, roomID: String, clientID: String) async { - self.channel = channel + internal nonisolated init(featureChannel: FeatureChannel, chatAPI: ChatAPI, roomID: String, clientID: String) async { + self.featureChannel = featureChannel self.chatAPI = chatAPI self.roomID = roomID self.clientID = clientID @@ -32,6 +32,10 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { await handleChannelEvents(roomId: roomID) } + internal nonisolated var channel: any RealtimeChannelProtocol { + featureChannel.channel + } + // (CHA-M4) Messages can be received via a subscription in realtime. internal func subscribe(bufferingPolicy: BufferingPolicy) async throws -> MessageSubscription { let uuid = UUID() @@ -99,9 +103,9 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { 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") + // (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. + internal func subscribeToDiscontinuities() async -> Subscription { + await featureChannel.subscribeToDiscontinuities() } private func getBeforeSubscriptionStart(_ uuid: UUID, params: QueryOptions) async throws -> any PaginatedResult { diff --git a/Sources/AblyChat/DefaultRoomLifecycleContributor.swift b/Sources/AblyChat/DefaultRoomLifecycleContributor.swift index a236061b..601850f7 100644 --- a/Sources/AblyChat/DefaultRoomLifecycleContributor.swift +++ b/Sources/AblyChat/DefaultRoomLifecycleContributor.swift @@ -1,8 +1,9 @@ import Ably -internal actor DefaultRoomLifecycleContributor: RoomLifecycleContributor { +internal actor DefaultRoomLifecycleContributor: RoomLifecycleContributor, EmitsDiscontinuities { internal let channel: DefaultRoomLifecycleContributorChannel internal let feature: RoomFeature + private var discontinuitySubscriptions: [Subscription] = [] internal init(channel: DefaultRoomLifecycleContributorChannel, feature: RoomFeature) { self.channel = channel @@ -11,8 +12,17 @@ internal actor DefaultRoomLifecycleContributor: RoomLifecycleContributor { // MARK: - Discontinuities - internal func emitDiscontinuity(_: ARTErrorInfo) { - // TODO: https://github.com/ably-labs/ably-chat-swift/issues/47 + internal func emitDiscontinuity(_ error: ARTErrorInfo) { + for subscription in discontinuitySubscriptions { + subscription.emit(error) + } + } + + internal func subscribeToDiscontinuities() -> Subscription { + let subscription = Subscription(bufferingPolicy: .unbounded) + // TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36) + discontinuitySubscriptions.append(subscription) + return subscription } } diff --git a/Sources/AblyChat/Room.swift b/Sources/AblyChat/Room.swift index 9967dfad..076bc0fe 100644 --- a/Sources/AblyChat/Room.swift +++ b/Sources/AblyChat/Room.swift @@ -81,8 +81,9 @@ internal actor DefaultRoom throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.") } - channels = Self.createChannels(roomID: roomID, realtime: realtime) - let contributors = Self.createContributors(channels: channels) + let featureChannels = Self.createFeatureChannels(roomID: roomID, realtime: realtime) + channels = featureChannels.mapValues(\.channel) + let contributors = featureChannels.values.map(\.contributor) lifecycleManager = await lifecycleManagerFactory.createManager( contributors: contributors, @@ -90,28 +91,22 @@ internal actor DefaultRoom ) messages = await DefaultMessages( - channel: channels[.messages]!, + featureChannel: featureChannels[.messages]!, chatAPI: chatAPI, roomID: roomID, clientID: clientId ) } - private static func createChannels(roomID: String, realtime: RealtimeClient) -> [RoomFeature: RealtimeChannelProtocol] { + private static func createFeatureChannels(roomID: String, realtime: RealtimeClient) -> [RoomFeature: DefaultFeatureChannel] { .init(uniqueKeysWithValues: [RoomFeature.messages].map { feature in let channel = realtime.getChannel(feature.channelNameForRoomID(roomID)) + let contributor = DefaultRoomLifecycleContributor(channel: .init(underlyingChannel: channel), feature: feature) - return (feature, channel) + return (feature, .init(channel: channel, contributor: contributor)) }) } - private static func createContributors(channels: [RoomFeature: RealtimeChannelProtocol]) -> [DefaultRoomLifecycleContributor] { - channels.map { entry in - let (feature, channel) = entry - return .init(channel: .init(underlyingChannel: channel), feature: feature) - } - } - public nonisolated var presence: any Presence { fatalError("Not yet implemented") } diff --git a/Sources/AblyChat/RoomFeature.swift b/Sources/AblyChat/RoomFeature.swift index e2fb70fc..a006472b 100644 --- a/Sources/AblyChat/RoomFeature.swift +++ b/Sources/AblyChat/RoomFeature.swift @@ -1,3 +1,5 @@ +import Ably + /// The features offered by a chat room. internal enum RoomFeature { case messages @@ -21,3 +23,22 @@ internal enum RoomFeature { } } } + +/// Provides all of the channel-related functionality that a room feature (e.g. an implementation of ``Messages``) needs. +/// +/// This mishmash exists to give a room feature access to both: +/// +/// - a `RealtimeChannelProtocol` object (this is the interface that our features are currently written against, as opposed to, say, `RoomLifecycleContributorChannel`) +/// - the discontinuities emitted by the room lifecycle +internal protocol FeatureChannel: Sendable, EmitsDiscontinuities { + var channel: RealtimeChannelProtocol { get } +} + +internal struct DefaultFeatureChannel: FeatureChannel { + internal var channel: RealtimeChannelProtocol + internal var contributor: DefaultRoomLifecycleContributor + + internal func subscribeToDiscontinuities() async -> Subscription { + await contributor.subscribeToDiscontinuities() + } +} diff --git a/Tests/AblyChatTests/DefaultMessagesTests.swift b/Tests/AblyChatTests/DefaultMessagesTests.swift index 9bdbdb9b..4ab88a51 100644 --- a/Tests/AblyChatTests/DefaultMessagesTests.swift +++ b/Tests/AblyChatTests/DefaultMessagesTests.swift @@ -11,7 +11,8 @@ struct DefaultMessagesTests { let realtime = MockRealtime.create() let chatAPI = ChatAPI(realtime: realtime) let channel = MockRealtimeChannel() - let defaultMessages = await DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId") + let featureChannel = MockFeatureChannel(channel: channel) + let defaultMessages = await DefaultMessages(featureChannel: featureChannel, 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: { @@ -28,7 +29,8 @@ struct DefaultMessagesTests { let realtime = MockRealtime.create { (MockHTTPPaginatedResponse.successGetMessagesWithNoItems, nil) } let chatAPI = ChatAPI(realtime: realtime) let channel = MockRealtimeChannel() - let defaultMessages = await DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId") + let featureChannel = MockFeatureChannel(channel: channel) + let defaultMessages = await DefaultMessages(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId") // Then await #expect(throws: Never.self, performing: { @@ -52,7 +54,8 @@ struct DefaultMessagesTests { channelSerial: "001" ) ) - let defaultMessages = await DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId") + let featureChannel = MockFeatureChannel(channel: channel) + let defaultMessages = await DefaultMessages(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId") let subscription = try await defaultMessages.subscribe(bufferingPolicy: .unbounded) let expectedPaginatedResult = PaginatedResultWrapper( paginatedResponse: MockHTTPPaginatedResponse.successGetMessagesWithNoItems, @@ -65,4 +68,24 @@ struct DefaultMessagesTests { // Then #expect(previousMessages == expectedPaginatedResult) } + + // @spec CHA-M7 + @Test + func subscribeToDiscontinuities() async throws { + // Given: A DefaultMessages instance + let realtime = MockRealtime.create() + let chatAPI = ChatAPI(realtime: realtime) + let channel = MockRealtimeChannel() + let featureChannel = MockFeatureChannel(channel: channel) + let messages = await DefaultMessages(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId") + + // When: The feature channel emits a discontinuity through `subscribeToDiscontinuities` + let featureChannelDiscontinuity = ARTErrorInfo.createUnknownError() // arbitrary + let messagesDiscontinuitySubscription = await messages.subscribeToDiscontinuities() + await featureChannel.emitDiscontinuity(featureChannelDiscontinuity) + + // Then: The DefaultMessages instance emits this discontinuity through `subscribeToDiscontinuities` + let messagesDiscontinuity = try #require(await messagesDiscontinuitySubscription.first { _ in true }) + #expect(messagesDiscontinuity === featureChannelDiscontinuity) + } } diff --git a/Tests/AblyChatTests/Mocks/MockFeatureChannel.swift b/Tests/AblyChatTests/Mocks/MockFeatureChannel.swift new file mode 100644 index 00000000..41759530 --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockFeatureChannel.swift @@ -0,0 +1,24 @@ +import Ably +@testable import AblyChat + +final actor MockFeatureChannel: FeatureChannel { + let channel: RealtimeChannelProtocol + // TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36) + private var discontinuitySubscriptions: [Subscription] = [] + + init(channel: RealtimeChannelProtocol) { + self.channel = channel + } + + func subscribeToDiscontinuities() async -> Subscription { + let subscription = Subscription(bufferingPolicy: .unbounded) + discontinuitySubscriptions.append(subscription) + return subscription + } + + func emitDiscontinuity(_ discontinuity: ARTErrorInfo) { + for subscription in discontinuitySubscriptions { + subscription.emit(discontinuity) + } + } +} From 88dfe0f2d0a71d5ccdc0169c4d41aae3f318b9fa Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 12 Nov 2024 14:19:33 -0300 Subject: [PATCH 5/7] Use `main` branch of spec for coverage The PR containing chat lifecycle has now been merged. Resolves #97. --- Sources/BuildTool/BuildTool.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/BuildTool/BuildTool.swift b/Sources/BuildTool/BuildTool.swift index bfe389c9..55f417ac 100644 --- a/Sources/BuildTool/BuildTool.swift +++ b/Sources/BuildTool/BuildTool.swift @@ -498,8 +498,7 @@ struct SpecCoverage: AsyncParsableCommand { } mutating func run() async throws { - // TODO: https://github.com/ably-labs/ably-chat-swift/issues/97 - switch to use main at some point - let branchName = "chat-lifecycle" + let branchName = "main" let commitSHA = try await fetchLatestSpecCommitSHAForBranchName(branchName) print("Using latest spec commit (\(commitSHA.prefix(7))) from branch \(branchName).\n") From be064c439b4289d5da706e009ae3fe7b710e6ded Mon Sep 17 00:00:00 2001 From: Umair Date: Fri, 18 Oct 2024 11:17:03 +0100 Subject: [PATCH 6/7] Spec complete for Ephemeral Room Reactions in line with [1] at b4a495e. `CHA-ER3b` & `CHA-ER3c` have not been implemented despite being outlined in [1], as per the ADR at [2]. Example app has also been updated to support both a working and mock implementation of the Chat app. [1] - https://sdk.ably.com/builds/ably/specification/pull/200/chat-features/ [2] - https://ably.atlassian.net/wiki/spaces/CHA/pages/3438116905/CHADR-066+Removing+Reserved+Keyspace#Solution --- Example/AblyChatExample/ContentView.swift | 63 ++++++++++++-- Example/AblyChatExample/Mocks/Misc.swift | 2 +- .../AblyChatExample/Mocks/MockClients.swift | 4 +- Sources/AblyChat/DefaultMessages.swift | 14 ++- Sources/AblyChat/DefaultRoomReactions.swift | 87 +++++++++++++++++++ Sources/AblyChat/Events.swift | 4 + Sources/AblyChat/Reaction.swift | 1 + Sources/AblyChat/Room.swift | 18 +++- Sources/AblyChat/RoomFeature.swift | 5 +- Sources/AblyChat/RoomReactions.swift | 10 +++ Sources/AblyChat/Subscription.swift | 10 +++ .../DefaultRoomReactionsTests.swift | 82 +++++++++++++++++ Tests/AblyChatTests/DefaultRoomTests.swift | 6 ++ Tests/AblyChatTests/IntegrationTests.swift | 32 +++++-- .../Mocks/MockRealtimeChannel.swift | 11 ++- 15 files changed, 321 insertions(+), 28 deletions(-) create mode 100644 Sources/AblyChat/DefaultRoomReactions.swift create mode 100644 Tests/AblyChatTests/DefaultRoomReactionsTests.swift diff --git a/Example/AblyChatExample/ContentView.swift b/Example/AblyChatExample/ContentView.swift index f3a54f14..3f32f1c5 100644 --- a/Example/AblyChatExample/ContentView.swift +++ b/Example/AblyChatExample/ContentView.swift @@ -1,3 +1,4 @@ +import Ably import AblyChat import SwiftUI @@ -11,11 +12,24 @@ struct ContentView: View { let screenHeight = UIScreen.main.bounds.height #endif - @State private var chatClient = MockChatClient( + // Can be replaced with your own room ID + private let roomID = "DemoRoomID" + + // Set mode to `.live` if you wish to connect to actual instances of the Chat client in either Prod or Sandbox environments. Setting the mode to `.mock` will use the `MockChatClient`, and therefore simulate all features of the Chat app. + private let mode = Environment.mock + private enum Environment { + case mock + case live + } + + @State private var mockChatClient = MockChatClient( realtime: MockRealtime.create(), clientOptions: ClientOptions() ) + private let liveRealtime: ARTRealtime + @State private var liveChatClient: DefaultChatClient + @State private var title = "Room" @State private var messages = [BasicListItem]() @State private var reactions: [Reaction] = [] @@ -24,8 +38,19 @@ struct ContentView: View { @State private var occupancyInfo = "Connections: 0" @State private var statusInfo = "" + // You only need to set `options.key` and `options.clientId` if your mode is set to `.live`. Otherwise, you can ignore this. + init() { + let options = ARTClientOptions() + options.key = "" + options.clientId = "" + liveRealtime = ARTRealtime(options: options) + + _liveChatClient = State(initialValue: DefaultChatClient(realtime: liveRealtime, clientOptions: .init())) + } + private func room() async throws -> Room { - try await chatClient.rooms.get(roomID: "Demo", options: .init()) + let chosenChatClient: ChatClient = (mode == .mock) ? mockChatClient : liveChatClient + return try await chosenChatClient.rooms.get(roomID: roomID, options: .init(reactions: .init())) } private var sendTitle: String { @@ -99,18 +124,24 @@ struct ContentView: View { } } .tryTask { try await setDefaultTitle() } + .tryTask { try await attachRoom() } .tryTask { try await showMessages() } .tryTask { try await showReactions() } - .tryTask { try await showPresence() } - .tryTask { try await showTypings() } - .tryTask { try await showOccupancy() } - .tryTask { try await showRoomStatus() } + .tryTask { + // NOTE: As we implement more features, move them out of the `if mode == .mock` block and into the main block just above. + if mode == .mock { + try await showPresence() + try await showTypings() + try await showOccupancy() + try await showRoomStatus() + } + } } func sendButtonAction() { if newMessage.isEmpty { Task { - try await sendReaction(type: ReactionType.like.rawValue) + try await sendReaction(type: ReactionType.like.emoji) } } else { Task { @@ -123,8 +154,21 @@ struct ContentView: View { title = try await "\(room().roomID)" } + func attachRoom() async throws { + try await room().attach() + } + func showMessages() async throws { - for await message in try await room().messages.subscribe(bufferingPolicy: .unbounded) { + let messagesSubscription = try await room().messages.subscribe(bufferingPolicy: .unbounded) + let previousMessages = try await messagesSubscription.getPreviousMessages(params: .init()) + + for message in previousMessages.items { + withAnimation { + messages.append(BasicListItem(id: message.timeserial, title: message.clientID, text: message.text)) + } + } + + for await message in messagesSubscription { withAnimation { messages.insert(BasicListItem(id: message.timeserial, title: message.clientID, text: message.text), at: 0) } @@ -132,7 +176,8 @@ struct ContentView: View { } func showReactions() async throws { - for await reaction in try await room().reactions.subscribe(bufferingPolicy: .unbounded) { + let reactionSubscription = try await room().reactions.subscribe(bufferingPolicy: .unbounded) + for await reaction in reactionSubscription { withAnimation { showReaction(reaction.displayedText) } diff --git a/Example/AblyChatExample/Mocks/Misc.swift b/Example/AblyChatExample/Mocks/Misc.swift index 9a6b362c..cf6b9578 100644 --- a/Example/AblyChatExample/Mocks/Misc.swift +++ b/Example/AblyChatExample/Mocks/Misc.swift @@ -88,6 +88,6 @@ enum ReactionType: String, CaseIterable { extension Reaction { var displayedText: String { - ReactionType(rawValue: type)?.emoji ?? ReactionType.idk.emoji + type } } diff --git a/Example/AblyChatExample/Mocks/MockClients.swift b/Example/AblyChatExample/Mocks/MockClients.swift index a373aa4c..f3e374ae 100644 --- a/Example/AblyChatExample/Mocks/MockClients.swift +++ b/Example/AblyChatExample/Mocks/MockClients.swift @@ -69,7 +69,7 @@ actor MockRoom: Room { private var mockSubscriptions: [MockSubscription] = [] func attach() async throws { - fatalError("Not yet implemented") + print("Mock client attached to room with roomID: \(roomID)") } func detach() async throws { @@ -165,7 +165,7 @@ actor MockRoomReactions: RoomReactions { private func createSubscription() -> MockSubscription { let subscription = MockSubscription(randomElement: { Reaction( - type: ReactionType.allCases.randomElement()!.rawValue, + type: ReactionType.allCases.randomElement()!.emoji, metadata: [:], headers: [:], createdAt: Date(), diff --git a/Sources/AblyChat/DefaultMessages.swift b/Sources/AblyChat/DefaultMessages.swift index 0f5d6e66..23074557 100644 --- a/Sources/AblyChat/DefaultMessages.swift +++ b/Sources/AblyChat/DefaultMessages.swift @@ -75,7 +75,7 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { } let metadata = data["metadata"] as? Metadata - let headers = try message.extras?.toJSON()["headers"] as? Headers + let headers = extras["headers"] as? Headers let message = Message( timeserial: timeserial, @@ -205,27 +205,33 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { // (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 + // avoids multiple invocations of the continuation + var nillableContinuation: CheckedContinuation? = continuation + 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) + nillableContinuation?.resume(returning: attachSerial) } else { - continuation.resume(throwing: ARTErrorInfo.create(withCode: 40000, status: 400, message: "Channel is attached, but attachSerial is not defined")) + nillableContinuation?.resume(throwing: ARTErrorInfo.create(withCode: 40000, status: 400, message: "Channel is attached, but attachSerial is not defined")) } + nillableContinuation = nil case .failed, .suspended: // TODO: Revisit as part of https://github.com/ably-labs/ably-chat-swift/issues/32 - continuation.resume( + nillableContinuation?.resume( throwing: ARTErrorInfo.create( withCode: ErrorCode.messagesAttachmentFailed.rawValue, status: ErrorCode.messagesAttachmentFailed.statusCode, message: "Channel failed to attach" ) ) + nillableContinuation = nil default: break } diff --git a/Sources/AblyChat/DefaultRoomReactions.swift b/Sources/AblyChat/DefaultRoomReactions.swift new file mode 100644 index 00000000..9924e27d --- /dev/null +++ b/Sources/AblyChat/DefaultRoomReactions.swift @@ -0,0 +1,87 @@ +import Ably + +// TODO: This class errors with "Task-isolated value of type '() async throws -> ()' passed as a strongly transferred parameter; later accesses could race". Adding @MainActor fixes this, revisit as part of https://github.com/ably-labs/ably-chat-swift/issues/83 +@MainActor +internal final class DefaultRoomReactions: RoomReactions, EmitsDiscontinuities { + private let roomID: String + public let featureChannel: FeatureChannel + private let logger: InternalLogger + private let clientID: String + + internal nonisolated var channel: any RealtimeChannelProtocol { + featureChannel.channel + } + + internal init(featureChannel: FeatureChannel, clientID: String, roomID: String, logger: InternalLogger) { + self.roomID = roomID + self.featureChannel = featureChannel + self.logger = logger + self.clientID = clientID + } + + // (CHA-ER3) Ephemeral room reactions are sent to Ably via the Realtime connection via a send method. + // (CHA-ER3a) Reactions are sent on the channel using a message in a particular format - see spec for format. + internal func send(params: SendReactionParams) async throws { + let extras = ["headers": params.headers ?? [:]] as ARTJsonCompatible + channel.publish(RoomReactionEvents.reaction.rawValue, data: params.asQueryItems(), extras: extras) + } + + // (CHA-ER4) A user may subscribe to reaction events in Realtime. + // (CHA-ER4a) A user may provide a listener to subscribe to reaction events. This operation must have no side-effects in relation to room or underlying status. When a realtime message with name roomReaction is received, this message is converted into a reaction object and emitted to subscribers. + internal func subscribe(bufferingPolicy: BufferingPolicy) async -> Subscription { + let subscription = Subscription(bufferingPolicy: bufferingPolicy) + + // (CHA-ER4c) Realtime events with an unknown name shall be silently discarded. + channel.subscribe(RoomReactionEvents.reaction.rawValue) { [clientID, logger] message in + Task { + do { + guard let data = message.data as? [String: Any], + let reactionType = data["type"] as? String + else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data or text") + } + + guard let messageClientID = message.clientId else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without clientId") + } + + guard let timestamp = message.timestamp else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without timestamp") + } + + guard let extras = try message.extras?.toJSON() else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without extras") + } + + let metadata = data["metadata"] as? Metadata + let headers = extras["headers"] as? Headers + + // (CHA-ER4d) Realtime events that are malformed (unknown fields should be ignored) shall not be emitted to listeners. + let reaction = Reaction( + type: reactionType, + metadata: metadata ?? .init(), + headers: headers ?? .init(), + createdAt: timestamp, + clientID: messageClientID, + isSelf: messageClientID == clientID + ) + + subscription.emit(reaction) + } catch { + logger.log(message: "Error processing incoming reaction message: \(error)", level: .error) + } + } + } + + return subscription + } + + // (CHA-ER5) Users may subscribe to discontinuity events to know when there’s been a break in reactions that they need to resolve. Their listener will be called when a discontinuity event is triggered from the room lifecycle. + internal func subscribeToDiscontinuities() async -> Subscription { + await featureChannel.subscribeToDiscontinuities() + } + + private enum RoomReactionsError: Error { + case noReferenceToSelf + } +} diff --git a/Sources/AblyChat/Events.swift b/Sources/AblyChat/Events.swift index cd2d5fb0..73141dee 100644 --- a/Sources/AblyChat/Events.swift +++ b/Sources/AblyChat/Events.swift @@ -1,3 +1,7 @@ internal enum MessageEvent: String { case created = "message.created" } + +internal enum RoomReactionEvents: String { + case reaction = "roomReaction" +} diff --git a/Sources/AblyChat/Reaction.swift b/Sources/AblyChat/Reaction.swift index 7b23fb5c..9f6bd720 100644 --- a/Sources/AblyChat/Reaction.swift +++ b/Sources/AblyChat/Reaction.swift @@ -3,6 +3,7 @@ import Foundation public typealias ReactionHeaders = Headers public typealias ReactionMetadata = Metadata +// (CHA-ER2) A Reaction corresponds to a single reaction in a chat room. This is analogous to a single user-specified message on an Ably channel (NOTE: not a ProtocolMessage). public struct Reaction: Sendable { public var type: String public var metadata: ReactionMetadata diff --git a/Sources/AblyChat/Room.swift b/Sources/AblyChat/Room.swift index 076bc0fe..99653346 100644 --- a/Sources/AblyChat/Room.swift +++ b/Sources/AblyChat/Room.swift @@ -61,6 +61,7 @@ internal actor DefaultRoom private let chatAPI: ChatAPI public nonisolated let messages: any Messages + private let _reactions: (any RoomReactions)? // Exposed for testing. private nonisolated let realtime: RealtimeClient @@ -90,16 +91,25 @@ internal actor DefaultRoom logger: logger ) + // TODO: Address force unwrapping of `channels` within feature initialisation below: https://github.com/ably-labs/ably-chat-swift/issues/105 + messages = await DefaultMessages( featureChannel: featureChannels[.messages]!, chatAPI: chatAPI, roomID: roomID, clientID: clientId ) + + _reactions = options.reactions != nil ? await DefaultRoomReactions( + featureChannel: featureChannels[.reactions]!, + clientID: clientId, + roomID: roomID, + logger: logger + ) : nil } private static func createFeatureChannels(roomID: String, realtime: RealtimeClient) -> [RoomFeature: DefaultFeatureChannel] { - .init(uniqueKeysWithValues: [RoomFeature.messages].map { feature in + .init(uniqueKeysWithValues: [RoomFeature.messages, RoomFeature.reactions].map { feature in let channel = realtime.getChannel(feature.channelNameForRoomID(roomID)) let contributor = DefaultRoomLifecycleContributor(channel: .init(underlyingChannel: channel), feature: feature) @@ -112,7 +122,11 @@ internal actor DefaultRoom } public nonisolated var reactions: any RoomReactions { - fatalError("Not yet implemented") + guard let _reactions else { + fatalError("Reactions are not enabled for this room") + } + + return _reactions } public nonisolated var typing: any Typing { diff --git a/Sources/AblyChat/RoomFeature.swift b/Sources/AblyChat/RoomFeature.swift index a006472b..d630c3e2 100644 --- a/Sources/AblyChat/RoomFeature.swift +++ b/Sources/AblyChat/RoomFeature.swift @@ -17,7 +17,10 @@ internal enum RoomFeature { case .messages: // (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. "chatMessages" - case .typing, .reactions, .presence, .occupancy: + case .reactions: + // (CHA-ER1) Reactions for a Room are sent on a corresponding realtime channel ::$chat::$reactions. For example, if your room id is my-room then the reactions channel will be my-room::$chat::$reactions. + "reactions" + case .typing, .presence, .occupancy: // We’ll add these, with reference to the relevant spec points, as we implement these features fatalError("Don’t know channel name suffix for room feature \(self)") } diff --git a/Sources/AblyChat/RoomReactions.swift b/Sources/AblyChat/RoomReactions.swift index b02c1800..b50b9212 100644 --- a/Sources/AblyChat/RoomReactions.swift +++ b/Sources/AblyChat/RoomReactions.swift @@ -17,3 +17,13 @@ public struct SendReactionParams: Sendable { self.headers = headers } } + +internal extension SendReactionParams { + // Same as `ARTDataQuery.asQueryItems` from ably-cocoa. + func asQueryItems() -> [String: String] { + var dict: [String: String] = [:] + dict["type"] = "\(type)" + dict["metadata"] = "\(metadata ?? [:])" + return dict + } +} diff --git a/Sources/AblyChat/Subscription.swift b/Sources/AblyChat/Subscription.swift index 2866eab0..8c46fc4d 100644 --- a/Sources/AblyChat/Subscription.swift +++ b/Sources/AblyChat/Subscription.swift @@ -71,6 +71,16 @@ public struct Subscription: Sendable, AsyncSequence { } } + // TODO: https://github.com/ably-labs/ably-chat-swift/issues/36 Revisit how we want to unsubscribe to fulfil CHA-M4b & CHA-ER4b. I think exposing this publicly for all Subscription types is suitable. + public func finish() { + switch mode { + case let .default(_, continuation): + continuation.finish() + case .mockAsyncSequence: + fatalError("`finish` cannot be called on a Subscription that was created using init(mockAsyncSequence:)") + } + } + public struct AsyncIterator: AsyncIteratorProtocol { fileprivate enum Mode { case `default`(iterator: AsyncStream.AsyncIterator) diff --git a/Tests/AblyChatTests/DefaultRoomReactionsTests.swift b/Tests/AblyChatTests/DefaultRoomReactionsTests.swift new file mode 100644 index 00000000..c0940795 --- /dev/null +++ b/Tests/AblyChatTests/DefaultRoomReactionsTests.swift @@ -0,0 +1,82 @@ +import Ably +@testable import AblyChat +import Testing + +struct DefaultRoomReactionsTests { + // @spec CHA-ER1 + @Test + func init_channelNameIsSetAsReactionsChannelName() async throws { + // Given + let channel = MockRealtimeChannel(name: "basketball::$chat::$reactions") + let featureChannel = MockFeatureChannel(channel: channel) + + // When + let defaultRoomReactions = await DefaultRoomReactions(featureChannel: featureChannel, clientID: "mockClientId", roomID: "basketball", logger: TestLogger()) + + // Then + #expect(defaultRoomReactions.channel.name == "basketball::$chat::$reactions") + } + + // @spec CHA-ER3a + @Test + func reactionsAreSentInTheCorrectFormat() async throws { + // channel name and roomID values are arbitrary + // Given + let channel = MockRealtimeChannel(name: "basketball::$chat::$reactions") + let featureChannel = MockFeatureChannel(channel: channel) + + // When + let defaultRoomReactions = await DefaultRoomReactions(featureChannel: featureChannel, clientID: "mockClientId", roomID: "basketball", logger: TestLogger()) + + let sendReactionParams = SendReactionParams( + type: "like", + metadata: ["test": MetadataValue.string("test")], + headers: ["test": HeadersValue.string("test")] + ) + + // When + try await defaultRoomReactions.send(params: sendReactionParams) + + // Then + #expect(channel.lastMessagePublishedName == RoomReactionEvents.reaction.rawValue) + #expect(channel.lastMessagePublishedData as? [String: String] == sendReactionParams.asQueryItems()) + #expect(channel.lastMessagePublishedExtras as? Dictionary == ["headers": sendReactionParams.headers]) + } + + // @spec CHA-ER4 + @Test + func subscribe_returnsSubscription() async throws { + // all setup values here are arbitrary + // Given + let channel = MockRealtimeChannel(name: "basketball::$chat::$reactions") + let featureChannel = MockFeatureChannel(channel: channel) + + // When + let defaultRoomReactions = await DefaultRoomReactions(featureChannel: featureChannel, clientID: "mockClientId", roomID: "basketball", logger: TestLogger()) + + // When + let subscription: Subscription? = await defaultRoomReactions.subscribe(bufferingPolicy: .unbounded) + + // Then + #expect(subscription != nil) + } + + // @spec CHA-ER5 + @Test + func subscribeToDiscontinuities() async throws { + // all setup values here are arbitrary + // Given: A DefaultRoomReactions instance + let channel = MockRealtimeChannel() + let featureChannel = MockFeatureChannel(channel: channel) + let roomReactions = await DefaultRoomReactions(featureChannel: featureChannel, clientID: "mockClientId", roomID: "basketball", logger: TestLogger()) + + // When: The feature channel emits a discontinuity through `subscribeToDiscontinuities` + let featureChannelDiscontinuity = ARTErrorInfo.createUnknownError() // arbitrary + let messagesDiscontinuitySubscription = await roomReactions.subscribeToDiscontinuities() + await featureChannel.emitDiscontinuity(featureChannelDiscontinuity) + + // Then: The DefaultRoomReactions instance emits this discontinuity through `subscribeToDiscontinuities` + let messagesDiscontinuity = try #require(await messagesDiscontinuitySubscription.first { _ in true }) + #expect(messagesDiscontinuity === featureChannelDiscontinuity) + } +} diff --git a/Tests/AblyChatTests/DefaultRoomTests.swift b/Tests/AblyChatTests/DefaultRoomTests.swift index eec8e94b..36fa48a2 100644 --- a/Tests/AblyChatTests/DefaultRoomTests.swift +++ b/Tests/AblyChatTests/DefaultRoomTests.swift @@ -11,6 +11,7 @@ struct DefaultRoomTests { // Given: a DefaultRoom instance let channelsList = [ MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .success), + MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .success), // required as DefaultRoom attaches reactions implicitly for now ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) @@ -32,6 +33,7 @@ struct DefaultRoomTests { // Given: a DefaultRoom instance let channelsList = [ MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .success), + MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .success), // required as DefaultRoom attaches reactions implicitly for now ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) @@ -68,6 +70,7 @@ struct DefaultRoomTests { // Given: a DefaultRoom instance let channelsList = [ MockRealtimeChannel(name: "basketball::$chat::$chatMessages", detachResult: .success), + MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .success), // required as DefaultRoom attaches reactions implicitly for now ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) @@ -100,6 +103,7 @@ struct DefaultRoomTests { // Given: a DefaultRoom instance let channelsList = [ MockRealtimeChannel(name: "basketball::$chat::$chatMessages"), + MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .success), // required as DefaultRoom attaches reactions implicitly for now ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) @@ -126,6 +130,7 @@ struct DefaultRoomTests { // Given: a DefaultRoom instance let channelsList = [ MockRealtimeChannel(name: "basketball::$chat::$chatMessages", detachResult: .success), + MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .success), // required as DefaultRoom attaches reactions implicitly for now ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) @@ -146,6 +151,7 @@ struct DefaultRoomTests { // Given: a DefaultRoom instance let channelsList = [ MockRealtimeChannel(name: "basketball::$chat::$chatMessages", detachResult: .success), + MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .success), // required as DefaultRoom attaches reactions implicitly for now ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) diff --git a/Tests/AblyChatTests/IntegrationTests.swift b/Tests/AblyChatTests/IntegrationTests.swift index fa975d70..3fffbfc1 100644 --- a/Tests/AblyChatTests/IntegrationTests.swift +++ b/Tests/AblyChatTests/IntegrationTests.swift @@ -22,6 +22,8 @@ struct IntegrationTests { @Test func basicIntegrationTest() async throws { + // MARK: - Setup + Attach + let apiKey = try await Sandbox.createAPIKey() // (1) Create a couple of chat clients — one for sending and one for receiving @@ -30,8 +32,8 @@ struct IntegrationTests { // (2) Fetch a room let roomID = "basketball" - let txRoom = try await txClient.rooms.get(roomID: roomID, options: .init()) - let rxRoom = try await rxClient.rooms.get(roomID: roomID, options: .init()) + let txRoom = try await txClient.rooms.get(roomID: roomID, options: .init(reactions: .init())) + let rxRoom = try await rxClient.rooms.get(roomID: roomID, options: .init(reactions: .init())) // (3) Subscribe to room status let rxRoomStatusSubscription = await rxRoom.onStatusChange(bufferingPolicy: .unbounded) @@ -43,6 +45,8 @@ struct IntegrationTests { _ = try #require(await rxRoomStatusSubscription.first { $0.current == .attached }) #expect(await rxRoom.status == .attached) + // MARK: - Send and receive messages + // (6) Send a message before subscribing to messages, so that later on we can check history works. // Create a throwaway subscription and wait for it to receive a message. This is to make sure that rxRoom has seen the message that we send here, so that the first message we receive on the subscription created in (7) is that which we’ll send in (8), and not that which we send here. @@ -68,21 +72,35 @@ struct IntegrationTests { try #require(rxMessagesBeforeSubscribing.items.count == 1) #expect(rxMessagesBeforeSubscribing.items[0] == txMessageBeforeRxSubscribe) - // (10) Detach the room + // MARK: - Reactions + + // (10) Subscribe to reactions + let rxReactionSubscription = await rxRoom.reactions.subscribe(bufferingPolicy: .unbounded) + + // (11) Now that we’re subscribed to reactions, send a reaction on the other client and check that we receive it on the subscription + try await txRoom.reactions.send(params: .init(type: "heart")) + let rxReactionFromSubscription = try #require(await rxReactionSubscription.first { _ in true }) + #expect(rxReactionFromSubscription.type == "heart") + + // MARK: - Detach + + // (12) Detach the room try await rxRoom.detach() - // (11) Check that we received a DETACHED status change as a result of detaching the room + // (13) Check that we received a DETACHED status change as a result of detaching the room _ = try #require(await rxRoomStatusSubscription.first { $0.current == .detached }) #expect(await rxRoom.status == .detached) - // (12) Release the room + // MARK: - Release + + // (14) Release the room try await rxClient.rooms.release(roomID: roomID) - // (13) Check that we received a RELEASED status change as a result of releasing the room + // (15) Check that we received a RELEASED status change as a result of releasing the room _ = try #require(await rxRoomStatusSubscription.first { $0.current == .released }) #expect(await rxRoom.status == .released) - // (14) Fetch the room we just released and check it’s a new object + // (16) Fetch the room we just released and check it’s a new object let postReleaseRxRoom = try await rxClient.rooms.get(roomID: roomID, options: .init()) #expect(postReleaseRxRoom !== rxRoom) } diff --git a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift index 67213d10..2d2c3f3c 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift @@ -8,6 +8,11 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { var properties: ARTChannelProperties { .init(attachSerial: attachSerial, channelSerial: channelSerial) } + // I don't see why the nonisolated(unsafe) keyword would cause a problem when used for tests in this context. + nonisolated(unsafe) var lastMessagePublishedName: String? + nonisolated(unsafe) var lastMessagePublishedData: Any? + nonisolated(unsafe) var lastMessagePublishedExtras: (any ARTJsonCompatible)? + init( name: String? = nil, properties: ARTChannelProperties = .init(), @@ -199,8 +204,10 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { fatalError("Not implemented") } - func publish(_: String?, data _: Any?, extras _: (any ARTJsonCompatible)?) { - fatalError("Not implemented") + func publish(_ name: String?, data: Any?, extras: (any ARTJsonCompatible)?) { + lastMessagePublishedName = name + lastMessagePublishedExtras = extras + lastMessagePublishedData = data } func publish(_: String?, data _: Any?, extras _: (any ARTJsonCompatible)?, callback _: ARTCallback? = nil) { From f7f19ccc84d26c167ddd826e0ab5a683b022c0d9 Mon Sep 17 00:00:00 2001 From: Umair Date: Thu, 14 Nov 2024 12:58:59 +0000 Subject: [PATCH 7/7] Removes reserved keyspaces in line with CHADR-066 - https://ably.atlassian.net/wiki/spaces/CHA/pages/3438116905/CHADR-066+Removing+Reserved+Keyspace --- Sources/AblyChat/ChatAPI.swift | 12 ------ Tests/AblyChatTests/ChatAPITests.swift | 59 -------------------------- 2 files changed, 71 deletions(-) diff --git a/Sources/AblyChat/ChatAPI.swift b/Sources/AblyChat/ChatAPI.swift index 947a4f1f..5142db50 100644 --- a/Sources/AblyChat/ChatAPI.swift +++ b/Sources/AblyChat/ChatAPI.swift @@ -32,22 +32,10 @@ internal final class ChatAPI: Sendable { // (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) diff --git a/Tests/AblyChatTests/ChatAPITests.swift b/Tests/AblyChatTests/ChatAPITests.swift index 53691da9..4c37d4df 100644 --- a/Tests/AblyChatTests/ChatAPITests.swift +++ b/Tests/AblyChatTests/ChatAPITests.swift @@ -5,65 +5,6 @@ import Testing struct ChatAPITests { // 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