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..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 @@ -29,7 +34,28 @@ public struct RoomStatusChange: Sendable, Equatable { } } -internal actor DefaultRoom: Room where LifecycleManagerFactory.Contributor == DefaultRoomLifecycleContributor { +internal protocol RoomFactory: Sendable { + associatedtype Room: AblyChat.InternalRoom + + 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: InternalRoom where LifecycleManagerFactory.Contributor == DefaultRoomLifecycleContributor { internal nonisolated let roomID: String internal nonisolated let options: RoomOptions private let chatAPI: ChatAPI @@ -40,12 +66,7 @@ internal actor DefaultRoom private nonisolated let realtime: RealtimeClient private let lifecycleManager: any RoomLifecycleManager - - #if DEBUG - internal nonisolated var testsOnly_realtime: RealtimeClient { - realtime - } - #endif + private let channels: [RoomFeature: any RealtimeChannelProtocol] private let logger: InternalLogger @@ -60,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( @@ -115,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 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,13 +43,28 @@ internal actor DefaultRooms 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/DefaultChatClientTests.swift b/Tests/AblyChatTests/DefaultChatClientTests.swift index e433e837..92b5d85d 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 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 69167d41..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 { @@ -10,18 +12,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 +38,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 +59,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 @@ -71,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 new file mode 100644 index 00000000..7039e218 --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockRoom.swift @@ -0,0 +1,60 @@ +@testable import AblyChat + +actor MockRoom: InternalRoom { + let options: RoomOptions + private(set) var releaseCallCount = 0 + let releaseImplementation: (@Sendable () async -> Void)? + + init(options: RoomOptions, releaseImplementation: (@Sendable () async -> Void)? = nil) { + self.options = options + self.releaseImplementation = releaseImplementation + } + + 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") + } + + 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 new file mode 100644 index 00000000..f24ad3d1 --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockRoomFactory.swift @@ -0,0 +1,24 @@ +@testable import AblyChat + +actor MockRoomFactory: RoomFactory { + 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) + + guard let room else { + fatalError("MockRoomFactory.createRoom called, but the mock factory has not been set up with a room to return") + } + + return room + } +} 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")