diff --git a/Sources/AblyChat/RoomLifecycleManager.swift b/Sources/AblyChat/RoomLifecycleManager.swift index dcdc7aad..f4590e5d 100644 --- a/Sources/AblyChat/RoomLifecycleManager.swift +++ b/Sources/AblyChat/RoomLifecycleManager.swift @@ -182,11 +182,14 @@ internal actor DefaultRoomLifecycleManager [Contributor] { + switch trigger { + case .detachOperation: + // CHA-RL2f + contributors + case let .retryOperation(_, triggeringContributor): + // CHA-RL5a + contributors.filter { $0.id != triggeringContributor.id } + } } // MARK: - RELEASE operation @@ -919,7 +974,7 @@ internal actor DefaultRoomLifecycleManager MockRoomLifecycleContributor { .init( feature: feature, channel: .init( initialState: initialState, + initialErrorReason: initialErrorReason, attachBehavior: attachBehavior, - detachBehavior: detachBehavior + detachBehavior: detachBehavior, + subscribeToStateBehavior: subscribeToStateBehavior ) ) } @@ -169,7 +173,7 @@ struct DefaultRoomLifecycleManagerTests { contributors: [ createContributor( // Arbitrary, allows the ATTACH to eventually complete - attachBehavior: .complete(.success), + attachBehavior: .success, // This allows us to prolong the execution of the DETACH triggered in (1) detachBehavior: contributorDetachOperation.behavior ), @@ -235,7 +239,7 @@ struct DefaultRoomLifecycleManagerTests { @Test func attach_attachesAllContributors_andWhenTheyAllAttachSuccessfully_transitionsToAttached() async throws { // Given: A DefaultRoomLifecycleManager, all of whose contributors’ calls to `attach` succeed - let contributors = (1 ... 3).map { _ in createContributor(attachBehavior: .complete(.success)) } + let contributors = (1 ... 3).map { _ in createContributor(attachBehavior: .success) } let manager = await createManager(contributors: contributors) let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) @@ -257,7 +261,7 @@ struct DefaultRoomLifecycleManagerTests { @Test func attach_uponSuccess_emitsPendingDiscontinuityEvents() async throws { // Given: A DefaultRoomLifecycleManager, all of whose contributors’ calls to `attach` succeed - let contributors = (1 ... 3).map { _ in createContributor(attachBehavior: .complete(.success)) } + let contributors = (1 ... 3).map { _ in createContributor(attachBehavior: .success) } let pendingDiscontinuityEvents: [MockRoomLifecycleContributor.ID: [ARTErrorInfo]] = [ contributors[1].id: [.init(domain: "SomeDomain", code: 123) /* arbitrary */ ], contributors[2].id: [.init(domain: "SomeDomain", code: 456) /* arbitrary */ ], @@ -291,7 +295,7 @@ struct DefaultRoomLifecycleManagerTests { @Test func attach_uponSuccess_clearsTransientDisconnectTimeouts() async throws { // Given: A DefaultRoomLifecycleManager, all of whose contributors’ calls to `attach` succeed - let contributors = (1 ... 3).map { _ in createContributor(attachBehavior: .complete(.success)) } + let contributors = (1 ... 3).map { _ in createContributor(attachBehavior: .success) } let manager = await createManager( forTestingWhatHappensWhenHasTransientDisconnectTimeoutForTheseContributorIDs: [contributors[1].id], contributors: contributors @@ -315,7 +319,7 @@ struct DefaultRoomLifecycleManagerTests { if i == 1 { createContributor(attachBehavior: .completeAndChangeState(.failure(contributorAttachError), newState: .suspended)) } else { - createContributor(attachBehavior: .complete(.success)) + createContributor(attachBehavior: .success) } } @@ -363,9 +367,9 @@ struct DefaultRoomLifecycleManagerTests { } else { createContributor( feature: .occupancy, // arbitrary, just needs to be different to that used for the other contributor - attachBehavior: .complete(.success), + attachBehavior: .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) + detachBehavior: .success ) } } @@ -411,13 +415,13 @@ struct DefaultRoomLifecycleManagerTests { let contributors = [ createContributor( attachBehavior: .completeAndChangeState(.success, newState: .attached), - detachBehavior: .complete(.success) + detachBehavior: .success ), createContributor( attachBehavior: .completeAndChangeState(.failure(.create(withCode: 123, message: "")), newState: .failed) ), createContributor( - detachBehavior: .complete(.success) + detachBehavior: .success ), ] @@ -572,7 +576,7 @@ struct DefaultRoomLifecycleManagerTests { @Test func detach_detachesAllContributors_andWhenTheyAllDetachSuccessfully_transitionsToDetached() async throws { // Given: A DefaultRoomLifecycleManager, all of whose contributors’ calls to `detach` succeed - let contributors = (1 ... 3).map { _ in createContributor(detachBehavior: .complete(.success)) } + let contributors = (1 ... 3).map { _ in createContributor(detachBehavior: .success) } let manager = await createManager(contributors: contributors) let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) @@ -665,11 +669,11 @@ struct DefaultRoomLifecycleManagerTests { // 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 + // Then: It attempts to detach the channel 3 times, waiting 250ms between each attempt, the room transitions from DETACHING to DETACHED with no status updates in between, and the call to `performDetachOperation()` succeeds #expect(await contributor.channel.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?" - #expect(await clock.sleepCallArguments == Array(repeating: 1, count: 2)) + #expect(await clock.sleepCallArguments == Array(repeating: 0.25, count: 2)) #expect(await asyncLetStatusChanges.map(\.current) == [.detaching, .detached]) } @@ -792,10 +796,10 @@ struct DefaultRoomLifecycleManagerTests { // - 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: .attached /* arbitrary non-FAILED */, detachBehavior: .success), // We put the one that will be skipped in the middle, to verify that the subsequent contributors don’t get skipped - createContributor(initialState: .failed, detachBehavior: .complete(.failure(.init(domain: "SomeDomain", code: 123) /* arbitrary error */ ))), - createContributor(initialState: .detached /* arbitrary non-FAILED */, detachBehavior: .complete(.success)), + createContributor(initialState: .failed, detachBehavior: .failure(.init(domain: "SomeDomain", code: 123) /* arbitrary error */ )), + createContributor(initialState: .detached /* arbitrary non-FAILED */, detachBehavior: .success), ] let manager = await createManager(contributors: contributors) @@ -843,11 +847,11 @@ struct DefaultRoomLifecycleManagerTests { // 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 + // It: calls `detach()` on the channel 3 times, with a 0.25s pause between each attempt, and the call to `performReleaseOperation` completes #expect(await contributor.channel.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?" - #expect(await clock.sleepCallArguments == Array(repeating: 1, count: 2)) + #expect(await clock.sleepCallArguments == Array(repeating: 0.25, count: 2)) } // @specOneOf(2/2) CHA-RL3e - Tests that this spec point suppresses CHA-RL3f retries @@ -868,19 +872,407 @@ struct DefaultRoomLifecycleManagerTests { // 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) + // - it waits 0.25s (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 #expect(await contributor.channel.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?" - #expect(await clock.sleepCallArguments == [1]) + #expect(await clock.sleepCallArguments == [0.25]) _ = await releasedStatusChange #expect(await manager.roomStatus == .released) } + // MARK: - RETRY operation + + // @specOneOf(1/2) CHA-RL5a + @Test + func retry_detachesAllContributorsExceptForTriggering() async throws { + // Given: A RoomLifecycleManager + let contributors = [ + createContributor( + attachBehavior: .success, // Not related to this test, just so that the subsequent CHA-RL5f attachment cycle completes + + // Not related to this test, just so that the subsequent CHA-RL5d wait-for-ATTACHED completes + subscribeToStateBehavior: .addSubscriptionAndEmitStateChange( + .init( + current: .attached, + previous: .attaching, // arbitrary + event: .attached, + reason: .createUnknownError() // Not related to this test, just to avoid a crash in CHA-RL4b1 handling of this state change + ) + ) + ), + createContributor( + attachBehavior: .success, // Not related to this test, just so that the subsequent CHA-RL5f attachment cycle completes + detachBehavior: .success + ), + createContributor( + attachBehavior: .success, // Not related to this test, just so that the subsequent CHA-RL5f attachment cycle completes + detachBehavior: .success + ), + ] + + let manager = await createManager(contributors: contributors) + + // When: `performRetryOperation(triggeredByContributor:errorForSuspendedStatus:)` is called on the manager + await manager.performRetryOperation(triggeredByContributor: contributors[0], errorForSuspendedStatus: .createUnknownError() /* arbitrary */ ) + + // Then: The manager calls `detach` on all contributors except that which triggered the RETRY (I’m using this, combined with the CHA-RL5b and CHA-RL5c tests, as a good-enough way of checking that a CHA-RL2f detachment cycle happened) + #expect(await contributors[0].channel.detachCallCount == 0) + #expect(await contributors[1].channel.detachCallCount == 1) + #expect(await contributors[2].channel.detachCallCount == 1) + } + + // @specOneOf(2/2) CHA-RL5a - Verifies that, when the RETRY operation triggers a CHA-RL2f detachment cycle, the retry behaviour of CHA-RL2h3 is performed. + @Test + func retry_ifDetachFailsDueToNonFailedChannelState_retries() async throws { + // Given: A RoomLifecycleManager, whose contributor at index 1 has an implementation of `detach()` which: + // - throws an error and transitions the contributor to a non-FAILED state the first time it’s called + // - succeeds the second time it’s called + let detachImpl = { @Sendable (callCount: Int) -> MockRoomLifecycleContributorChannel.AttachOrDetachBehavior in + if callCount == 1 { + return .completeAndChangeState(.failure(.createUnknownError() /* arbitrary */ ), newState: .attached /* this is what CHA-RL2h3 mentions as being the only non-FAILED state that would happen in this situation in reality */ ) + } else { + return .success + } + } + + let contributors = [ + createContributor( + attachBehavior: .success, // Not related to this test, just so that the subsequent CHA-RL5f attachment cycle completes + + // Not related to this test, just so that the subsequent CHA-RL5d wait-for-ATTACHED completes + subscribeToStateBehavior: .addSubscriptionAndEmitStateChange( + .init( + current: .attached, + previous: .attaching, // arbitrary + event: .attached, + reason: .createUnknownError() // Not related to this test, just to avoid a crash in CHA-RL4b1 handling of this state change + ) + ) + ), + createContributor( + attachBehavior: .success, // Not related to this test, just so that the subsequent CHA-RL5f attachment cycle completes + detachBehavior: .fromFunction(detachImpl) + ), + createContributor( + attachBehavior: .success, // Not related to this test, just so that the subsequent CHA-RL5f attachment cycle completes + detachBehavior: .success + ), + ] + + let clock = MockSimpleClock() + + let manager = await createManager(contributors: contributors, clock: clock) + + // When: `performRetryOperation(triggeredByContributor:errorForSuspendedStatus:)` is called on the manager, triggered by a contributor that isn’t that at index 1 + await manager.performRetryOperation(triggeredByContributor: contributors[0], errorForSuspendedStatus: .createUnknownError() /* arbitrary */ ) + + // Then: The manager calls `detach` in sequence on all contributors except that which triggered the RETRY, stopping upon one of these `detach` calls throwing an error, then sleeps for 250ms, then performs these `detach` calls again + + // (Note that for simplicity of the test I’m not actually making assertions about the sequence in which events happen here) + #expect(await contributors[0].channel.detachCallCount == 0) + #expect(await contributors[1].channel.detachCallCount == 2) + #expect(await contributors[2].channel.detachCallCount == 1) + #expect(await clock.sleepCallArguments == [0.25]) + } + + // @spec CHA-RL5c + @Test + func retry_ifDetachFailsDueToFailedChannelState_transitionsToFailed() async throws { + // Given: A RoomLifecycleManager, whose contributor at index 1 has an implementation of `detach()` which throws an error and transitions the contributor to the FAILED state + let contributor1DetachError = ARTErrorInfo.createUnknownError() // arbitrary + + let contributors = [ + // Features arbitrarily chosen, just need to be distinct in order to make assertions about errors later + createContributor(feature: .messages), + createContributor( + feature: .presence, + detachBehavior: .completeAndChangeState(.failure(contributor1DetachError), newState: .failed) + ), + createContributor( + feature: .typing, + detachBehavior: .success + ), + ] + + let manager = await createManager(contributors: contributors) + + let roomStatusSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let maybeFailedStatusChange = roomStatusSubscription.failedElements().first { _ in true } + + // When: `performRetryOperation(triggeredByContributor:errorForSuspendedStatus:)` is called on the manager, triggered by the contributor at index 0 + await manager.performRetryOperation(triggeredByContributor: contributors[0], errorForSuspendedStatus: .createUnknownError() /* arbitrary */ ) + + // Then: + // 1. (This is basically just testing the behaviour of CHA-RL2h1 again, plus the fact that we don’t try to detach the channel that triggered the RETRY) The manager calls `detach` in sequence on all contributors except that which triggered the RETRY, and enters the FAILED status, the associated error for this status being the *DetachmentFailed code corresponding to contributor 1’s feature, and its `cause` is contributor 1’s `errorReason` + // 2. the RETRY operation is terminated (which we confirm here by checking that it has not attempted to attach any of the contributors, which shows us that CHA-RL5f didn’t happen) + #expect(await contributors[0].channel.detachCallCount == 0) + #expect(await contributors[1].channel.detachCallCount == 1) + #expect(await contributors[2].channel.detachCallCount == 1) + + let roomStatus = await manager.roomStatus + let failedStatusChange = try #require(await maybeFailedStatusChange) + + #expect(roomStatus.isFailed) + for error in [roomStatus.error, failedStatusChange.error] { + #expect(isChatError(error, withCode: .presenceDetachmentFailed, cause: contributor1DetachError)) + } + + for contributor in contributors { + #expect(await contributor.channel.attachCallCount == 0) + } + } + + // swiftlint:disable type_name + /// Describes how a contributor will end up in one of the states that CHA-RL5d expects it to end up in. + enum CHA_RL5d_JourneyToState { + // swiftlint:enable type_name + case transitionsToState + case alreadyInState + } + + // @spec CHA-RL5e + @Test(arguments: [CHA_RL5d_JourneyToState.transitionsToState, .alreadyInState]) + func retry_whenTriggeringContributorEndsUpFailed_terminatesOperation(journeyToState: CHA_RL5d_JourneyToState) async throws { + // Given: A RoomLifecycleManager, with a contributor that: + // - (if test argument journeyToState is .transitionsToState) emits a state change to FAILED after subscribeToState() is called on it + // - (if test argument journeyToState is .alreadyInState) is already FAILED (it would be more realistic for it to become FAILED _during_ the CHA-RL5a detachment cycle, but that would be more awkward to mock, so this will do) + let contributorFailedReason = ARTErrorInfo.createUnknownError() // arbitrary + + let contributors = [ + createContributor( + initialState: ({ + switch journeyToState { + case .alreadyInState: + .failed + case .transitionsToState: + .suspended // arbitrary + } + })(), + initialErrorReason: ({ + switch journeyToState { + case .alreadyInState: + contributorFailedReason + case .transitionsToState: + nil + } + })(), + + detachBehavior: .success, // Not related to this test, just so that the CHA-RL5a detachment cycle completes + + subscribeToStateBehavior: ({ + switch journeyToState { + case .alreadyInState: + return nil + case .transitionsToState: + let contributorFailedStateChange = ARTChannelStateChange( + current: .failed, + previous: .attaching, // arbitrary + event: .failed, + reason: contributorFailedReason + ) + + return .addSubscriptionAndEmitStateChange(contributorFailedStateChange) + } + })() + ), + createContributor( + detachBehavior: .success // Not related to this test, just so that the CHA-RL5a detachment cycle completes + ), + ] + + let manager = await createManager( + contributors: contributors + ) + + let roomStatusSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let maybeFailedStatusChange = roomStatusSubscription.failedElements().first { _ in true } + + // When: `performRetryOperation(triggeredByContributor:errorForSuspendedStatus:)` is called on the manager, triggered by the aforementioned contributor + await manager.performRetryOperation(triggeredByContributor: contributors[0], errorForSuspendedStatus: .createUnknownError() /* arbitrary */ ) + + // Then: + // 1. The room transitions to (and remains in) FAILED, and its error is: + // - (if test argument journeyToState is .transitionsToState) that associated with the contributor state change + // - (if test argument journeyToState is .alreadyInState) the channel’s `errorReason` + // 2. The RETRY operation terminates (to confirm this, we check that the room has not attempted to attach any of the contributors, indicating that we have not proceeded to the CHA-RL5f attachment cycle) + + let failedStatusChange = try #require(await maybeFailedStatusChange) + + let roomStatus = await manager.roomStatus + #expect(roomStatus.isFailed) + + for error in [failedStatusChange.error, roomStatus.error] { + #expect(error === contributorFailedReason) + } + + for contributor in contributors { + #expect(await contributor.channel.attachCallCount == 0) + } + } + + // @specOneOf(1/3) CHA-RL5f - Tests the first part of CHA-RL5f, namely the transition to ATTACHING once the triggering channel becomes ATTACHED. + @Test(arguments: [CHA_RL5d_JourneyToState.transitionsToState, .alreadyInState]) + func retry_whenTriggeringContributorEndsUpAttached_proceedsToAttachmentCycle(journeyToState: CHA_RL5d_JourneyToState) async throws { + // Given: A RoomLifecycleManager, with a contributor that: + // - (if test argument journeyToState is .transitionsToState) emits a state change to ATTACHED after subscribeToState() is called on it + // - (if test argument journeyToState is .alreadyInState) is already ATTACHED (it would be more realistic for it to become ATTACHED _during_ the CHA-RL5a detachment cycle, but that would be more awkward to mock, so this will do) + let contributors = [ + createContributor( + initialState: ({ + switch journeyToState { + case .alreadyInState: + .attached + case .transitionsToState: + .suspended // arbitrary + } + })(), + + attachBehavior: .success, // Not related to this test, just so that the subsequent CHA-RL5f attachment cycle completes + + subscribeToStateBehavior: ({ + switch journeyToState { + case .alreadyInState: + return nil + case .transitionsToState: + let contributorAttachedStateChange = ARTChannelStateChange( + current: .attached, + previous: .attaching, // arbitrary + event: .attached, + reason: .createUnknownError() // Not related to this test, just to avoid a crash in CHA-RL4b1 handling of this state change + ) + + return .addSubscriptionAndEmitStateChange(contributorAttachedStateChange) + } + })() + ), + createContributor( + attachBehavior: .success, // Not related to this test, just so that the subsequent CHA-RL5f attachment cycle completes + detachBehavior: .success // Not related to this test, just so that the CHA-RL5a detachment cycle completes + ), + ] + + let manager = await createManager( + contributors: contributors + ) + + let roomStatusSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let maybeAttachingStatusChange = roomStatusSubscription.attachingElements().first { _ in true } + + // When: `performRetryOperation(triggeredByContributor:errorForSuspendedStatus:)` is called on the manager, triggered by the aforementioned contributor + await manager.performRetryOperation(triggeredByContributor: contributors[0], errorForSuspendedStatus: .createUnknownError() /* arbitrary */ ) + + // Then: The room transitions to ATTACHING, and the RETRY operation continues (to confirm this, we check that the room has attempted to attach all of the contributors, indicating that we have proceeded to the CHA-RL5f attachment cycle) + let attachingStatusChange = try #require(await maybeAttachingStatusChange) + // The spec doesn’t mention an error for the ATTACHING status change, so I’ll assume there shouldn’t be one + #expect(attachingStatusChange.error == nil) + + for contributor in contributors { + #expect(await contributor.channel.attachCallCount == 1) + } + } + + // @specOneOf(2/3) CHA-RL5f - Tests the case where the CHA-RL1e attachment cycle succeeds + @Test + func retry_whenAttachmentCycleSucceeds() async throws { + // Given: A RoomLifecycleManager, with contributors on all of whom calling `attach()` succeeds (i.e. conditions for a CHA-RL1e attachment cycle to succeed) + let contributors = [ + createContributor( + attachBehavior: .success, + subscribeToStateBehavior: .addSubscriptionAndEmitStateChange( + .init( + current: .attached, + previous: .attaching, // arbitrary + event: .attached, + reason: .createUnknownError() // Not related to this test, just to avoid a crash in CHA-RL4b1 handling of this state change + ) + ) // Not related to this test, just so that the CHA-RL5d wait completes + ), + createContributor( + attachBehavior: .success, + detachBehavior: .success // Not related to this test, just so that the CHA-RL5a detachment cycle completes + ), + createContributor( + attachBehavior: .success, + detachBehavior: .success // Not related to this test, just so that the CHA-RL5a detachment cycle completes + ), + ] + + let manager = await createManager( + contributors: contributors + ) + + let roomStatusSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let attachedStatusChange = roomStatusSubscription.first { $0.current == .attached } + + // When: `performRetryOperation(triggeredByContributor:errorForSuspendedStatus:)` is called on the manager + await manager.performRetryOperation(triggeredByContributor: contributors[0], errorForSuspendedStatus: .createUnknownError() /* arbitrary */ ) + + // Then: The room performs a CHA-RL1e attachment cycle (we sufficiently satisfy ourselves of this fact by checking that it’s attempted to attach all of the channels), transitions to ATTACHED, and the RETRY operation completes + for contributor in contributors { + #expect(await contributor.channel.attachCallCount == 1) + } + + _ = try #require(await attachedStatusChange) + #expect(await manager.roomStatus == .attached) + } + + // @specOneOf(3/3) CHA-RL5f - Tests the case where the CHA-RL1e attachment cycle fails + @Test + func retry_whenAttachmentCycleFails() async throws { + // Given: A RoomLifecycleManager, with a contributor on whom calling `attach()` fails, putting the contributor into the FAILED state (i.e. conditions for a CHA-RL1e attachment cycle to fail) + let contributorAttachError = ARTErrorInfo.createUnknownError() // arbitrary + + let contributors = [ + createContributor( + attachBehavior: .success, + detachBehavior: .success, // So that the detach performed by CHA-RL1h5’s rollback of the attachment cycle succeeds + subscribeToStateBehavior: .addSubscriptionAndEmitStateChange( + .init( + current: .attached, + previous: .attaching, // arbitrary + event: .attached, + reason: .createUnknownError() // Not related to this test, just to avoid a crash in CHA-RL4b1 handling of this state change + ) + ) // Not related to this test, just so that the CHA-RL5d wait completes + ), + createContributor( + attachBehavior: .completeAndChangeState(.failure(contributorAttachError), newState: .failed), + detachBehavior: .success // Not related to this test, just so that the CHA-RL5a detachment cycle completes + ), + createContributor( + attachBehavior: .success, + detachBehavior: .success // So that the CHA-RL5a detachment cycle completes (not related to this test) and so that the detach performed by CHA-RL1h5’s rollback of the attachment cycle succeeds + ), + ] + + let manager = await createManager( + contributors: contributors + ) + + let roomStatusSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let failedStatusChange = roomStatusSubscription.failedElements().first { _ in true } + + // When: `performRetryOperation(triggeredByContributor:errorForSuspendedStatus:)` is called on the manager + await manager.performRetryOperation(triggeredByContributor: contributors[0], errorForSuspendedStatus: .createUnknownError() /* arbitrary */ ) + + // Then: The room performs a CHA-RL1e attachment cycle (we sufficiently satisfy ourselves of this fact by checking that it’s attempted to attach all of the channels up to and including the one for which attachment fails, and subsequently detached all channels except for the FAILED one), transitions to FAILED, and the RETRY operation completes + #expect(await contributors[0].channel.attachCallCount == 1) + #expect(await contributors[1].channel.attachCallCount == 1) + #expect(await contributors[2].channel.attachCallCount == 0) + + #expect(await contributors[0].channel.detachCallCount == 1) // from CHA-RL1h5’s rollback of the attachment cycle + #expect(await contributors[1].channel.detachCallCount == 1) // from CHA-RL5a detachment cycle + #expect(await contributors[2].channel.detachCallCount == 2) // from CHA-RL5a detachment cycle and CHA-RL1h5’s rollback of the attachment cycle + + _ = try #require(await failedStatusChange) + #expect(await manager.roomStatus.isFailed) + } + // MARK: - Handling contributor UPDATE events // @spec CHA-RL4a1 diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift index 28f6723e..ceb431c6 100644 --- a/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift +++ b/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift @@ -4,6 +4,7 @@ final actor MockRoomLifecycleContributorChannel: RoomLifecycleContributorChannel { private let attachBehavior: AttachOrDetachBehavior? private let detachBehavior: AttachOrDetachBehavior? + private let subscribeToStateBehavior: SubscribeToStateBehavior var state: ARTRealtimeChannelState var errorReason: ARTErrorInfo? @@ -15,12 +16,16 @@ final actor MockRoomLifecycleContributorChannel: RoomLifecycleContributorChannel init( initialState: ARTRealtimeChannelState, + initialErrorReason: ARTErrorInfo?, attachBehavior: AttachOrDetachBehavior?, - detachBehavior: AttachOrDetachBehavior? + detachBehavior: AttachOrDetachBehavior?, + subscribeToStateBehavior: SubscribeToStateBehavior? ) { state = initialState + errorReason = initialErrorReason self.attachBehavior = attachBehavior self.detachBehavior = detachBehavior + self.subscribeToStateBehavior = subscribeToStateBehavior ?? .justAddSubscription } enum AttachOrDetachResult { @@ -52,6 +57,11 @@ final actor MockRoomLifecycleContributorChannel: RoomLifecycleContributorChannel } } + enum SubscribeToStateBehavior { + case justAddSubscription + case addSubscriptionAndEmitStateChange(ARTChannelStateChange) + } + func attach() async throws(ARTErrorInfo) { attachCallCount += 1 @@ -100,6 +110,14 @@ final actor MockRoomLifecycleContributorChannel: RoomLifecycleContributorChannel func subscribeToState() -> Subscription { let subscription = Subscription(bufferingPolicy: .unbounded) subscriptions.append(subscription) + + switch subscribeToStateBehavior { + case .justAddSubscription: + break + case let .addSubscriptionAndEmitStateChange(stateChange): + emitStateChange(stateChange) + } + return subscription }