From 63d01a069bafb5190b27e9a948d456b4790e72cd Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 14 Nov 2024 14:04:44 -0300 Subject: [PATCH 1/6] Consistently use mock behaviour shorthand For whatever reason, I forgot to do this in some places. --- .../DefaultRoomLifecycleManagerTests.swift | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Tests/AblyChatTests/DefaultRoomLifecycleManagerTests.swift b/Tests/AblyChatTests/DefaultRoomLifecycleManagerTests.swift index e9974ac6..2840837f 100644 --- a/Tests/AblyChatTests/DefaultRoomLifecycleManagerTests.swift +++ b/Tests/AblyChatTests/DefaultRoomLifecycleManagerTests.swift @@ -169,7 +169,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 +235,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 +257,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 +291,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 +315,7 @@ struct DefaultRoomLifecycleManagerTests { if i == 1 { createContributor(attachBehavior: .completeAndChangeState(.failure(contributorAttachError), newState: .suspended)) } else { - createContributor(attachBehavior: .complete(.success)) + createContributor(attachBehavior: .success) } } @@ -363,9 +363,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 +411,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 +572,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) @@ -792,10 +792,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) From db160bfd4d1e38eff6758e060af69c08adf6c1f4 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 13 Nov 2024 11:31:47 -0300 Subject: [PATCH 2/6] =?UTF-8?q?Enhance=20contributor=20mock=E2=80=99s=20st?= =?UTF-8?q?ate=20change=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an option for it to emit a state change when you subscribe to its state. Will use in an upcoming commit. --- .../DefaultRoomLifecycleManagerTests.swift | 6 ++++-- .../MockRoomLifecycleContributorChannel.swift | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Tests/AblyChatTests/DefaultRoomLifecycleManagerTests.swift b/Tests/AblyChatTests/DefaultRoomLifecycleManagerTests.swift index 2840837f..05bc5837 100644 --- a/Tests/AblyChatTests/DefaultRoomLifecycleManagerTests.swift +++ b/Tests/AblyChatTests/DefaultRoomLifecycleManagerTests.swift @@ -73,14 +73,16 @@ struct DefaultRoomLifecycleManagerTests { 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 + detachBehavior: MockRoomLifecycleContributorChannel.AttachOrDetachBehavior? = nil, + subscribeToStateBehavior: MockRoomLifecycleContributorChannel.SubscribeToStateBehavior? = nil ) -> MockRoomLifecycleContributor { .init( feature: feature, channel: .init( initialState: initialState, attachBehavior: attachBehavior, - detachBehavior: detachBehavior + detachBehavior: detachBehavior, + subscribeToStateBehavior: subscribeToStateBehavior ) ) } diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift index 28f6723e..b363f875 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? @@ -16,11 +17,13 @@ final actor MockRoomLifecycleContributorChannel: RoomLifecycleContributorChannel init( initialState: ARTRealtimeChannelState, attachBehavior: AttachOrDetachBehavior?, - detachBehavior: AttachOrDetachBehavior? + detachBehavior: AttachOrDetachBehavior?, + subscribeToStateBehavior: SubscribeToStateBehavior? ) { state = initialState self.attachBehavior = attachBehavior self.detachBehavior = detachBehavior + self.subscribeToStateBehavior = subscribeToStateBehavior ?? .justAddSubscription } enum AttachOrDetachResult { @@ -52,6 +55,11 @@ final actor MockRoomLifecycleContributorChannel: RoomLifecycleContributorChannel } } + enum SubscribeToStateBehavior { + case justAddSubscription + case addSubscriptionAndEmitStateChange(ARTChannelStateChange) + } + func attach() async throws(ARTErrorInfo) { attachCallCount += 1 @@ -100,6 +108,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 } From 05f2a031ada37c18823e818e4d00c6b6b71e9db4 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 13 Nov 2024 10:43:35 -0300 Subject: [PATCH 3/6] Split attachment & detachment cycles into methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We’ll use these when we implement the RETRY operation in #51. References to CHA-RL5* points are based on spec at 8ff947d. --- Sources/AblyChat/RoomLifecycleManager.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Sources/AblyChat/RoomLifecycleManager.swift b/Sources/AblyChat/RoomLifecycleManager.swift index dcdc7aad..98148006 100644 --- a/Sources/AblyChat/RoomLifecycleManager.swift +++ b/Sources/AblyChat/RoomLifecycleManager.swift @@ -533,7 +533,7 @@ internal actor DefaultRoomLifecycleManager Date: Wed, 13 Nov 2024 13:40:25 -0300 Subject: [PATCH 4/6] Link the SUSPENDED status to a RETRY operation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is so that when we implement our upcoming RETRY operation, the manager’s `hasOperationInProgress` will return `true` if a RETRY operation is in progress. --- Sources/AblyChat/RoomLifecycleManager.swift | 26 +++++++++++++-------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/Sources/AblyChat/RoomLifecycleManager.swift b/Sources/AblyChat/RoomLifecycleManager.swift index 98148006..806c1505 100644 --- a/Sources/AblyChat/RoomLifecycleManager.swift +++ b/Sources/AblyChat/RoomLifecycleManager.swift @@ -186,7 +186,8 @@ internal actor DefaultRoomLifecycleManager Date: Wed, 13 Nov 2024 14:19:18 -0300 Subject: [PATCH 5/6] Fill in previously-unspecified wait durations This are now specified. Taken from spec version 8ff947d. --- Sources/AblyChat/RoomLifecycleManager.swift | 12 ++++++------ .../DefaultRoomLifecycleManagerTests.swift | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Sources/AblyChat/RoomLifecycleManager.swift b/Sources/AblyChat/RoomLifecycleManager.swift index 806c1505..b7ceb1e9 100644 --- a/Sources/AblyChat/RoomLifecycleManager.swift +++ b/Sources/AblyChat/RoomLifecycleManager.swift @@ -883,9 +883,9 @@ internal actor DefaultRoomLifecycleManager Date: Thu, 31 Oct 2024 16:35:40 -0300 Subject: [PATCH 6/6] Implement RETRY room lifecycle operation Based on spec at bfcfa7e. The internal triggering of the RETRY operation (as specified by CHA-RL1h3 and CHA-RL4b9) will come in #50. Resolves #51. --- Sources/AblyChat/RoomLifecycleManager.swift | 171 +++++++- .../DefaultRoomLifecycleManagerTests.swift | 390 ++++++++++++++++++ .../MockRoomLifecycleContributorChannel.swift | 2 + 3 files changed, 550 insertions(+), 13 deletions(-) diff --git a/Sources/AblyChat/RoomLifecycleManager.swift b/Sources/AblyChat/RoomLifecycleManager.swift index b7ceb1e9..f4590e5d 100644 --- a/Sources/AblyChat/RoomLifecycleManager.swift +++ b/Sources/AblyChat/RoomLifecycleManager.swift @@ -182,10 +182,12 @@ 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 @@ -934,7 +974,7 @@ internal actor DefaultRoomLifecycleManager 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 b363f875..ceb431c6 100644 --- a/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift +++ b/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift @@ -16,11 +16,13 @@ final actor MockRoomLifecycleContributorChannel: RoomLifecycleContributorChannel init( initialState: ARTRealtimeChannelState, + initialErrorReason: ARTErrorInfo?, attachBehavior: AttachOrDetachBehavior?, detachBehavior: AttachOrDetachBehavior?, subscribeToStateBehavior: SubscribeToStateBehavior? ) { state = initialState + errorReason = initialErrorReason self.attachBehavior = attachBehavior self.detachBehavior = detachBehavior self.subscribeToStateBehavior = subscribeToStateBehavior ?? .justAddSubscription