diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a107f536b..94abc82ddd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## StreamChat ### 🐞 Fixed - Fix a rare infinite loop triggering a crash when handling database changes [#3508](https://github.com/GetStream/stream-chat-swift/pull/3508) +- Fix reconnection timeout handler not working in the token provider phase [#3513](https://github.com/GetStream/stream-chat-swift/pull/3513) ## StreamChatUI ### 🐞 Fixed diff --git a/Sources/StreamChat/ChatClient+Environment.swift b/Sources/StreamChat/ChatClient+Environment.swift index 9d86085105..08fd6535aa 100644 --- a/Sources/StreamChat/ChatClient+Environment.swift +++ b/Sources/StreamChat/ChatClient+Environment.swift @@ -47,6 +47,11 @@ extension ChatClient { ) } + var reconnectionHandlerBuilder: (_ chatClientConfig: ChatClientConfig) -> StreamTimer? = { + guard let reconnectionTimeout = $0.reconnectionTimeout else { return nil } + return ScheduledStreamTimer(interval: reconnectionTimeout, fireOnStart: false, repeats: false) + } + var extensionLifecycleBuilder = NotificationExtensionLifecycle.init var requestEncoderBuilder: (_ baseURL: URL, _ apiKey: APIKey) -> RequestEncoder = DefaultRequestEncoder.init @@ -97,8 +102,7 @@ extension ChatClient { _ extensionLifecycle: NotificationExtensionLifecycle, _ backgroundTaskScheduler: BackgroundTaskScheduler?, _ internetConnection: InternetConnection, - _ keepConnectionAliveInBackground: Bool, - _ reconnectionTimeoutHandler: StreamTimer? + _ keepConnectionAliveInBackground: Bool ) -> ConnectionRecoveryHandler = { DefaultConnectionRecoveryHandler( webSocketClient: $0, @@ -109,8 +113,7 @@ extension ChatClient { internetConnection: $5, reconnectionStrategy: DefaultRetryStrategy(), reconnectionTimerType: DefaultTimer.self, - keepConnectionAliveInBackground: $6, - reconnectionTimeoutHandler: $7 + keepConnectionAliveInBackground: $6 ) } diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index c6af54a27d..7e01e5d7c6 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -96,6 +96,9 @@ public class ChatClient { /// Used as a bridge to communicate between the host app and the notification extension. Holds the state for the app lifecycle. let extensionLifecycle: NotificationExtensionLifecycle + /// The component responsible to timeout the user connection if it takes more time than the `ChatClientConfig.reconnectionTimeout`. + var reconnectionTimeoutHandler: StreamTimer? + /// The environment object containing all dependencies of this `Client` instance. private let environment: Environment @@ -219,12 +222,18 @@ public class ChatClient { setupOfflineRequestQueue() setupConnectionRecoveryHandler(with: environment) validateIntegrity() + + reconnectionTimeoutHandler = environment.reconnectionHandlerBuilder(config) + reconnectionTimeoutHandler?.onChange = { [weak self] in + self?.timeout() + } } deinit { Self._activeLocalStorageURLs.mutate { $0.subtract(databaseContainer.persistentStoreDescriptions.compactMap(\.url)) } completeConnectionIdWaiters(connectionId: nil) completeTokenWaiters(token: nil) + reconnectionTimeoutHandler?.stop() } func setupTokenRefresher() { @@ -254,8 +263,7 @@ public class ChatClient { extensionLifecycle, environment.backgroundTaskSchedulerBuilder(), environment.internetConnection(eventNotificationCenter, environment.internetMonitor), - config.staysConnectedInBackground, - config.reconnectionTimeout.map { ScheduledStreamTimer(interval: $0, fireOnStart: false, repeats: false) } + config.staysConnectedInBackground ) } @@ -300,7 +308,9 @@ public class ChatClient { tokenProvider: @escaping TokenProvider, completion: ((Error?) -> Void)? = nil ) { + reconnectionTimeoutHandler?.start() connectionRecoveryHandler?.start() + connectionRepository.initialize() authenticationRepository.connectUser( userInfo: userInfo, @@ -393,7 +403,9 @@ public class ChatClient { userInfo: UserInfo, completion: ((Error?) -> Void)? = nil ) { + connectionRepository.initialize() connectionRecoveryHandler?.start() + reconnectionTimeoutHandler?.start() authenticationRepository.connectGuestUser(userInfo: userInfo, completion: { completion?($0) }) } @@ -417,6 +429,8 @@ public class ChatClient { /// Connects an anonymous user /// - Parameter completion: The completion that will be called once the **first** user session for the given token is setup. public func connectAnonymousUser(completion: ((Error?) -> Void)? = nil) { + connectionRepository.initialize() + reconnectionTimeoutHandler?.start() connectionRecoveryHandler?.start() authenticationRepository.connectAnonymousUser( completion: { completion?($0) } @@ -458,7 +472,7 @@ public class ChatClient { completion() } authenticationRepository.clearTokenProvider() - authenticationRepository.cancelTimers() + authenticationRepository.reset() } /// Disconnects the chat client from the chat servers. No further updates from the servers @@ -617,6 +631,15 @@ public class ChatClient { completion?($0) } } + + private func timeout() { + completeConnectionIdWaiters(connectionId: nil) + authenticationRepository.completeTokenCompletions(error: ClientError.ReconnectionTimeout()) + completeTokenWaiters(token: nil) + authenticationRepository.reset() + let webSocketConnectionState = webSocketClient?.connectionState ?? .initialized + connectionRepository.disconnect(source: .timeout(from: webSocketConnectionState)) {} + } } extension ChatClient: AuthenticationRepositoryDelegate { @@ -646,6 +669,17 @@ extension ChatClient: ConnectionStateDelegate { ) connectionRecoveryHandler?.webSocketClient(client, didUpdateConnectionState: state) try? backgroundWorker(of: MessageSender.self).didUpdateConnectionState(state) + + switch state { + case .connecting: + if reconnectionTimeoutHandler?.isRunning == false { + reconnectionTimeoutHandler?.start() + } + case .connected: + reconnectionTimeoutHandler?.stop() + default: + break + } } } @@ -692,6 +726,14 @@ extension ClientError { } } + public final class ReconnectionTimeout: ClientError { + override public var localizedDescription: String { + """ + The reconnection process has timed out after surpassing the value from `ChatClientConfig.reconnectionTimeout`. + """ + } + } + public final class MissingToken: ClientError {} final class WaiterTimeout: ClientError {} diff --git a/Sources/StreamChat/Repositories/AuthenticationRepository.swift b/Sources/StreamChat/Repositories/AuthenticationRepository.swift index 7f36fca6fb..6d45afb2c2 100644 --- a/Sources/StreamChat/Repositories/AuthenticationRepository.swift +++ b/Sources/StreamChat/Repositories/AuthenticationRepository.swift @@ -196,9 +196,12 @@ class AuthenticationRepository { isGettingToken = false } - func cancelTimers() { + func reset() { connectionProviderTimer?.cancel() tokenProviderTimer?.cancel() + tokenQueue.async(flags: .barrier) { + self._tokenExpirationRetryStrategy.resetConsecutiveFailures() + } } func logOutUser() { @@ -280,6 +283,19 @@ class AuthenticationRepository { updateToken(token: token, notifyTokenWaiters: true) } + func completeTokenCompletions(error: Error?) { + let completionBlocks: [(Error?) -> Void]? = tokenQueue.sync(flags: .barrier) { + self._isGettingToken = false + let completions = self._tokenRequestCompletions + return completions + } + completionBlocks?.forEach { $0(error) } + tokenQueue.async(flags: .barrier) { + self._tokenRequestCompletions = [] + self._consecutiveRefreshFailures = 0 + } + } + private func updateToken(token: Token?, notifyTokenWaiters: Bool) { let waiters: [String: (Result) -> Void] = tokenQueue.sync(flags: .barrier) { _currentToken = token @@ -331,21 +347,12 @@ class AuthenticationRepository { isGettingToken = true let onCompletion: (Error?) -> Void = { [weak self] error in - guard let self = self else { return } if let error = error { log.error("Error when getting token: \(error)", subsystems: .authentication) } else { log.debug("Successfully retrieved token", subsystems: .authentication) } - - let completionBlocks: [(Error?) -> Void]? = self.tokenQueue.sync(flags: .barrier) { - self._isGettingToken = false - let completions = self._tokenRequestCompletions - self._tokenRequestCompletions = [] - self._consecutiveRefreshFailures = 0 - return completions - } - completionBlocks?.forEach { $0(error) } + self?.completeTokenCompletions(error: error) } guard consecutiveRefreshFailures < Constants.maximumTokenRefreshAttempts else { diff --git a/Sources/StreamChat/Repositories/ConnectionRepository.swift b/Sources/StreamChat/Repositories/ConnectionRepository.swift index d9b1ece47e..2e6489a18f 100644 --- a/Sources/StreamChat/Repositories/ConnectionRepository.swift +++ b/Sources/StreamChat/Repositories/ConnectionRepository.swift @@ -42,6 +42,10 @@ class ConnectionRepository { self.timerType = timerType } + func initialize() { + webSocketClient?.initialize() + } + /// Connects the chat client the controller represents to the chat servers. /// /// When the connection is established, `ChatClient` starts receiving chat updates, and `currentUser` variable is available. @@ -95,14 +99,6 @@ class ConnectionRepository { return } - if connectionId == nil { - if source == .userInitiated { - log.warning("The client is already disconnected. Skipping the `disconnect` call.") - } - completion() - return - } - // Disconnect the web socket webSocketClient?.disconnect(source: source) { [weak self] in // Reset `connectionId`. This would happen asynchronously by the callback from WebSocketClient anyway, but it's diff --git a/Sources/StreamChat/WebSocketClient/ConnectionStatus.swift b/Sources/StreamChat/WebSocketClient/ConnectionStatus.swift index 50a7607cd8..94fb00e06b 100644 --- a/Sources/StreamChat/WebSocketClient/ConnectionStatus.swift +++ b/Sources/StreamChat/WebSocketClient/ConnectionStatus.swift @@ -77,7 +77,7 @@ enum WebSocketConnectionState: Equatable { } } - /// The initial state meaning that there was no atempt to connect yet. + /// The initial state meaning that the web socket engine is not yet connected or connecting. case initialized /// The web socket is not connected. Contains the source/reason why the disconnection has happened. diff --git a/Sources/StreamChat/WebSocketClient/WebSocketClient.swift b/Sources/StreamChat/WebSocketClient/WebSocketClient.swift index bb03570918..3bcdf9f092 100644 --- a/Sources/StreamChat/WebSocketClient/WebSocketClient.swift +++ b/Sources/StreamChat/WebSocketClient/WebSocketClient.swift @@ -100,6 +100,10 @@ class WebSocketClient { self.eventNotificationCenter = eventNotificationCenter } + func initialize() { + connectionState = .initialized + } + /// Connects the web connect. /// /// Calling this method has no effect is the web socket is already connected, or is in the connecting phase. @@ -137,23 +141,18 @@ class WebSocketClient { source: WebSocketConnectionState.DisconnectionSource = .userInitiated, completion: @escaping () -> Void ) { - connectionState = .disconnecting(source: source) - engineQueue.async { [engine, eventsBatcher] in - engine?.disconnect() - - eventsBatcher.processImmediately(completion: completion) + switch connectionState { + case .initialized, .disconnected, .disconnecting: + connectionState = .disconnected(source: source) + case .connecting, .waitingForConnectionId, .connected: + connectionState = .disconnecting(source: source) } - } - - func timeout() { - let previousState = connectionState - connectionState = .disconnected(source: .timeout(from: previousState)) + engineQueue.async { [engine, eventsBatcher] in engine?.disconnect() - eventsBatcher.processImmediately {} + eventsBatcher.processImmediately(completion: completion) } - log.error("Connection timed out. `\(connectionState)", subsystems: .webSocket) } } diff --git a/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift b/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift index 14d99290a0..41e4a90344 100644 --- a/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift +++ b/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift @@ -36,7 +36,6 @@ final class DefaultConnectionRecoveryHandler: ConnectionRecoveryHandler { private var reconnectionStrategy: RetryStrategy private var reconnectionTimer: TimerControl? private let keepConnectionAliveInBackground: Bool - private var reconnectionTimeoutHandler: StreamTimer? // MARK: - Init @@ -49,8 +48,7 @@ final class DefaultConnectionRecoveryHandler: ConnectionRecoveryHandler { internetConnection: InternetConnection, reconnectionStrategy: RetryStrategy, reconnectionTimerType: Timer.Type, - keepConnectionAliveInBackground: Bool, - reconnectionTimeoutHandler: StreamTimer? + keepConnectionAliveInBackground: Bool ) { self.webSocketClient = webSocketClient self.eventNotificationCenter = eventNotificationCenter @@ -61,7 +59,6 @@ final class DefaultConnectionRecoveryHandler: ConnectionRecoveryHandler { self.reconnectionStrategy = reconnectionStrategy self.reconnectionTimerType = reconnectionTimerType self.keepConnectionAliveInBackground = keepConnectionAliveInBackground - self.reconnectionTimeoutHandler = reconnectionTimeoutHandler } func start() { @@ -71,7 +68,6 @@ final class DefaultConnectionRecoveryHandler: ConnectionRecoveryHandler { func stop() { unsubscribeFromNotifications() cancelReconnectionTimer() - reconnectionTimeoutHandler?.stop() } deinit { @@ -94,11 +90,6 @@ private extension DefaultConnectionRecoveryHandler { name: .internetConnectionAvailabilityDidChange, object: nil ) - - reconnectionTimeoutHandler?.onChange = { [weak self] in - self?.webSocketClient.timeout() - self?.cancelReconnectionTimer() - } } func unsubscribeFromNotifications() { @@ -177,9 +168,6 @@ extension DefaultConnectionRecoveryHandler { switch state { case .connecting: cancelReconnectionTimer() - if reconnectionTimeoutHandler?.isRunning == false { - reconnectionTimeoutHandler?.start() - } case .connected: extensionLifecycle.setAppState(isReceivingEvents: true) @@ -187,7 +175,6 @@ extension DefaultConnectionRecoveryHandler { syncRepository.syncLocalState { log.info("Local state sync completed", subsystems: .offlineSupport) } - reconnectionTimeoutHandler?.stop() case .disconnected: extensionLifecycle.setAppState(isReceivingEvents: false) diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/ConnectionRepository_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/ConnectionRepository_Mock.swift index efd3cf5a4e..bd95468abd 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/ConnectionRepository_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/ConnectionRepository_Mock.swift @@ -8,6 +8,7 @@ import Foundation /// Mock implementation of `ChatClientUpdater` final class ConnectionRepository_Mock: ConnectionRepository, Spy { enum Signature { + static let initialize = "initialize()" static let connect = "connect(completion:)" static let disconnect = "disconnect(source:completion:)" static let forceConnectionInactiveMode = "forceConnectionStatusForInactiveModeIfNeeded()" @@ -58,6 +59,10 @@ final class ConnectionRepository_Mock: ConnectionRepository, Spy { // MARK: - Overrides + override func initialize() { + record() + } + override func connect(completion: ((Error?) -> Void)? = nil) { record() if let result = connectResult { diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/AuthenticationRepository_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/AuthenticationRepository_Mock.swift index 5daf097ba9..e8e6577e71 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/AuthenticationRepository_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/AuthenticationRepository_Mock.swift @@ -14,6 +14,7 @@ class AuthenticationRepository_Mock: AuthenticationRepository, Spy { static let clearTokenProvider = "clearTokenProvider()" static let logOut = "logOutUser()" static let completeTokenWaiters = "completeTokenWaiters(token:)" + static let completeTokenCompletions = "completeTokenCompletions(error:)" static let setToken = "setToken(token:completeTokenWaiters:)" static let provideToken = "provideToken(timeout:completion:)" } @@ -94,9 +95,9 @@ class AuthenticationRepository_Mock: AuthenticationRepository, Spy { record() } - var cancelTimersCallCount: Int = 0 - override func cancelTimers() { - cancelTimersCallCount += 1 + var resetCallCount: Int = 0 + override func reset() { + resetCallCount += 1 } override func completeTokenWaiters(token: Token?) { @@ -104,6 +105,10 @@ class AuthenticationRepository_Mock: AuthenticationRepository, Spy { completeWaitersToken = token } + override func completeTokenCompletions(error: (any Error)?) { + record() + } + override func provideToken(timeout: TimeInterval = 10, completion: @escaping (Result) -> Void) { record() } diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketClient_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketClient_Mock.swift index 169931a561..95180c1fe1 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketClient_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketClient_Mock.swift @@ -21,8 +21,6 @@ final class WebSocketClient_Mock: WebSocketClient { var disconnect_called: Bool { disconnect_calledCounter > 0 } var disconnect_completion: (() -> Void)? - var timeout_callCount = 0 - var mockedConnectionState: WebSocketConnectionState? @@ -78,10 +76,6 @@ final class WebSocketClient_Mock: WebSocketClient { disconnect_completion = completion } - override func timeout() { - timeout_callCount += 1 - } - var mockEventsBatcher: EventBatcher_Mock { eventsBatcher as! EventBatcher_Mock } diff --git a/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift index dc2a0b86dd..bd887776ca 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -462,6 +462,9 @@ final class ChatClient_Tests: XCTestCase { let client = ChatClient(config: inMemoryStorageConfig, environment: testEnv.environment) let userInfo = UserInfo(id: "id") let authenticationRepository = try XCTUnwrap(client.authenticationRepository as? AuthenticationRepository_Mock) + let reconnectionTimeoutHandler = try XCTUnwrap(client.reconnectionTimeoutHandler as? ScheduledStreamTimer_Mock) + let connectionRecoveryHandler = try XCTUnwrap(client.connectionRecoveryHandler as? ConnectionRecoveryHandler_Mock) + let connectionRepository = try XCTUnwrap(client.connectionRepository as? ConnectionRepository_Mock) let expectation = self.expectation(description: "Connect completes") authenticationRepository.connectUserResult = .success(()) @@ -472,8 +475,11 @@ final class ChatClient_Tests: XCTestCase { } waitForExpectations(timeout: defaultTimeout) - XCTAssertCall(AuthenticationRepository_Mock.Signature.connectTokenProvider, on: authenticationRepository) XCTAssertNil(receivedError) + XCTAssertCall(AuthenticationRepository_Mock.Signature.connectTokenProvider, on: authenticationRepository) + XCTAssertCall(ConnectionRepository_Mock.Signature.initialize, on: connectionRepository) + XCTAssertEqual(reconnectionTimeoutHandler.startCallCount, 1) + XCTAssertEqual(connectionRecoveryHandler.startCallCount, 1) } // MARK: - Connect Static Token @@ -598,6 +604,9 @@ final class ChatClient_Tests: XCTestCase { let client = ChatClient(config: inMemoryStorageConfig, environment: testEnv.environment) let userInfo = UserInfo(id: "id") let authenticationRepository = try XCTUnwrap(client.authenticationRepository as? AuthenticationRepository_Mock) + let reconnectionTimeoutHandler = try XCTUnwrap(client.reconnectionTimeoutHandler as? ScheduledStreamTimer_Mock) + let connectionRecoveryHandler = try XCTUnwrap(client.connectionRecoveryHandler as? ConnectionRecoveryHandler_Mock) + let connectionRepository = try XCTUnwrap(client.connectionRepository as? ConnectionRepository_Mock) let expectation = self.expectation(description: "Connect completes") authenticationRepository.connectGuestResult = .success(()) @@ -608,8 +617,11 @@ final class ChatClient_Tests: XCTestCase { } waitForExpectations(timeout: defaultTimeout) - XCTAssertCall(AuthenticationRepository_Mock.Signature.connectGuest, on: authenticationRepository) XCTAssertNil(receivedError) + XCTAssertCall(AuthenticationRepository_Mock.Signature.connectGuest, on: authenticationRepository) + XCTAssertCall(ConnectionRepository_Mock.Signature.initialize, on: connectionRepository) + XCTAssertEqual(reconnectionTimeoutHandler.startCallCount, 1) + XCTAssertEqual(connectionRecoveryHandler.startCallCount, 1) } // MARK: - Connect Anonymous @@ -635,6 +647,9 @@ final class ChatClient_Tests: XCTestCase { func test_connectAnonymous_tokenProvider_callsAuthenticationRepository_success() throws { let client = ChatClient(config: inMemoryStorageConfig, environment: testEnv.environment) let authenticationRepository = try XCTUnwrap(client.authenticationRepository as? AuthenticationRepository_Mock) + let reconnectionTimeoutHandler = try XCTUnwrap(client.reconnectionTimeoutHandler as? ScheduledStreamTimer_Mock) + let connectionRecoveryHandler = try XCTUnwrap(client.connectionRecoveryHandler as? ConnectionRecoveryHandler_Mock) + let connectionRepository = try XCTUnwrap(client.connectionRepository as? ConnectionRepository_Mock) let expectation = self.expectation(description: "Connect completes") authenticationRepository.connectAnonResult = .success(()) @@ -645,8 +660,11 @@ final class ChatClient_Tests: XCTestCase { } waitForExpectations(timeout: defaultTimeout) - XCTAssertCall(AuthenticationRepository_Mock.Signature.connectAnon, on: authenticationRepository) XCTAssertNil(receivedError) + XCTAssertCall(AuthenticationRepository_Mock.Signature.connectAnon, on: authenticationRepository) + XCTAssertCall(ConnectionRepository_Mock.Signature.initialize, on: connectionRepository) + XCTAssertEqual(reconnectionTimeoutHandler.startCallCount, 1) + XCTAssertEqual(connectionRecoveryHandler.startCallCount, 1) } // MARK: - Disconnect @@ -665,7 +683,7 @@ final class ChatClient_Tests: XCTestCase { XCTAssertCall(ConnectionRepository_Mock.Signature.disconnect, on: connectionRepository) XCTAssertCall(AuthenticationRepository_Mock.Signature.clearTokenProvider, on: authenticationRepository) - XCTAssertEqual(client.mockAuthenticationRepository.cancelTimersCallCount, 1) + XCTAssertEqual(client.mockAuthenticationRepository.resetCallCount, 1) } func test_logout_shouldDisconnect_logOut_andRemoveAllData() throws { @@ -835,6 +853,97 @@ final class ChatClient_Tests: XCTestCase { XCTAssertEqual(streamHeader, SystemEnvironment.xStreamClientHeader) } + + // MARK: - Reconnection Timeout Tests + + func test_reconnectionTimeoutHandler_isInitializedWithConfig() { + // Given + var config = inMemoryStorageConfig + config.reconnectionTimeout = 20 + let client = ChatClient(config: config) + + // Then + XCTAssertNotNil(client.reconnectionTimeoutHandler) + } + + func test_reconnectionTimeoutHandler_notInitialisedIfTimeoutNotProvided() { + // Given + var config = inMemoryStorageConfig + config.reconnectionTimeout = nil + let client = ChatClient(config: config) + + // Then + XCTAssertNil(client.reconnectionTimeoutHandler) + } + + func test_reconnectionTimeoutHandler_startsOnConnect() { + // Given + let client = ChatClient(config: inMemoryStorageConfig, environment: testEnv.environment) + let timerMock = try! XCTUnwrap(client.reconnectionTimeoutHandler as? ScheduledStreamTimer_Mock) + + // When + client.connectAnonymousUser() + + // Then + XCTAssertEqual(timerMock.startCallCount, 1) + } + + func test_reconnectionTimeoutHandler_stopsOnConnected() { + // Given + let client = ChatClient(config: inMemoryStorageConfig, environment: testEnv.environment) + let timerMock = try! XCTUnwrap(client.reconnectionTimeoutHandler as? ScheduledStreamTimer_Mock) + + // When + client.webSocketClient(client.webSocketClient!, didUpdateConnectionState: .connected(connectionId: .unique)) + + // Then + XCTAssertEqual(timerMock.stopCallCount, 1) + } + + func test_reconnectionTimeoutHandler_startsOnConnecting() { + // Given + let client = ChatClient(config: inMemoryStorageConfig, environment: testEnv.environment) + let timerMock = try! XCTUnwrap(client.reconnectionTimeoutHandler as? ScheduledStreamTimer_Mock) + timerMock.isRunning = false + + // When + client.webSocketClient(client.webSocketClient!, didUpdateConnectionState: .connecting) + + // Then + XCTAssertEqual(timerMock.startCallCount, 1) + } + + func test_reconnectionTimeoutHandler_whenRunning_doesNotStart() { + let client = ChatClient(config: inMemoryStorageConfig, environment: testEnv.environment) + let timerMock = try! XCTUnwrap(client.reconnectionTimeoutHandler as? ScheduledStreamTimer_Mock) + timerMock.isRunning = true + + // When + client.webSocketClient(client.webSocketClient!, didUpdateConnectionState: .connecting) + + // Then + XCTAssertEqual(timerMock.startCallCount, 0) + } + + func test_reconnectionTimeout_onChange() throws { + // Given + let client = ChatClient(config: inMemoryStorageConfig, environment: testEnv.environment) + let timerMock = try XCTUnwrap(client.reconnectionTimeoutHandler as? ScheduledStreamTimer_Mock) + let authenticationRepository = try XCTUnwrap(client.authenticationRepository as? AuthenticationRepository_Mock) + let connectionRepository = try XCTUnwrap(client.connectionRepository as? ConnectionRepository_Mock) + connectionRepository.disconnectResult = .success(()) + + // When + timerMock.onChange?() + + // Then + XCTAssertCall(ConnectionRepository_Mock.Signature.disconnect, on: connectionRepository) + XCTAssertCall(ConnectionRepository_Mock.Signature.completeConnectionIdWaiters, on: connectionRepository) + XCTAssertCall(AuthenticationRepository_Mock.Signature.completeTokenWaiters, on: authenticationRepository) + XCTAssertCall(AuthenticationRepository_Mock.Signature.completeTokenCompletions, on: authenticationRepository) + XCTAssertEqual(connectionRepository.disconnectSource, .timeout(from: .initialized)) + XCTAssertEqual(authenticationRepository.resetCallCount, 1) + } } final class TestWorker: Worker { @@ -904,6 +1013,9 @@ private class TestEnvironment { ) return self.databaseContainer! }, + reconnectionHandlerBuilder: { _ in + ScheduledStreamTimer_Mock() + }, requestEncoderBuilder: { if let encoder = self.requestEncoder { return encoder @@ -940,6 +1052,9 @@ private class TestEnvironment { return self.backgroundTaskScheduler! }, timerType: VirtualTimeTimer.self, + connectionRecoveryHandlerBuilder: { _, _, _, _, _, _, _ in + ConnectionRecoveryHandler_Mock() + }, authenticationRepositoryBuilder: { self.authenticationRepository = AuthenticationRepository_Mock( apiClient: $0, diff --git a/Tests/StreamChatTests/Repositories/AuthenticationRepository_Tests.swift b/Tests/StreamChatTests/Repositories/AuthenticationRepository_Tests.swift index 3cdbd7e658..63c54ddb90 100644 --- a/Tests/StreamChatTests/Repositories/AuthenticationRepository_Tests.swift +++ b/Tests/StreamChatTests/Repositories/AuthenticationRepository_Tests.swift @@ -1039,11 +1039,12 @@ final class AuthenticationRepository_Tests: XCTestCase { XCTAssertEqual(state, .newToken) } - // MARK: Cancel Timers + // MARK: Reset - func test_cancelTimers() { + func test_reset() { let mockTimer = MockTimer() FakeTimer.mockTimer = mockTimer + retryStrategy.consecutiveFailuresCount = 5 let repository = AuthenticationRepository( apiClient: apiClient, databaseContainer: database, @@ -1059,10 +1060,11 @@ final class AuthenticationRepository_Tests: XCTestCase { completion: { _ in } ) - repository.cancelTimers() + repository.reset() // should cancel the connection provider timer and the // the token provider timer XCTAssertEqual(mockTimer.cancelCallCount, 2) + XCTAssertEqual(retryStrategy.mock_resetConsecutiveFailures.count, 1) } // MARK: Helpers diff --git a/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift b/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift index 58a1e70192..55a4087d05 100644 --- a/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift +++ b/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift @@ -150,19 +150,6 @@ final class ConnectionRepository_Tests: XCTestCase { // MARK: Disconnect - func test_disconnect_noConnectionId_shouldReturnWithoutTryingToConnect() { - XCTAssertNil(repository.connectionId) - - let expectation = self.expectation(description: "connect completes") - repository.disconnect(source: .userInitiated) { expectation.fulfill() } - - waitForExpectations(timeout: defaultTimeout) - - XCTAssertFalse(webSocketClient.disconnect_called) - XCTAssertCall(APIClient_Spy.Signature.flushRequestsQueue, on: apiClient) - XCTAssertCall(SyncRepository_Mock.Signature.cancelRecoveryFlow, on: syncRepository) - } - func test_disconnect_withConnectionId_notInActiveMode_shouldReturnError() { repository.completeConnectionIdWaiters(connectionId: "123") XCTAssertNotNil(repository.connectionId) diff --git a/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift b/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift index e992b436fa..6ddf97c582 100644 --- a/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift +++ b/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift @@ -238,7 +238,9 @@ final class WebSocketClient_Tests: XCTestCase { ] for source in testCases { + // reset state engine?.disconnect_calledCount = 0 + webSocketClient.connect() // Call `disconnect` with the given source webSocketClient.disconnect(source: source) {} @@ -257,6 +259,17 @@ final class WebSocketClient_Tests: XCTestCase { } } + func test_disconnect_whenInitialized_shouldDisconnect() { + // When in initialized state + XCTAssertEqual(webSocketClient.connectionState, .initialized) + + // Call disconnect when not connected + webSocketClient.disconnect {} + + // Assert connection state is updated + XCTAssertEqual(webSocketClient.connectionState, .disconnected(source: .userInitiated)) + } + func test_connectionState_afterDecodingError() { // Simulate connection test_connectionFlow() diff --git a/Tests/StreamChatTests/Workers/Background/ConnectionRecoveryHandler_Tests.swift b/Tests/StreamChatTests/Workers/Background/ConnectionRecoveryHandler_Tests.swift index 7584c85b4f..b6b4a2b463 100644 --- a/Tests/StreamChatTests/Workers/Background/ConnectionRecoveryHandler_Tests.swift +++ b/Tests/StreamChatTests/Workers/Background/ConnectionRecoveryHandler_Tests.swift @@ -14,7 +14,6 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { var mockBackgroundTaskScheduler: BackgroundTaskScheduler_Mock! var mockRetryStrategy: RetryStrategy_Spy! var mockTime: VirtualTime { VirtualTimeTimer.time } - var mockReconnectionTimeoutHandler: ScheduledStreamTimer_Mock! override func setUp() { super.setUp() @@ -26,7 +25,6 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { mockRetryStrategy = RetryStrategy_Spy() mockRetryStrategy.mock_nextRetryDelay.returns(5) mockInternetConnection = .init(notificationCenter: mockChatClient.eventNotificationCenter) - mockReconnectionTimeoutHandler = ScheduledStreamTimer_Mock() } override func tearDown() { @@ -46,14 +44,6 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { super.tearDown() } - func test_reconnectionTimeoutHandler_onChange_shouldTimeout() { - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false, withReconnectionTimeout: true) - mockReconnectionTimeoutHandler.onChange?() - - XCTAssertEqual(mockChatClient.mockWebSocketClient.timeout_callCount, 1) - XCTAssertTrue(mockTime.scheduledTimers.isEmpty) - } - /// keepConnectionAliveInBackground == false /// /// 1. internet -> OFF (no disconnect, no bg task, no timer) @@ -511,31 +501,26 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { XCTAssertNotCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository) XCTAssertNil(mockChatClient.mockExtensionLifecycle.receivedIsReceivingEvents) - XCTAssertEqual(mockReconnectionTimeoutHandler.startCallCount, 0) } func test_webSocketStateUpdate_connecting_whenTimeout_whenNotRunning_shouldStartTimeout() { handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false, withReconnectionTimeout: true) - mockReconnectionTimeoutHandler.isRunning = false // Simulate connection update handler.webSocketClient(mockChatClient.mockWebSocketClient, didUpdateConnectionState: .connecting) XCTAssertNotCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository) XCTAssertNil(mockChatClient.mockExtensionLifecycle.receivedIsReceivingEvents) - XCTAssertEqual(mockReconnectionTimeoutHandler.startCallCount, 1) } func test_webSocketStateUpdate_connecting_whenTimeout_whenRunning_shouldNotStartTimeout() { handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false, withReconnectionTimeout: true) - mockReconnectionTimeoutHandler.isRunning = true // Simulate connection update handler.webSocketClient(mockChatClient.mockWebSocketClient, didUpdateConnectionState: .connecting) XCTAssertNotCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository) XCTAssertNil(mockChatClient.mockExtensionLifecycle.receivedIsReceivingEvents) - XCTAssertEqual(mockReconnectionTimeoutHandler.startCallCount, 0) } func test_webSocketStateUpdate_connected() { @@ -547,7 +532,6 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { XCTAssertCall(RetryStrategy_Spy.Signature.resetConsecutiveFailures, on: mockRetryStrategy, times: 1) XCTAssertCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository, times: 1) XCTAssert(mockChatClient.mockExtensionLifecycle.receivedIsReceivingEvents == true) - XCTAssertEqual(mockReconnectionTimeoutHandler.stopCallCount, 0) } func test_webSocketStateUpdate_connected_whenTimeout_shouldStopTimeout() { @@ -559,7 +543,6 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { XCTAssertCall(RetryStrategy_Spy.Signature.resetConsecutiveFailures, on: mockRetryStrategy, times: 1) XCTAssertCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository, times: 1) XCTAssert(mockChatClient.mockExtensionLifecycle.receivedIsReceivingEvents == true) - XCTAssertEqual(mockReconnectionTimeoutHandler.stopCallCount, 1) } func test_webSocketStateUpdate_disconnected_userInitiated() { @@ -648,8 +631,7 @@ private extension ConnectionRecoveryHandler_Tests { internetConnection: mockInternetConnection, reconnectionStrategy: mockRetryStrategy, reconnectionTimerType: VirtualTimeTimer.self, - keepConnectionAliveInBackground: keepConnectionAliveInBackground, - reconnectionTimeoutHandler: withReconnectionTimeout ? mockReconnectionTimeoutHandler : nil + keepConnectionAliveInBackground: keepConnectionAliveInBackground ) handler.start()