diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1771ced8..0920a9f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,3 +28,46 @@ 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.) +- When writing code that implements behaviour specified by the Chat SDK features spec, add a comment that references the identifier of the relevant spec item. + +### Testing guidelines + +#### Attributing tests to a spec point + +When writing a test that relates to a spec point from the Chat SDK features spec, add a comment that contains one of the following tags: + +- `@spec ` — The test case directly tests all the functionality documented in the spec item. +- `@specOneOf(m/n) ` — The test case is the mth of n test cases which, together, test all the functionality documented in the spec item. +- `@specPartial ` — The test case tests some, but not all, of the functionality documented in the spec item. This is different to `@specOneOf` in that it implies that the test suite does not fully test this spec item. + +The `` parameter should be a spec item identifier such as `CHA-RL3g`. + +Each of the above tags can optionally be followed by a hyphen and an explanation of how the test relates to the given spec item. + +Examples: + +```swift +// @spec CHA-EX3f +func test1 { … } +``` + +```swift +// @specOneOf(1/2) CHA-EX2h — Tests the case where the room is FAILED +func test2 { … } + +// @specOneOf(2/2) CHA-EX2h — Tests the case where the room is SUSPENDED +func test3 { … } +``` + +```swift +// @specPartial CHA-EX1h4 - Tests that we retry, but not the retry attempt limit because we’ve not implemented it yet +func test4 { … } +``` + +In [#46](https://github.com/ably-labs/ably-chat-swift/issues/46), we’ll write a script that uses these tags to generate a report about how much of the feature spec we’ve implemented. + +#### Marking a spec point as untested + +In addition to the above, you can add the following as a comment anywhere in the test suite: + +- `@specUntested - ` — This indicates that the SDK implements the given spec point, but that there are no automated tests for it. This should be used sparingly; only use it when there is no way to test a spec point. It must be accompanied by an explanation of why this spec point is not tested. diff --git a/Sources/AblyChat/Errors.swift b/Sources/AblyChat/Errors.swift index 8a3070cf..d4d153c8 100644 --- a/Sources/AblyChat/Errors.swift +++ b/Sources/AblyChat/Errors.swift @@ -16,12 +16,43 @@ 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 + case messagesAttachmentFailed = 102_001 + case presenceAttachmentFailed = 102_002 + case reactionsAttachmentFailed = 102_003 + case occupancyAttachmentFailed = 102_004 + case typingAttachmentFailed = 102_005 + + case messagesDetachmentFailed = 102_050 + case presenceDetachmentFailed = 102_051 + case reactionsDetachmentFailed = 102_052 + case occupancyDetachmentFailed = 102_053 + case typingDetachmentFailed = 102_054 + + case roomInFailedState = 102_101 + case roomIsReleasing = 102_102 + case roomIsReleased = 102_103 + /// 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 + // TODO: These are currently a guess, revisit once outstanding spec question re status codes is answered (https://github.com/ably/specification/pull/200#discussion_r1755222945), and also revisit in https://github.com/ably-labs/ably-chat-swift/issues/32 switch self { - case .inconsistentRoomOptions: + case .inconsistentRoomOptions, + .messagesDetachmentFailed, + .presenceDetachmentFailed, + .reactionsDetachmentFailed, + .occupancyDetachmentFailed, + .typingDetachmentFailed, + .roomInFailedState, + .roomIsReleasing, + .roomIsReleased: 400 + case .messagesAttachmentFailed, + .presenceAttachmentFailed, + .reactionsAttachmentFailed, + .occupancyAttachmentFailed, + .typingAttachmentFailed: + // TODO: This is currently a best guess based on the limited status code information given in the spec at time of writing (i.e. CHA-RL1h4); it's not clear to me whether these error codes are always meant to have the same status code. Revisit once aforementioned spec question re status codes answered. + 500 } } } @@ -29,17 +60,87 @@ 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 attachmentFailed(feature: RoomFeature, underlyingError: ARTErrorInfo) + case detachmentFailed(feature: RoomFeature, underlyingError: ARTErrorInfo) + case roomInFailedState + case roomIsReleasing + case roomIsReleased /// The ``ARTErrorInfo.code`` that should be returned for this error. internal var code: ErrorCode { switch self { case .inconsistentRoomOptions: .inconsistentRoomOptions + case let .attachmentFailed(feature, _): + switch feature { + case .messages: + .messagesAttachmentFailed + case .occupancy: + .occupancyAttachmentFailed + case .presence: + .presenceAttachmentFailed + case .reactions: + .reactionsAttachmentFailed + case .typing: + .typingAttachmentFailed + } + case let .detachmentFailed(feature, _): + switch feature { + case .messages: + .messagesDetachmentFailed + case .occupancy: + .occupancyDetachmentFailed + case .presence: + .presenceDetachmentFailed + case .reactions: + .reactionsDetachmentFailed + case .typing: + .typingDetachmentFailed + } + case .roomInFailedState: + .roomInFailedState + case .roomIsReleasing: + .roomIsReleasing + case .roomIsReleased: + .roomIsReleased + } + } + + /// A helper type for parameterising the construction of error messages. + private enum AttachOrDetach { + case attach + case detach + } + + private static func localizedDescription( + forFailureOfOperation operation: AttachOrDetach, + feature: RoomFeature + ) -> String { + let featureDescription = switch feature { + case .messages: + "messages" + case .occupancy: + "occupancy" + case .presence: + "presence" + case .reactions: + "reactions" + case .typing: + "typing" } + + let operationDescription = switch operation { + case .attach: + "attach" + case .detach: + "detach" + } + + return "The \(featureDescription) feature failed to \(operationDescription)." } /// The ``ARTErrorInfo.localizedDescription`` that should be returned for this error. @@ -47,6 +148,31 @@ 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 let .attachmentFailed(feature, _): + Self.localizedDescription(forFailureOfOperation: .attach, feature: feature) + case let .detachmentFailed(feature, _): + Self.localizedDescription(forFailureOfOperation: .detach, feature: feature) + case .roomInFailedState: + "Cannot perform operation because the room is in a failed state." + case .roomIsReleasing: + "Cannot perform operation because the room is in a releasing state." + case .roomIsReleased: + "Cannot perform operation because the room is in a released state." + } + } + + /// The ``ARTErrorInfo.cause`` that should be returned for this error. + internal var cause: ARTErrorInfo? { + switch self { + case let .attachmentFailed(_, underlyingError): + underlyingError + case let .detachmentFailed(_, underlyingError): + underlyingError + case .inconsistentRoomOptions, + .roomInFailedState, + .roomIsReleasing, + .roomIsReleased: + nil } } } @@ -58,6 +184,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/RoomFeature.swift b/Sources/AblyChat/RoomFeature.swift new file mode 100644 index 00000000..e88aead5 --- /dev/null +++ b/Sources/AblyChat/RoomFeature.swift @@ -0,0 +1,8 @@ +/// The features offered by a chat room. +internal enum RoomFeature { + case messages + case presence + case reactions + case occupancy + case typing +} diff --git a/Sources/AblyChat/RoomLifecycleManager.swift b/Sources/AblyChat/RoomLifecycleManager.swift new file mode 100644 index 00000000..6ddc8c34 --- /dev/null +++ b/Sources/AblyChat/RoomLifecycleManager.swift @@ -0,0 +1,274 @@ +import Ably + +/// The interface that the lifecycle manager expects its contributing realtime channels to conform to. +/// +/// We use this instead of the ``RealtimeChannel`` interface as its ``attach`` and ``detach`` methods are `async` instead of using callbacks. This makes it easier to write mocks for (since ``RealtimeChannel`` doesn’t express to the type system that the callbacks it receives need to be `Sendable`, it’s hard to, for example, create a mock that creates a `Task` and then calls the callback from inside this task). +/// +/// We choose to also mark the channel’s mutable state as `async`. This is a way of highlighting at the call site of accessing this state that, since `ARTRealtimeChannel` mutates this state on a separate thread, it’s possible for this state to have changed since the last time you checked it, or since the last time you performed an operation that might have mutated it, or since the last time you recieved an event informing you that it changed. To be clear, marking these as `async` doesn’t _solve_ these issues; it just makes them a bit more visible. We’ll decide how to address them in https://github.com/ably-labs/ably-chat-swift/issues/49. +internal protocol RoomLifecycleContributorChannel: Sendable { + // TODO: In Swift 6, mark these throws as typed to ARTErrorInfo, to avoid having to cast (https://github.com/ably-labs/ably-chat-swift/issues/21) + func attach() async throws + func detach() async throws + + var state: ARTRealtimeChannelState { get async } + var errorReason: ARTErrorInfo? { get async } +} + +internal actor RoomLifecycleManager { + /// A realtime channel that contributes to the room lifecycle. + internal struct Contributor { + /// The room feature that this contributor corresponds to. Used only for choosing which error to throw when a contributor operation fails. + internal var feature: RoomFeature + + internal var channel: Channel + } + + internal private(set) var current: RoomLifecycle = .initialized + internal private(set) var error: ARTErrorInfo? + + private let logger: InternalLogger + private let clock: SimpleClock + private let contributors: [Contributor] + + internal init(contributors: [Contributor], logger: InternalLogger, clock: SimpleClock) { + self.contributors = contributors + self.logger = logger + self.clock = clock + } + + internal init(forTestingWhatHappensWhenCurrentlyIn current: RoomLifecycle, contributors: [Contributor], logger: InternalLogger, clock: SimpleClock) { + self.current = current + self.contributors = contributors + self.logger = logger + self.clock = clock + } + + // 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: .roomIsReleasing) + case .released: + // CHA-RL1c + throw ARTErrorInfo(chatError: .roomIsReleased) + 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 \(contributor)", level: .info) + try await contributor.channel.attach() + } catch { + // TODO: remove cast once aforementioned typed throw implemented (https://github.com/ably-labs/ably-chat-swift/issues/21) + // swiftlint:disable:next force_cast + let contributorAttachError = error as! ARTErrorInfo + + let contributorState = await contributor.channel.state + logger.log(message: "Failed to attach contributor \(contributor), which is now in state \(contributorState), error \(contributorAttachError)", level: .info) + + switch contributorState { + case .suspended: + // CHA-RL1h2 + let error = ARTErrorInfo(chatError: .attachmentFailed(feature: contributor.feature, underlyingError: contributorAttachError)) + changeStatus(to: .suspended, error: error) + + // CHA-RL1h3 + throw error + case .failed: + // CHA-RL1h4 + let error = ARTErrorInfo(chatError: .attachmentFailed(feature: contributor.feature, underlyingError: contributorAttachError)) + changeStatus(to: .failed, error: error) + + // CHA-RL1h5 + // TODO: Implement the "asynchronously with respect to CHA-RL1h4" part of CHA-RL1h5 (https://github.com/ably-labs/ably-chat-swift/issues/50) + await detachNonFailedContributors() + + throw error + default: + // TODO: The spec assumes the channel will be in one of the above states, but working in a multi-threaded environment means it might not be (https://github.com/ably-labs/ably-chat-swift/issues/49) + 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 await (contributor.channel.state) != .failed { + // CHA-RL1h6: Retry until detach succeeds + while true { + do { + logger.log(message: "Detaching non-failed contributor \(contributor)", level: .info) + try await contributor.channel.detach() + break + } catch { + logger.log(message: "Failed to detach non-failed contributor \(contributor), error \(error). Retrying.", level: .info) + // Loop repeats + } + } + } + } + + /// Implements CHA-RL2’s DETACH operation. + internal func performDetachOperation() async throws { + switch current { + case .detached: + // CHA-RL2a + return + case .releasing: + // CHA-RL2b + throw ARTErrorInfo(chatError: .roomIsReleasing) + case .released: + // CHA-RL2c + throw ARTErrorInfo(chatError: .roomIsReleased) + case .failed: + // CHA-RL2d + throw ARTErrorInfo(chatError: .roomInFailedState) + case .initialized, .suspended, .attaching, .attached, .detaching: + break + } + + // CHA-RL2e + changeStatus(to: .detaching) + + // CHA-RL2f + var firstDetachError: Error? + for contributor in contributors { + logger.log(message: "Detaching contributor \(contributor)", level: .info) + do { + try await contributor.channel.detach() + } catch { + let contributorState = await contributor.channel.state + logger.log(message: "Failed to detach contributor \(contributor), which is now in state \(contributorState), error \(error)", level: .info) + + switch contributorState { + case .failed: + // CHA-RL2h1 + guard let contributorError = await contributor.channel.errorReason else { + // TODO: The spec assumes this will be populated, but working in a multi-threaded environment means it might not be (https://github.com/ably-labs/ably-chat-swift/issues/49) + preconditionFailure("Contributor entered FAILED but its errorReason is not set") + } + + let error = ARTErrorInfo(chatError: .detachmentFailed(feature: contributor.feature, underlyingError: contributorError)) + + if firstDetachError == nil { + // We’ll throw this after we’ve tried detaching all the channels + firstDetachError = error + } + + // This check is CHA-RL2h2 + if current != .failed { + changeStatus(to: .failed, error: error) + } + default: + // CHA-RL2h3: Retry until detach succeeds, with a pause before each attempt + while true { + do { + logger.log(message: "Will attempt to detach non-failed contributor \(contributor) in 1s.", level: .info) + // TODO: what's the correct wait time? (https://github.com/ably/specification/pull/200#discussion_r1763799223) + try await clock.sleep(nanoseconds: 1_000_000_000) + logger.log(message: "Detaching non-failed contributor \(contributor)", level: .info) + try await contributor.channel.detach() + break + } catch { + // Loop repeats + logger.log(message: "Failed to detach non-failed contributor \(contributor), error \(error). Will retry.", level: .info) + } + } + } + } + } + + if let firstDetachError { + // CHA-RL2f + throw firstDetachError + } + + // CHA-RL2g + changeStatus(to: .detached) + } + + /// Implementes CHA-RL3’s RELEASE operation. + internal func performReleaseOperation() async { + switch current { + case .released: + // CHA-RL3a + return + case .detached: + // CHA-RL3b + changeStatus(to: .released) + return + case .releasing, .initialized, .attached, .attaching, .detaching, .suspended, .failed: + break + } + + changeStatus(to: .releasing) + + // CHA-RL3d + for contributor in contributors { + detachLoop: while true { + let contributorState = await contributor.channel.state + + // CHA-RL3e + guard contributorState != .failed else { + logger.log(message: "Contributor \(contributor) is FAILED; skipping detach", level: .info) + break + } + + logger.log(message: "Detaching contributor \(contributor)", level: .info) + do { + try await contributor.channel.detach() + break detachLoop + } catch { + // CHA-RL3f: Retry until detach succeeds, with a pause before each attempt + logger.log(message: "Failed to detach contributor \(contributor), error \(error). Will retry in 1s.", level: .info) + // TODO: Make this not trap in the case where the Task is cancelled (as part of the broader https://github.com/ably-labs/ably-chat-swift/issues/29 for handling task cancellation) + // TODO: what's the correct wait time? (https://github.com/ably/specification/pull/200#discussion_r1763822207) + // swiftlint:disable:next force_try + try! await clock.sleep(nanoseconds: 1_000_000_000) + // Loop repeats + } + } + } + + // CHA-RL3g + changeStatus(to: .released) + } +} diff --git a/Sources/AblyChat/SimpleClock.swift b/Sources/AblyChat/SimpleClock.swift new file mode 100644 index 00000000..a7aeac15 --- /dev/null +++ b/Sources/AblyChat/SimpleClock.swift @@ -0,0 +1,7 @@ +/// A clock that causes the current task to sleep. +/// +/// Exists for mocking in tests. Note that we can’t use the Swift `Clock` type since it doesn’t exist in our minimum supported OS versions. +internal protocol SimpleClock: Sendable { + /// Behaves like `Task.sleep(nanoseconds:)`. + func sleep(nanoseconds duration: UInt64) async throws +} diff --git a/Tests/AblyChatTests/Helpers/Helpers.swift b/Tests/AblyChatTests/Helpers/Helpers.swift index 669c23ed..b37909bf 100644 --- a/Tests/AblyChatTests/Helpers/Helpers.swift +++ b/Tests/AblyChatTests/Helpers/Helpers.swift @@ -5,11 +5,30 @@ import XCTest /** Asserts that a given optional `Error` is an `ARTErrorInfo` in the chat error domain with a given code. */ -func assertIsChatError(_ maybeError: (any Error)?, withCode code: AblyChat.ErrorCode, file: StaticString = #filePath, line: UInt = #line) throws { +func assertIsChatError(_ maybeError: (any Error)?, withCode code: AblyChat.ErrorCode, cause: ARTErrorInfo? = nil, file: StaticString = #filePath, line: UInt = #line) throws { let error = try XCTUnwrap(maybeError, "Expected an error", file: file, line: line) let ablyError = try XCTUnwrap(error as? ARTErrorInfo, "Expected an ARTErrorInfo", file: file, line: line) XCTAssertEqual(ablyError.domain, AblyChat.errorDomain as String, file: file, line: line) XCTAssertEqual(ablyError.code, code.rawValue, file: file, line: line) XCTAssertEqual(ablyError.statusCode, code.statusCode, file: file, line: line) + XCTAssertEqual(ablyError.cause, cause, 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, cause: ARTErrorInfo? = nil, _ expression: () async throws -> Void, 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, cause: cause, file: file, line: line) } diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift new file mode 100644 index 00000000..3778b750 --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift @@ -0,0 +1,95 @@ +import Ably +@testable import AblyChat + +final actor MockRoomLifecycleContributorChannel: RoomLifecycleContributorChannel { + private let attachBehavior: AttachOrDetachBehavior? + private let detachBehavior: AttachOrDetachBehavior? + + var state: ARTRealtimeChannelState + var errorReason: ARTErrorInfo? + + private(set) var attachCallCount = 0 + private(set) var detachCallCount = 0 + + init( + initialState: ARTRealtimeChannelState, + attachBehavior: AttachOrDetachBehavior?, + detachBehavior: AttachOrDetachBehavior? + ) { + state = initialState + self.attachBehavior = attachBehavior + self.detachBehavior = detachBehavior + } + + enum AttachOrDetachResult { + case success + case failure(ARTErrorInfo) + + func performCallback(_ callback: ARTCallback?) { + switch self { + case .success: + callback?(nil) + case let .failure(error): + callback?(error) + } + } + } + + 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(@Sendable (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 attach() async throws { + attachCallCount += 1 + + guard let attachBehavior else { + fatalError("attachBehavior must be set before attach is called") + } + + try await performBehavior(attachBehavior, callCount: attachCallCount) + } + + func detach() async throws { + detachCallCount += 1 + + guard let detachBehavior else { + fatalError("detachBehavior must be set before detach is called") + } + + try await performBehavior(detachBehavior, callCount: detachCallCount) + } + + private func performBehavior(_ behavior: AttachOrDetachBehavior, callCount: Int) async throws { + let result: AttachOrDetachResult + switch behavior { + case let .fromFunction(function): + result = await function(callCount) + case let .complete(completeResult): + result = completeResult + case let .completeAndChangeState(completeResult, newState): + state = newState + if case let .failure(error) = completeResult { + errorReason = error + } + result = completeResult + } + + switch result { + case .success: + return + case let .failure(error): + throw error + } + } +} diff --git a/Tests/AblyChatTests/Mocks/MockSimpleClock.swift b/Tests/AblyChatTests/Mocks/MockSimpleClock.swift new file mode 100644 index 00000000..12e2bc00 --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockSimpleClock.swift @@ -0,0 +1,10 @@ +@testable import AblyChat + +/// A mock implementation of ``SimpleClock`` which records its arguments but does not actually sleep. +actor MockSimpleClock: SimpleClock { + private(set) var sleepCallArguments: [UInt64] = [] + + func sleep(nanoseconds duration: UInt64) async throws { + sleepCallArguments.append(duration) + } +} diff --git a/Tests/AblyChatTests/RoomLifecycleManagerTests.swift b/Tests/AblyChatTests/RoomLifecycleManagerTests.swift new file mode 100644 index 00000000..154f88ba --- /dev/null +++ b/Tests/AblyChatTests/RoomLifecycleManagerTests.swift @@ -0,0 +1,710 @@ +import Ably +@testable import AblyChat +import XCTest + +final class RoomLifecycleManagerTests: XCTestCase { + // MARK: - Test helpers + + /// A mock implementation of a realtime channel’s `attach` or `detach` operation. Its ``complete(result:)`` method allows you to signal to the mock that the mocked operation should complete with a given result. + final class SignallableChannelOperation: Sendable { + private let continuation: AsyncStream.Continuation + + /// When this behavior is set as a ``MockRealtimeChannel``’s `attachBehavior` or `detachBehavior`, calling ``complete(result:)`` will cause the corresponding channel operation to complete with the result passed to that method. + let behavior: MockRoomLifecycleContributorChannel.AttachOrDetachBehavior + + init() { + let (stream, continuation) = AsyncStream.makeStream(of: MockRoomLifecycleContributorChannel.AttachOrDetachResult.self) + self.continuation = continuation + + behavior = .fromFunction { _ in + await (stream.first { _ in true })! + } + } + + /// Causes the async function embedded in ``behavior`` to return with the given result. + func complete(result: MockRoomLifecycleContributorChannel.AttachOrDetachResult) { + continuation.yield(result) + } + } + + private func createManager( + contributors: [RoomLifecycleManager.Contributor] = [], + clock: SimpleClock = MockSimpleClock() + ) -> RoomLifecycleManager { + .init(contributors: contributors, logger: TestLogger(), clock: clock) + } + + private func createManager( + forTestingWhatHappensWhenCurrentlyIn current: RoomLifecycle, + contributors: [RoomLifecycleManager.Contributor] = [], + clock: SimpleClock = MockSimpleClock() + ) -> RoomLifecycleManager { + .init(forTestingWhatHappensWhenCurrentlyIn: current, contributors: contributors, logger: TestLogger(), clock: clock) + } + + private func createContributor( + initialState: ARTRealtimeChannelState = .initialized, + feature: RoomFeature = .messages, // Arbitrarily chosen, its value only matters in test cases where we check which error is thrown + attachBehavior: MockRoomLifecycleContributorChannel.AttachOrDetachBehavior? = nil, + detachBehavior: MockRoomLifecycleContributorChannel.AttachOrDetachBehavior? = nil + ) -> RoomLifecycleManager.Contributor { + .init(feature: feature, channel: .init(initialState: initialState, attachBehavior: attachBehavior, detachBehavior: detachBehavior)) + } + + // MARK: - Initial state + + // @spec CHA-RS2a + // @spec CHA-RS3 + func test_current_startsAsInitialized() async { + let manager = createManager() + + let current = await manager.current + XCTAssertEqual(current, .initialized) + } + + func test_error_startsAsNil() async { + let manager = createManager() + let error = await manager.error + XCTAssertNil(error) + } + + // MARK: - ATTACH operation + + // @spec CHA-RL1a + func test_attach_whenAlreadyAttached() async throws { + // Given: A RoomLifecycleManager in the ATTACHED state + let contributor = createContributor() + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .attached, contributors: [contributor]) + + // When: `performAttachOperation()` is called on the lifecycle manager + try await manager.performAttachOperation() + + // Then: The room attach operation succeeds, and no attempt is made to attach a contributor (which we’ll consider as satisfying the spec’s requirement that a "no-op" happen) + let attachCallCount = await contributor.channel.attachCallCount + XCTAssertEqual(attachCallCount, 0) + } + + // @spec CHA-RL1b + func test_attach_whenReleasing() async throws { + // Given: A RoomLifecycleManager in the RELEASING state + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .releasing) + + // When: `performAttachOperation()` is called on the lifecycle manager + // Then: It throws a roomIsReleasing error + try await assertThrowsARTErrorInfo(withCode: .roomIsReleasing) { + try await manager.performAttachOperation() + } + } + + // @spec CHA-RL1c + func test_attach_whenReleased() async throws { + // Given: A RoomLifecycleManager in the RELEASED state + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .released) + + // When: `performAttachOperation()` is called on the lifecycle manager + // Then: It throws a roomIsReleased error + try await assertThrowsARTErrorInfo(withCode: .roomIsReleased) { + try await manager.performAttachOperation() + } + } + + // @spec CHA-RL1e + func test_attach_transitionsToAttaching() async throws { + // Given: A RoomLifecycleManager, with a contributor on whom calling `attach()` will not complete until after the "Then" part of this test (the motivation for this is to suppress the room from transitioning to ATTACHED, so that we can assert its current state as being ATTACHING) + let contributorAttachOperation = SignallableChannelOperation() + + let manager = createManager(contributors: [createContributor(attachBehavior: contributorAttachOperation.behavior)]) + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let statusChange = statusChangeSubscription.first { _ in true } + + // When: `performAttachOperation()` is called on the lifecycle manager + async let _ = try await manager.performAttachOperation() + + // Then: It emits a status change to ATTACHING, and its current state is ATTACHING + guard let statusChange = await statusChange else { + XCTFail("Expected status change but didn’t get one") + return + } + XCTAssertEqual(statusChange.current, .attaching) + + let current = await manager.current + XCTAssertEqual(current, .attaching) + + // Post-test: Now that we’ve seen the ATTACHING state, allow the contributor `attach` call to complete + contributorAttachOperation.complete(result: .success) + } + + // @spec CHA-RL1f + // @spec CHA-RL1g + func test_attach_attachesAllContributors_andWhenTheyAllAttachSuccessfully_transitionsToAttached() async throws { + // Given: A RoomLifecycleManager, all of whose contributors’ calls to `attach` succeed + let contributors = (1 ... 3).map { _ in createContributor(attachBehavior: .complete(.success)) } + let manager = createManager(contributors: contributors) + + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let attachedStatusChange = statusChangeSubscription.first { $0.current == .attached } + + // When: `performAttachOperation()` is called on the lifecycle manager + try await manager.performAttachOperation() + + // Then: It calls `attach` on all the contributors, the room attach operation succeeds, it emits a status change to ATTACHED, and its current state is ATTACHED + for contributor in contributors { + let attachCallCount = await contributor.channel.attachCallCount + XCTAssertGreaterThan(attachCallCount, 0) + } + + guard let statusChange = await attachedStatusChange else { + XCTFail("Expected status change to ATTACHED but didn't get one") + return + } + + XCTAssertEqual(statusChange.current, .attached) + + let current = await manager.current + XCTAssertEqual(current, .attached) + } + + // @spec CHA-RL1h2 + // @specOneOf(1/2) CHA-RL1h1 - tests that an error gets thrown when channel attach fails due to entering SUSPENDED (TODO: but I don’t yet fully understand the meaning of CHA-RL1h1; outstanding question https://github.com/ably/specification/pull/200/files#r1765476610) + // @specPartial CHA-RL1h3 - Have tested the failure of the operation and the error that’s thrown. Have not yet implemented the "enter the recovery loop" (TODO: https://github.com/ably-labs/ably-chat-swift/issues/50) + func test_attach_whenContributorFailsToAttachAndEntersSuspended_transitionsToSuspended() async throws { + // Given: A RoomLifecycleManager, one of whose contributors’ call to `attach` fails causing it to enter the SUSPENDED state + let contributorAttachError = ARTErrorInfo(domain: "SomeDomain", code: 123) + let contributors = (1 ... 3).map { i in + if i == 1 { + createContributor(attachBehavior: .completeAndChangeState(.failure(contributorAttachError), newState: .suspended)) + } else { + createContributor(attachBehavior: .complete(.success)) + } + } + + let manager = createManager(contributors: contributors) + + 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 the AttachmentFailed code corresponding to the feature of the failed contributor, `cause` equal to the error thrown by the contributor `attach` call + // 2. the manager’s `error` is set to this same error + // 3. the room attach operation fails with this same error + guard let suspendedStatusChange = await suspendedStatusChange else { + XCTFail("Expected status change to FAILED but didn’t get one") + return + } + + let current = await manager.current + XCTAssertEqual(current, .suspended) + + var roomAttachError: Error? + do { + _ = try await roomAttachResult + } catch { + roomAttachError = error + } + + for error in await [suspendedStatusChange.error, manager.error, roomAttachError] { + try assertIsChatError(error, withCode: .messagesAttachmentFailed, cause: contributorAttachError) + } + } + + // @specOneOf(2/2) CHA-RL1h1 - tests that an error gets thrown when channel attach fails due to entering FAILED (TODO: but I don’t yet fully understand the meaning of CHA-RL1h1; outstanding question https://github.com/ably/specification/pull/200/files#r1765476610)) + // @spec CHA-RL1h4 + func test_attach_whenContributorFailsToAttachAndEntersFailed_transitionsToFailed() async throws { + // Given: A RoomLifecycleManager, one of whose contributors’ call to `attach` fails causing it to enter the FAILED state + let contributorAttachError = ARTErrorInfo(domain: "SomeDomain", code: 123) + let contributors = (1 ... 3).map { i in + if i == 1 { + createContributor( + feature: .messages, // arbitrary + attachBehavior: .completeAndChangeState(.failure(contributorAttachError), newState: .failed) + ) + } else { + createContributor( + feature: .occupancy, // arbitrary, just needs to be different to that used for the other contributor + attachBehavior: .complete(.success), + // The room is going to try to detach per CHA-RL1h5, 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 = createManager(contributors: contributors) + + 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 the AttachmentFailed code corresponding to the feature of the failed contributor, `cause` equal to the error thrown by the contributor `attach` call + // 2. the manager’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 + } + + let current = await manager.current + XCTAssertEqual(current, .failed) + + var roomAttachError: Error? + do { + _ = try await roomAttachResult + } catch { + roomAttachError = error + } + + for error in await [failedStatusChange.error, manager.error, roomAttachError] { + try assertIsChatError(error, withCode: .messagesAttachmentFailed, cause: contributorAttachError) + } + } + + // @specPartial CHA-RL1h5 - My initial understanding of this spec point was that the "detach all non-failed channels" was meant to happen _inside_ the ATTACH operation, and that’s what I implemented. Andy subsequently updated the spec to clarify that it’s meant to happen _outside_ the ATTACH operation. I’ll implement this as a separate piece of work later (TODO: https://github.com/ably-labs/ably-chat-swift/issues/50) + func test_attach_whenAttachPutsChannelIntoFailedState_detachesAllNonFailedChannels() 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 = [ + createContributor( + attachBehavior: .completeAndChangeState(.success, newState: .attached), + detachBehavior: .complete(.success) + ), + createContributor( + attachBehavior: .completeAndChangeState(.failure(.create(withCode: 123, message: "")), newState: .failed) + ), + createContributor( + detachBehavior: .complete(.success) + ), + ] + + let manager = createManager(contributors: contributors) + + // 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 + let contributor0DetachCallCount = await contributors[0].channel.detachCallCount + XCTAssertGreaterThan(contributor0DetachCallCount, 0) + let contributor2DetachCallCount = await contributors[2].channel.detachCallCount + XCTAssertGreaterThan(contributor2DetachCallCount, 0) + let contributor1DetachCallCount = await contributors[1].channel.detachCallCount + XCTAssertEqual(contributor1DetachCallCount, 0) + } + + // @spec CHA-RL1h6 + func test_attach_whenChannelDetachTriggered_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 = { @Sendable (callCount: Int) async -> MockRoomLifecycleContributorChannel.AttachOrDetachResult in + if callCount == 1 { + return .failure(.create(withCode: 123, message: "")) + } else { + return .success + } + } + + let contributors = [ + createContributor( + attachBehavior: .completeAndChangeState(.success, newState: .attached), + detachBehavior: .fromFunction(detachResult) + ), + createContributor( + attachBehavior: .completeAndChangeState(.failure(.create(withCode: 123, message: "")), newState: .failed) + ), + ] + + let manager = createManager(contributors: contributors) + + // 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) + let detachCallCount = await contributors[0].channel.detachCallCount + XCTAssertEqual(detachCallCount, 2) + } + + // MARK: - DETACH operation + + // @spec CHA-RL2a + func test_detach_whenAlreadyDetached() async throws { + // Given: A RoomLifecycleManager in the DETACHED state + let contributor = createContributor() + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .detached, contributors: [contributor]) + + // When: `performDetachOperation()` is called on the lifecycle manager + try await manager.performDetachOperation() + + // Then: The room detach operation succeeds, and no attempt is made to detach a contributor (which we’ll consider as satisfying the spec’s requirement that a "no-op" happen) + let detachCallCount = await contributor.channel.detachCallCount + XCTAssertEqual(detachCallCount, 0) + } + + // @spec CHA-RL2b + func test_detach_whenReleasing() async throws { + // Given: A RoomLifecycleManager in the RELEASING state + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .releasing) + + // When: `performDetachOperation()` is called on the lifecycle manager + // Then: It throws a roomIsReleasing error + try await assertThrowsARTErrorInfo(withCode: .roomIsReleasing) { + try await manager.performDetachOperation() + } + } + + // @spec CHA-RL2c + func test_detach_whenReleased() async throws { + // Given: A RoomLifecycleManager in the RELEASED state + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .released) + + // When: `performAttachOperation()` is called on the lifecycle manager + // Then: It throws a roomIsReleased error + try await assertThrowsARTErrorInfo(withCode: .roomIsReleased) { + try await manager.performDetachOperation() + } + } + + // @spec CHA-RL2d + func test_detach_whenFailed() async throws { + // Given: A RoomLifecycleManager in the FAILED state + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .failed) + + // When: `performAttachOperation()` is called on the lifecycle manager + // Then: It throws a roomInFailedState error + try await assertThrowsARTErrorInfo(withCode: .roomInFailedState) { + try await manager.performDetachOperation() + } + } + + // @specPartial CHA-RL2e - Haven’t implemented the part that refers to "transient disconnect timeouts"; TODO do this (https://github.com/ably-labs/ably-chat-swift/issues/48) + func test_detach_transitionsToDetaching() async throws { + // Given: A RoomLifecycleManager, with a contributor on whom calling `detach()` will not complete until after the "Then" part of this test (the motivation for this is to suppress the room from transitioning to DETACHED, so that we can assert its current state as being DETACHING) + let contributorDetachOperation = SignallableChannelOperation() + + let manager = createManager(contributors: [createContributor(detachBehavior: contributorDetachOperation.behavior)]) + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let statusChange = statusChangeSubscription.first { _ in true } + + // When: `performDetachOperation()` is called on the lifecycle manager + async let _ = try await manager.performDetachOperation() + + // Then: It emits a status change to DETACHING, and its current state is DETACHING + guard let statusChange = await statusChange else { + XCTFail("Expected status change but didn’t get one") + return + } + XCTAssertEqual(statusChange.current, .detaching) + + let current = await manager.current + XCTAssertEqual(current, .detaching) + + // Post-test: Now that we’ve seen the DETACHING state, allow the contributor `detach` call to complete + contributorDetachOperation.complete(result: .success) + } + + // @spec CHA-RL2f + // @spec CHA-RL2g + func test_detach_detachesAllContributors_andWhenTheyAllDetachSuccessfully_transitionsToDetached() async throws { + // Given: A RoomLifecycleManager, all of whose contributors’ calls to `detach` succeed + let contributors = (1 ... 3).map { _ in createContributor(detachBehavior: .complete(.success)) } + let manager = createManager(contributors: contributors) + + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let detachedStatusChange = statusChangeSubscription.first { $0.current == .detached } + + // When: `performDetachOperation()` is called on the lifecycle manager + try await manager.performDetachOperation() + + // Then: It calls `detach` on all the contributors, the room detach operation succeeds, it emits a status change to DETACHED, and its current state is DETACHED + for contributor in contributors { + let detachCallCount = await contributor.channel.detachCallCount + XCTAssertGreaterThan(detachCallCount, 0) + } + + guard let statusChange = await detachedStatusChange else { + XCTFail("Expected status change to DETACHED but didn't get one") + return + } + + XCTAssertEqual(statusChange.current, .detached) + + let current = await manager.current + XCTAssertEqual(current, .detached) + } + + // @spec CHA-RL2h1 + func test_detach_whenAContributorFailsToDetachAndEntersFailed_detachesRemainingContributorsAndTransitionsToFailed() async throws { + // Given: A RoomLifecycleManager, which has 4 contributors: + // + // 0: calling `detach` succeeds + // 1: calling `detach` fails, causing that contributor to subsequently be in the FAILED state + // 2: calling `detach` fails, causing that contributor to subsequently be in the FAILED state + // 3: calling `detach` succeeds + let contributor1DetachError = ARTErrorInfo(domain: "SomeDomain", code: 123) + let contributor2DetachError = ARTErrorInfo(domain: "SomeDomain", code: 456) + + let contributors = [ + // Features arbitrarily chosen, just need to be distinct in order to make assertions about errors later + createContributor(feature: .messages, detachBehavior: .success), + createContributor(feature: .presence, detachBehavior: .completeAndChangeState(.failure(contributor1DetachError), newState: .failed)), + createContributor(feature: .reactions, detachBehavior: .completeAndChangeState(.failure(contributor2DetachError), newState: .failed)), + createContributor(feature: .typing, detachBehavior: .success), + ] + + let manager = createManager(contributors: contributors) + + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let failedStatusChange = statusChangeSubscription.first { $0.current == .failed } + + // When: `performDetachOperation()` is called on the lifecycle manager + let maybeRoomDetachError: Error? + do { + try await manager.performDetachOperation() + maybeRoomDetachError = nil + } catch { + maybeRoomDetachError = error + } + + // Then: It: + // - calls `detach` on all of the contributors + // - emits a state change to FAILED and the call to `performDetachOperation()` fails; the error associated with the state change and the `performDetachOperation()` has the *DetachmentFailed code corresponding to contributor 1’s feature, and its `cause` is contributor 1’s `errorReason` (contributor 1 because it’s the "first feature to fail" as the spec says) + // TODO: Understand whether it’s `errorReason` or the contributor `detach` thrown error that’s meant to be use (outstanding question https://github.com/ably/specification/pull/200/files#r1763792152) + for contributor in contributors { + let detachCallCount = await contributor.channel.detachCallCount + XCTAssertGreaterThan(detachCallCount, 0) + } + + guard let failedStateChange = await failedStatusChange else { + XCTFail("Expected state change to FAILED") + return + } + + for maybeError in [maybeRoomDetachError, failedStateChange.error] { + try assertIsChatError(maybeError, withCode: .presenceDetachmentFailed, cause: contributor1DetachError) + } + } + + // @specUntested CHA-RL2h2 - I was unable to find a way to test this spec point in an environment in which concurrency is being used; there is no obvious moment at which to stop observing the emitted state changes in order to be sure that FAILED has not been emitted twice. + + // @spec CHA-RL2h3 + func test_detach_whenAContributorFailsToDetachAndEntersANonFailedState_pausesAWhileThenRetriesDetach() async throws { + // Given: A RoomLifecycleManager, with a contributor for whom: + // + // - the first two times `detach` is called, it throws an error, leaving it in the ATTACHED state + // - the third time `detach` is called, it succeeds + let detachImpl = { @Sendable (callCount: Int) async -> MockRoomLifecycleContributorChannel.AttachOrDetachResult in + if callCount < 3 { + return .failure(ARTErrorInfo(domain: "SomeDomain", code: 123)) // exact error is unimportant + } + return .success + } + let contributor = createContributor(initialState: .attached, detachBehavior: .fromFunction(detachImpl)) + let clock = MockSimpleClock() + + let manager = createManager(contributors: [contributor], clock: clock) + + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let asyncLetStatusChanges = Array(statusChangeSubscription.prefix(2)) + + // When: `performDetachOperation()` is called on the manager + try await manager.performDetachOperation() + + // Then: It attempts to detach the channel 3 times, waiting 1s between each attempt, the room transitions from DETACHING to DETACHED with no status updates in between, and the call to `performDetachOperation()` succeeds + let detachCallCount = await contributor.channel.detachCallCount + XCTAssertEqual(detachCallCount, 3) + + // We use "did it call clock.sleep(…)?" as a good-enough proxy for the question "did it wait for the right amount of time at the right moment?" + let clockSleepArguments = await clock.sleepCallArguments + XCTAssertEqual(clockSleepArguments, Array(repeating: 1_000_000_000, count: 2)) + + let statusChanges = await asyncLetStatusChanges + XCTAssertEqual(statusChanges.map(\.current), [.detaching, .detached]) + } + + // MARK: - RELEASE operation + + // @spec CHA-RL3a + func test_release_whenAlreadyReleased() async { + // Given: A RoomLifecycleManager in the RELEASED state + let contributor = createContributor() + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .released, contributors: [contributor]) + + // When: `performReleaseOperation()` is called on the lifecycle manager + await manager.performReleaseOperation() + + // Then: The room release operation succeeds, and no attempt is made to detach a contributor (which we’ll consider as satisfying the spec’s requirement that a "no-op" happen) + let detachCallCount = await contributor.channel.detachCallCount + XCTAssertEqual(detachCallCount, 0) + } + + // @spec CHA-RL3b + func test_release_whenDetached() async { + // Given: A RoomLifecycleManager in the DETACHED state + let contributor = createContributor() + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .detached, contributors: [contributor]) + + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let statusChange = statusChangeSubscription.first { _ in true } + + // When: `performReleaseOperation()` is called on the lifecycle manager + await manager.performReleaseOperation() + + // Then: The room release operation succeeds, the room transitions to RELEASED, and no attempt is made to detach a contributor (which we’ll consider as satisfying the spec’s requirement that the transition be "immediate") + guard let statusChange = await statusChange else { + XCTFail("Expected status change") + return + } + + XCTAssertEqual(statusChange.current, .released) + + let current = await manager.current + XCTAssertEqual(current, .released) + + let detachCallCount = await contributor.channel.detachCallCount + XCTAssertEqual(detachCallCount, 0) + } + + // @specPartial CHA-RL3c - Haven’t implemented the part that refers to "transient disconnect timeouts"; TODO do this (https://github.com/ably-labs/ably-chat-swift/issues/48) + func test_release_transitionsToReleasing() async { + // Given: A RoomLifecycleManager, with a contributor on whom calling `detach()` will not complete until after the "Then" part of this test (the motivation for this is to suppress the room from transitioning to RELEASED, so that we can assert its current state as being RELEASING) + let contributorDetachOperation = SignallableChannelOperation() + + let manager = createManager(contributors: [createContributor(detachBehavior: contributorDetachOperation.behavior)]) + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let statusChange = statusChangeSubscription.first { _ in true } + + // When: `performReleaseOperation()` is called on the lifecycle manager + async let _ = await manager.performReleaseOperation() + + // Then: It emits a status change to RELEASING, and its current state is RELEASING + guard let statusChange = await statusChange else { + XCTFail("Expected status change but didn’t get one") + return + } + XCTAssertEqual(statusChange.current, .releasing) + + let current = await manager.current + XCTAssertEqual(current, .releasing) + + // Post-test: Now that we’ve seen the RELEASING state, allow the contributor `detach` call to complete + contributorDetachOperation.complete(result: .success) + } + + // @spec CHA-RL3d + // @specOneOf(1/2) CHA-RL3e + // @spec CHA-RL3g + func test_release_detachesAllNonFailedContributors() async throws { + // Given: A RoomLifecycleManager, with the following contributors: + // - two in a non-FAILED state, and on whom calling `detach()` succeeds + // - one in the FAILED state + let contributors = [ + createContributor(initialState: .attached /* arbitrary non-FAILED */, detachBehavior: .complete(.success)), + createContributor(initialState: .failed, detachBehavior: .complete(.failure(.init(domain: "SomeDomain", code: 123) /* arbitrary error */ ))), + createContributor(initialState: .detached /* arbitrary non-FAILED */, detachBehavior: .complete(.success)), + ] + + let manager = createManager(contributors: contributors) + + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let releasedStatusChange = statusChangeSubscription.first { $0.current == .released } + + // When: `performReleaseOperation()` is called on the lifecycle manager + await manager.performReleaseOperation() + + // Then: + // - it calls `detach()` on the non-FAILED contributors + // - it does not call `detach()` on the FAILED contributor + // - the room transitions to RELEASED + // - the call to `performReleaseOperation()` completes + for nonFailedContributor in [contributors[0], contributors[2]] { + let detachCallCount = await nonFailedContributor.channel.detachCallCount + XCTAssertEqual(detachCallCount, 1) + } + + let failedContributorDetachCallCount = await contributors[1].channel.detachCallCount + XCTAssertEqual(failedContributorDetachCallCount, 0) + + _ = await releasedStatusChange + + let current = await manager.current + XCTAssertEqual(current, .released) + } + + // @spec CHA-RL3f + func test_release_whenDetachFails_ifContributorIsNotFailed_retriesAfterPause() async { + // Given: A RoomLifecycleManager, with a contributor for which: + // - the first two times that `detach()` is called, it fails, leaving the contributor in a non-FAILED state + // - the third time that `detach()` is called, it succeeds + let detachImpl = { @Sendable (callCount: Int) async -> MockRoomLifecycleContributorChannel.AttachOrDetachResult in + if callCount < 3 { + return .failure(ARTErrorInfo(domain: "SomeDomain", code: 123)) // exact error is unimportant + } + return .success + } + let contributor = createContributor(detachBehavior: .fromFunction(detachImpl)) + + let clock = MockSimpleClock() + + let manager = createManager(contributors: [contributor], clock: clock) + + // Then: When `performReleaseOperation()` is called on the manager + await manager.performReleaseOperation() + + // It: calls `detach()` on the channel 3 times, with a 1s pause between each attempt, and the call to `performReleaseOperation` completes + let detachCallCount = await contributor.channel.detachCallCount + XCTAssertEqual(detachCallCount, 3) + + // We use "did it call clock.sleep(…)?" as a good-enough proxy for the question "did it wait for the right amount of time at the right moment?" + let clockSleepArguments = await clock.sleepCallArguments + XCTAssertEqual(clockSleepArguments, Array(repeating: 1_000_000_000, count: 2)) + } + + // @specOneOf(2/2) CHA-RL3e - Tests that this spec point suppresses CHA-RL3f retries + func test_release_whenDetachFails_ifContributorIsFailed_doesNotRetry() async { + // Given: A RoomLifecycleManager, with a contributor for which, when `detach()` is called, it fails, causing the contributor to enter the FAILED state + let contributor = createContributor(detachBehavior: .completeAndChangeState(.failure(.init(domain: "SomeDomain", code: 123) /* arbitrary error */ ), newState: .failed)) + + let clock = MockSimpleClock() + + let manager = createManager(contributors: [contributor], clock: clock) + + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let releasedStatusChange = statusChangeSubscription.first { $0.current == .released } + + // When: `performReleaseOperation()` is called on the lifecycle manager + await manager.performReleaseOperation() + + // Then: + // - it calls `detach()` precisely once on the contributor (that is, it does not retry) + // - it waits 1s (TODO: confirm my interpretation of CHA-RL3f, which is that the wait still happens, but is not followed by a retry; have asked in https://github.com/ably/specification/pull/200/files#r1765372854) + // - the room transitions to RELEASED + // - the call to `performReleaseOperation()` completes + let detachCallCount = await contributor.channel.detachCallCount + XCTAssertEqual(detachCallCount, 1) + + // We use "did it call clock.sleep(…)?" as a good-enough proxy for the question "did it wait for the right amount of time at the right moment?" + let clockSleepArguments = await clock.sleepCallArguments + XCTAssertEqual(clockSleepArguments, [1_000_000_000]) + + _ = await releasedStatusChange + + let current = await manager.current + XCTAssertEqual(current, .released) + } +}