diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1501f9c6..79fb7bef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,6 +28,7 @@ To check formatting and code quality, run `swift run BuildTool lint`. Run with ` - We should aim to make it easy for consumers of the SDK to be able to mock out the SDK in the tests for their own code. A couple of things that will aid with this: - Describe the SDK’s functionality via protocols (when doing so would still be sufficiently idiomatic to Swift). - When defining a `struct` that is emitted by the public API of the library, make sure to define a public memberwise initializer so that users can create one to be emitted by their mocks. (There is no way to make Swift’s autogenerated memberwise initializer public, so you will need to write one yourself. In Xcode, you can do this by clicking at the start of the type declaration and doing Editor → Refactor → Generate Memberwise Initializer.) +- TODO something about adding spec points in code and tests ## Building for Swift 6 diff --git a/Sources/AblyChat/Errors.swift b/Sources/AblyChat/Errors.swift index 8a3070cf..6a4ccd23 100644 --- a/Sources/AblyChat/Errors.swift +++ b/Sources/AblyChat/Errors.swift @@ -16,11 +16,18 @@ public enum ErrorCode: Int { /// TODO this code is a guess, revisit in https://github.com/ably-labs/ably-chat-swift/issues/32 case inconsistentRoomOptions = 1 + // TODO: describe, and code is a guess + case channelAttachResultedInSuspended = 2 + case channelAttachResultedInFailed = 3 + + case attachWhenReleasing = 102_102 // CHA-RL1b + case attachWhenReleased = 102_103 // CHA-RL1c + /// The ``ARTErrorInfo.statusCode`` that should be returned for this error. internal var statusCode: Int { // TODO: These are currently a guess, revisit in https://github.com/ably-labs/ably-chat-swift/issues/32 switch self { - case .inconsistentRoomOptions: + case .inconsistentRoomOptions, .channelAttachResultedInSuspended, .channelAttachResultedInFailed, .attachWhenReleasing, .attachWhenReleased: 400 } } @@ -29,16 +36,28 @@ public enum ErrorCode: Int { /** The errors thrown by the Chat SDK. - This type exists in addition to ``ErrorCode`` to allow us to attach metadata which can be incorporated into the error’s `localizedDescription`. + This type exists in addition to ``ErrorCode`` to allow us to attach metadata which can be incorporated into the error’s `localizedDescription` and `cause`. */ internal enum ChatError { case inconsistentRoomOptions(requested: RoomOptions, existing: RoomOptions) + case channelAttachResultedInSuspended(underlyingError: ARTErrorInfo) + case channelAttachResultedInFailed(underlyingError: ARTErrorInfo) + case attachWhenReleasing + case attachWhenReleased /// The ``ARTErrorInfo.code`` that should be returned for this error. internal var code: ErrorCode { switch self { case .inconsistentRoomOptions: .inconsistentRoomOptions + case .channelAttachResultedInSuspended: + .channelAttachResultedInSuspended + case .channelAttachResultedInFailed: + .channelAttachResultedInFailed + case .attachWhenReleasing: + .attachWhenReleasing + case .attachWhenReleased: + .attachWhenReleased } } @@ -47,6 +66,28 @@ internal enum ChatError { switch self { case let .inconsistentRoomOptions(requested, existing): "Rooms.get(roomID:options:) was called with a different set of room options than was used on a previous call. You must first release the existing room instance using Rooms.release(roomID:). Requested options: \(requested), existing options: \(existing)" + case .channelAttachResultedInSuspended: + "TODO" + case .channelAttachResultedInFailed: + "TODO" + case .attachWhenReleasing: + "Attempted to attach room that's in RELEASING state" + case .attachWhenReleased: + "Attempted to attach room that's in RELEASED state" + } + } + + /// The ``ARTErrorInfo.cause`` that should be returned for this error. + internal var cause: ARTErrorInfo? { + switch self { + case let .channelAttachResultedInSuspended(underlyingError): + underlyingError + case let .channelAttachResultedInFailed(underlyingError): + underlyingError + case .inconsistentRoomOptions, + .attachWhenReleasing, + .attachWhenReleased: + nil } } } @@ -58,6 +99,11 @@ internal extension ARTErrorInfo { userInfo["ARTErrorInfoStatusCode"] = chatError.code.statusCode userInfo[NSLocalizedDescriptionKey] = chatError.localizedDescription + // TODO: This is kind of an implementation detail (that NSUnderlyingErrorKey is what populates `cause`); consider documenting in ably-cocoa as part of https://github.com/ably-labs/ably-chat-swift/issues/32. + if let cause = chatError.cause { + userInfo[NSUnderlyingErrorKey] = cause + } + self.init( domain: errorDomain, code: chatError.code.rawValue, diff --git a/Sources/AblyChat/RoomLifecycleManager.swift b/Sources/AblyChat/RoomLifecycleManager.swift new file mode 100644 index 00000000..51d27d8a --- /dev/null +++ b/Sources/AblyChat/RoomLifecycleManager.swift @@ -0,0 +1,130 @@ +import Ably + +// TODO: integrate with the rest of the SDK +internal actor RoomLifecycleManager { + internal private(set) var current: RoomLifecycle = .initialized + internal private(set) var error: ARTErrorInfo? + + private let logger: InternalLogger + private let contributors: [RealtimeChannelProtocol] + + internal init(contributors: [RealtimeChannelProtocol] = [], logger: InternalLogger) { + self.contributors = contributors + self.logger = logger + } + + internal init(forTestingWhatHappensWhenCurrentlyIn current: RoomLifecycle, contributors: [RealtimeChannelProtocol] = [], logger: InternalLogger) { + self.current = current + self.contributors = contributors + self.logger = logger + } + + // TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36) + private var subscriptions: [Subscription] = [] + + internal func onChange(bufferingPolicy: BufferingPolicy) -> Subscription { + let subscription: Subscription = .init(bufferingPolicy: bufferingPolicy) + subscriptions.append(subscription) + return subscription + } + + /// Updates ``current`` and ``error`` and emits a status change event. + private func changeStatus(to new: RoomLifecycle, error: ARTErrorInfo? = nil) { + logger.log(message: "Transitioning from \(current) to \(new), error \(String(describing: error))", level: .info) + let previous = current + current = new + self.error = error + let statusChange = RoomStatusChange(current: current, previous: previous, error: error) + emitStatusChange(statusChange) + } + + private func emitStatusChange(_ change: RoomStatusChange) { + for subscription in subscriptions { + subscription.emit(change) + } + } + + /// Implements CHA-RL1’s `ATTACH` operation. + internal func performAttachOperation() async throws { + switch current { + case .attached: + // CHA-RL1a + return + case .releasing: + // CHA-RL1b + throw ARTErrorInfo(chatError: .attachWhenReleasing) + case .released: + // CHA-RL1c + throw ARTErrorInfo(chatError: .attachWhenReleased) + case .initialized, .suspended, .attaching, .detached, .detaching, .failed: + break + } + + // CHA-RL1e + changeStatus(to: .attaching) + + // CHA-RL1f + for contributor in contributors { + do { + logger.log(message: "Attaching contributor", level: .info) + try await contributor.attachAsync() + } catch { + let contributorState = contributor.state + logger.log(message: "Failed to attach contributor \(contributor), which is now in state \(contributorState), error \(error)", level: .info) + + switch contributorState { + case .suspended: + // CHA-RL1h2 + guard let contributorError = contributor.errorReason else { + // TODO: something about this + preconditionFailure("Contributor entered SUSPENDED but its errorReason is not set") + } + + let error = ARTErrorInfo(chatError: .channelAttachResultedInSuspended(underlyingError: contributorError)) + changeStatus(to: .suspended, error: error) + + // CHA-RL1h3 + throw contributorError + case .failed: + // CHA-RL1h4 + guard let contributorError = contributor.errorReason else { + // TODO: something about this + preconditionFailure("Contributor entered FAILED but its errorReason is not set") + } + + let error = ARTErrorInfo(chatError: .channelAttachResultedInFailed(underlyingError: contributorError)) + changeStatus(to: .failed, error: error) + + // CHA-RL1h5 + await detachNonFailedContributors() + + // CHA-RL1h1 + throw contributorError + default: + // TODO: something about this; quite possible due to thread timing stuff + preconditionFailure("Attach failure left contributor in unexpected state \(contributorState)") + } + } + } + + // CHA-RL1g + changeStatus(to: .attached) + } + + /// Implements CHA-RL1h5’s "detach all channels that are not in the FAILED state". + private func detachNonFailedContributors() async { + for contributor in contributors where contributor.state != .failed { + // CHA-RL1h6: Retry until detach succeeds + while true { + do { + logger.log(message: "Detaching non-failed contributor \(contributor)", level: .info) + try await contributor.detachAsync() + break + } catch { + logger.log(message: "Failed to detach non-failed contributor \(contributor), error \(error). Retrying.", level: .info) + // Loop repeats + } + } + } + } +} diff --git a/Tests/AblyChatTests/DefaultRoomTests.swift b/Tests/AblyChatTests/DefaultRoomTests.swift index d886dae0..038bec85 100644 --- a/Tests/AblyChatTests/DefaultRoomTests.swift +++ b/Tests/AblyChatTests/DefaultRoomTests.swift @@ -10,9 +10,9 @@ class DefaultRoomTests: XCTestCase { // - basketball::$chat::$typingIndicators // - basketball::$chat::$reactions let channelsList = [ - MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", attachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .success), + MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachBehavior: .success), + MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", attachBehavior: .success), + MockRealtimeChannel(name: "basketball::$chat::$reactions", attachBehavior: .success), ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) @@ -47,9 +47,9 @@ class DefaultRoomTests: XCTestCase { // and fails when called on channel basketball::$chat::$reactions let channelAttachError = ARTErrorInfo.createUnknownError() // arbitrary 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", attachBehavior: .success), + MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", attachBehavior: .success), + MockRealtimeChannel(name: "basketball::$chat::$reactions", attachBehavior: .failure(channelAttachError)), ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) @@ -76,9 +76,9 @@ class DefaultRoomTests: XCTestCase { // - basketball::$chat::$typingIndicators // - basketball::$chat::$reactions let channelsList = [ - MockRealtimeChannel(name: "basketball::$chat::$chatMessages", detachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", detachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$reactions", detachResult: .success), + MockRealtimeChannel(name: "basketball::$chat::$chatMessages", detachBehavior: .success), + MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", detachBehavior: .success), + MockRealtimeChannel(name: "basketball::$chat::$reactions", detachBehavior: .success), ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) @@ -113,9 +113,9 @@ class DefaultRoomTests: XCTestCase { // and fails when called on channel basketball::$chat::$reactions let channelDetachError = ARTErrorInfo.createUnknownError() // arbitrary let channelsList = [ - MockRealtimeChannel(name: "basketball::$chat::$chatMessages", detachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", detachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$reactions", detachResult: .failure(channelDetachError)), + MockRealtimeChannel(name: "basketball::$chat::$chatMessages", detachBehavior: .success), + MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", detachBehavior: .success), + MockRealtimeChannel(name: "basketball::$chat::$reactions", detachBehavior: .failure(channelDetachError)), ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) diff --git a/Tests/AblyChatTests/Helpers/Helpers.swift b/Tests/AblyChatTests/Helpers/Helpers.swift index 669c23ed..787b057e 100644 --- a/Tests/AblyChatTests/Helpers/Helpers.swift +++ b/Tests/AblyChatTests/Helpers/Helpers.swift @@ -13,3 +13,21 @@ func assertIsChatError(_ maybeError: (any Error)?, withCode code: AblyChat.Error XCTAssertEqual(ablyError.code, code.rawValue, file: file, line: line) XCTAssertEqual(ablyError.statusCode, code.statusCode, file: file, line: line) } + +/** + Asserts that a given async expression throws an `ARTErrorInfo` in the chat error domain with a given code. + + Doesn't take an autoclosure because for whatever reason one of our linting tools removes the `await` on the expression. + */ +func assertThrowsARTErrorInfo(withCode code: AblyChat.ErrorCode, _ expression: () async throws -> some Any, file: StaticString = #filePath, line: UInt = #line) async throws { + let caughtError: Error? + + do { + _ = try await expression() + caughtError = nil + } catch { + caughtError = error + } + + try assertIsChatError(caughtError, withCode: code, file: file, line: line) +} diff --git a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift index f01f70b2..5d3783ba 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift @@ -1,17 +1,17 @@ import Ably import AblyChat -final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { +final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol, @unchecked Sendable { private let _name: String? init( name: String? = nil, - attachResult: AttachOrDetachResult? = nil, - detachResult: AttachOrDetachResult? = nil + attachBehavior: AttachOrDetachBehavior? = nil, + detachBehavior: AttachOrDetachBehavior? = nil ) { _name = name - self.attachResult = attachResult - self.detachResult = detachResult + self.attachBehavior = attachBehavior + self.detachBehavior = detachBehavior } /// A threadsafe counter that starts at zero. @@ -27,10 +27,13 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { return value } - func increment() { + func increment() -> Int { + let value: Int mutex.lock() _value += 1 + value = _value mutex.unlock() + return value } var isZero: Bool { @@ -42,13 +45,9 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { } } - var state: ARTRealtimeChannelState { - fatalError("Not implemented") - } + @SynchronizedAccess var state: ARTRealtimeChannelState = .initialized - var errorReason: ARTErrorInfo? { - fatalError("Not implemented") - } + @SynchronizedAccess var errorReason: ARTErrorInfo? var options: ARTRealtimeChannelOptions? { fatalError("Not implemented") @@ -72,21 +71,61 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { } } - private let attachResult: AttachOrDetachResult? + enum AttachOrDetachBehavior { + /// Receives an argument indicating how many times (including the current call) the method for which this is providing a mock implementation has been called. + case fromFunction((Int) async -> AttachOrDetachResult) + case complete(AttachOrDetachResult) + case completeAndChangeState(AttachOrDetachResult, newState: ARTRealtimeChannelState) + + static var success: Self { + .complete(.success) + } + + static func failure(_ error: ARTErrorInfo) -> Self { + .complete(.failure(error)) + } + + func perform(callback: ARTCallback?, callCount: Int, channel: MockRealtimeChannel) { + Task { + let result: AttachOrDetachResult + switch self { + case let .fromFunction(function): + result = await function(callCount) + case let .complete(completeResult): + result = completeResult + case let .completeAndChangeState(completeResult, newState): + channel.state = newState + if case let .failure(error) = completeResult { + channel.errorReason = error + } + result = completeResult + } + + switch result { + case .success: + callback?(nil) + case let .failure(error): + callback?(error) + } + } + } + } + + private let attachBehavior: AttachOrDetachBehavior? let attachCallCounter = Counter() func attach(_ callback: ARTCallback? = nil) { - attachCallCounter.increment() + let callCount = attachCallCounter.increment() - guard let attachResult else { - fatalError("attachResult must be set before attach is called") + guard let attachBehavior else { + fatalError("attachBehavior must be set before attach is called") } - attachResult.performCallback(callback) + attachBehavior.perform(callback: callback, callCount: callCount, channel: self) } - private let detachResult: AttachOrDetachResult? + private let detachBehavior: AttachOrDetachBehavior? let detachCallCounter = Counter() @@ -95,13 +134,13 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { } func detach(_ callback: ARTCallback? = nil) { - detachCallCounter.increment() + let callCount = detachCallCounter.increment() - guard let detachResult else { - fatalError("detachResult must be set before detach is called") + guard let detachBehavior else { + fatalError("detachBehavior must be set before detach is called") } - detachResult.performCallback(callback) + detachBehavior.perform(callback: callback, callCount: callCount, channel: self) } func subscribe(_: @escaping ARTMessageCallback) -> ARTEventListener? { diff --git a/Tests/AblyChatTests/RoomLifecycleManagerTests.swift b/Tests/AblyChatTests/RoomLifecycleManagerTests.swift new file mode 100644 index 00000000..830efa20 --- /dev/null +++ b/Tests/AblyChatTests/RoomLifecycleManagerTests.swift @@ -0,0 +1,286 @@ +import Ably +@testable import AblyChat +import XCTest + +// TODO: note that this function can't be called multiple times +// TODO: document and see whether this is a good function +func makeAsyncFunction() -> (returnFromFunction: (MockRealtimeChannel.AttachOrDetachResult) -> Void, function: (Int) async -> MockRealtimeChannel.AttachOrDetachResult) { + let (stream, continuation) = AsyncStream.makeStream(of: MockRealtimeChannel.AttachOrDetachResult.self) + return ( + returnFromFunction: { result in + continuation.yield(result) + }, + function: { _ in + await (stream.first { _ in true })! + } + ) +} + +final class RoomLifecycleManagerTests: XCTestCase { + // @spec CHA-RS2a (TODO what's the best way to test this) + // @spec CHA-RS3 + func test_current_startsAsInitialized() async { + let manager = RoomLifecycleManager(logger: TestLogger()) + + let current = await manager.current + XCTAssertEqual(current, .initialized) + } + + func test_error_startsAsNil() async { + let manager = RoomLifecycleManager(logger: TestLogger()) + let error = await manager.error + XCTAssertNil(error) + } + + // @spec CHA-RL1a + func testAttachWhenAlreadyAttached() async throws { + let manager = RoomLifecycleManager(forTestingWhatHappensWhenCurrentlyIn: .attached, logger: TestLogger()) + + // TODO: is this the right place for this to go? + try await manager.performAttachOperation() + // TODO: how to check it's a no-op? what are the things that it might do? + } + + // @spec CHA-RL1b + func testAttachWhenReleasing() async throws { + let manager = RoomLifecycleManager(forTestingWhatHappensWhenCurrentlyIn: .releasing, logger: TestLogger()) + + try await assertThrowsARTErrorInfo(withCode: .attachWhenReleasing) { + try await manager.performAttachOperation() + } + } + + // @spec CHA-RL1c + func testAttachWhenReleased() async throws { + let manager = RoomLifecycleManager(forTestingWhatHappensWhenCurrentlyIn: .released, logger: TestLogger()) + + try await assertThrowsARTErrorInfo(withCode: .attachWhenReleased) { + try await manager.performAttachOperation() + } + } + + // @spec CHA-RL1e + func testAttachTransitionsToAttaching() async throws { + let (returnAttachResult, attachResult) = makeAsyncFunction() + + let manager = RoomLifecycleManager(contributors: [MockRealtimeChannel(attachBehavior: .fromFunction(attachResult))], logger: TestLogger()) + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let statusChange = statusChangeSubscription.first { _ in true } + + async let attachSignal: Void = try await manager.performAttachOperation() + + // Check that status change was emitted and that its `current` is as expected + guard let statusChange = await statusChange else { + XCTFail("Expected status change but didn’t get one") + return + } + + // Check that current status is as expected + let current = await manager.current + XCTAssertEqual(current, .attaching) + + // Now that we’ve seen the ATTACHING state, allow the contributor `attach` call to complete + returnAttachResult(.success) + _ = try await attachSignal + + XCTAssertEqual(statusChange.current, .attaching) + } + + // @spec CHA-RL1g + func testWhenAllContributorsAttachSuccessfullyItTransitionsToAttached() async throws { + let contributors = (1 ... 3).map { _ in MockRealtimeChannel(attachBehavior: .complete(.success)) } + let manager = RoomLifecycleManager(contributors: contributors, logger: TestLogger()) + + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let attachedStatusChange = statusChangeSubscription.first { $0.current == .attached } + + try await manager.performAttachOperation() + + guard let statusChange = await attachedStatusChange else { + XCTFail("Expected status change to attached but didn't get one") + return + } + + XCTAssertEqual(statusChange.current, .attached) + + // Check that current status is as expected + let current = await manager.current + XCTAssertEqual(current, .attached) + } + + // @spec CHA-RL1h2 + // @specpartial CHA-RL1h1 - tests that an error gets thrown when channel attach fails due to entering SUSPENDED + // @specpartial CHA-RL1h3 - tests which error gets thrown when room enters SUSPENDED + func testWhenContributorFailsToAttachAndEntersSuspendedItTransitionsToSuspended() async throws { + // Given: A RoomLifecycleManager, one of whose contributors’ call to `attach` fails causing it to enter the SUSPENDED state + let attachError = ARTErrorInfo(domain: "SomeDomain", code: 123) + let contributors = (1 ... 3).map { i in + if i == 1 { + MockRealtimeChannel(attachBehavior: .completeAndChangeState(.failure(attachError), newState: .suspended)) + } else { + MockRealtimeChannel(attachBehavior: .complete(.success)) + } + } + + let manager = RoomLifecycleManager(contributors: contributors, logger: TestLogger()) + + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let suspendedStatusChange = statusChangeSubscription.first { $0.current == .suspended } + + // When: `performAttachOperation()` is called on the lifecycle manager + async let roomAttachResult: Void = manager.performAttachOperation() + + // Then: + // + // 1. the room status transitions to SUSPENDED, with the state change’s `error` having `cause` equal to the channel’s `errorReason` + // 2. the room status’s `error` is set to this same error + // 3. the room attach operation fails with the channel’s `errorReason` + guard let suspendedStatusChange = await suspendedStatusChange else { + XCTFail("Expected status change to suspended but didn’t get one") + return + } + + XCTAssertEqual(suspendedStatusChange.error?.cause, attachError) + + let (current, error) = await (manager.current, manager.error) + XCTAssertEqual(current, .suspended) + XCTAssertEqual(error?.cause, attachError) + + var roomAttachError: Error? + do { + _ = try await roomAttachResult + } catch { + roomAttachError = error + } + + let roomAttachARTErrorInfo = try XCTUnwrap(roomAttachError as? ARTErrorInfo) + XCTAssertEqual(roomAttachARTErrorInfo, attachError) + } + + // @specpartial CHA-RL1h1 - tests that an error gets thrown when channel attach fails due to entering FAILED, but that spec point isn’t clear about what error should be thrown + // @spec CHA-RL1h4 + func testWhenContributorFailsToAttachAndEntersFailedItTransitionsToFailed() async throws { + // Given: A RoomLifecycleManager, one of whose contributors’ call to `attach` fails causing it to enter the FAILED state + let attachError = ARTErrorInfo(domain: "SomeDomain", code: 123) + let contributors = (1 ... 3).map { i in + if i == 1 { + MockRealtimeChannel( + attachBehavior: .completeAndChangeState(.failure(attachError), newState: .failed) + ) + } else { + MockRealtimeChannel( + attachBehavior: .complete(.success), + // The room is going to try to detach per CHA-RL1h6, so even though that's not what this test is testing, we need a detachBehavior so the mock doesn’t blow up + detachBehavior: .complete(.success) + ) + } + } + + let manager = RoomLifecycleManager(contributors: contributors, logger: TestLogger()) + + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let failedStatusChange = statusChangeSubscription.first { $0.current == .failed } + + // When: `performAttachOperation()` is called on the lifecycle manager + async let roomAttachResult: Void = manager.performAttachOperation() + + // Then: + // 1. the room status transitions to FAILED, with the state change’s `error` having `cause` equal to the channel’s `errorReason` + // 2. the room status’s `error` is set to this same error + // 3. the room attach operation fails with this same error + guard let failedStatusChange = await failedStatusChange else { + XCTFail("Expected status change to failed but didn’t get one") + return + } + + XCTAssertEqual(failedStatusChange.error?.cause, attachError) + + let (current, error) = await (manager.current, manager.error) + XCTAssertEqual(current, .failed) + XCTAssertEqual(error?.cause, attachError) + + var roomAttachError: Error? + do { + _ = try await roomAttachResult + } catch { + roomAttachError = error + } + + let roomAttachARTErrorInfo = try XCTUnwrap(roomAttachError as? ARTErrorInfo) + XCTAssertEqual(roomAttachARTErrorInfo, attachError) + } + + // @spec CHA-RL1h5 + func testWhenAttachPutsChannelIntoFailedStateItDetachesAllNonFailedChannels() async throws { + // Given: A room with the following contributors, in the following order: + // + // 0. a channel for whom calling `attach` will complete successfully, putting it in the ATTACHED state (i.e. an arbitrarily-chosen state that is not FAILED) + // 1. a channel for whom calling `attach` will fail, putting it in the FAILED state + // 2. a channel in the INITIALIZED state (another arbitrarily-chosen state that is not FAILED) + // + // for which, when `detach` is called on contributors 0 and 2 (i.e. the non-FAILED contributors), it completes successfully + let contributors = [ + MockRealtimeChannel( + attachBehavior: .completeAndChangeState(.success, newState: .attached), + detachBehavior: .complete(.success) + ), + MockRealtimeChannel( + attachBehavior: .completeAndChangeState(.failure(.create(withCode: 123, message: "")), newState: .failed) + ), + MockRealtimeChannel( + detachBehavior: .complete(.success) + ), + ] + + let manager = RoomLifecycleManager(contributors: contributors, logger: TestLogger()) + + // When: `performAttachOperation()` is called on the lifecycle manager + try? await manager.performAttachOperation() + + // Then: + // + // - the lifecycle manager will call `detach` on contributors 0 and 2 + // - the lifecycle manager will not call `detach` on contributor 1 + // + // (TODO Note that we aren’t testing that the room _waits_ for the detach calls to complete, because I didn’t think of a good way) + XCTAssertTrue(contributors[0].detachCallCounter.isNonZero) + XCTAssertTrue(contributors[2].detachCallCounter.isNonZero) + XCTAssertTrue(contributors[1].detachCallCounter.isZero) + } + + // @spec CHA-RL1h6 + func testWhenAttachPutsRoomIntoFailedState_ifADetachFailsItIsRetriedUntilSuccess() async throws { + // Given: A room with the following contributors, in the following order: + // + // 0. a channel: + // - for whom calling `attach` will complete successfully, putting it in the ATTACHED state (i.e. an arbitrarily-chosen state that is not FAILED) + // - and for whom subsequently calling `detach` will fail on the first attempt and succeed on the second + // 1. a channel for whom calling `attach` will fail, putting it in the FAILED state (we won’t make any assertions about this channel; it’s just to trigger the room’s channel detach behaviour) + + let detachResult = { (callCount: Int) async -> MockRealtimeChannel.AttachOrDetachResult in + if callCount == 1 { + return .failure(.create(withCode: 123, message: "")) + } else { + return .success + } + } + + let contributors = [ + MockRealtimeChannel( + attachBehavior: .completeAndChangeState(.success, newState: .attached), + detachBehavior: .fromFunction(detachResult) + ), + MockRealtimeChannel( + attachBehavior: .completeAndChangeState(.failure(.create(withCode: 123, message: "")), newState: .failed) + ), + ] + + let manager = RoomLifecycleManager(contributors: contributors, logger: TestLogger()) + + // When: `performAttachOperation()` is called on the lifecycle manager + try? await manager.performAttachOperation() + + // Then: the lifecycle manager will call `detach` twice on contributor 0 (i.e. it will retry the failed detach) + XCTAssertEqual(contributors[0].detachCallCounter.value, 2) + } +}