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..0b84e32d 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,41 @@ 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 + let hasExistingRoomAtMomentRoomReleaseCalled = await hasExistingRoomAtMomentRoomReleaseCalledStreamComponents.stream.first { _ in true } + #expect(try !#require(hasExistingRoomAtMomentRoomReleaseCalled)) + + #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..151e682f 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() + _releaseArguments.append(name) + mutex.unlock() + } + + 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")