diff --git a/Sources/AblyChat/DefaultConnection.swift b/Sources/AblyChat/DefaultConnection.swift index 782c0124..bff67e7b 100644 --- a/Sources/AblyChat/DefaultConnection.swift +++ b/Sources/AblyChat/DefaultConnection.swift @@ -59,6 +59,11 @@ internal final class DefaultConnection: Connection { // (CHA-CS5a3) If a transient disconnect timer is active and the realtime connections status changes to `CONNECTED`, `SUSPENDED` or `FAILED`, the library shall cancel the transient disconnect timer. The superseding status change shall be emitted. if isTimerRunning, currentState == .connected || currentState == .suspended || currentState == .failed { await timerManager.cancelTimer() + #if DEBUG + for subscription in await transientDisconnectTimerSubscriptions { + subscription.emit(.init(active: false)) + } + #endif subscription.emit(statusChange) // update local state and error await connectionStatusManager.updateError(to: stateChange.reason) @@ -67,10 +72,20 @@ internal final class DefaultConnection: Connection { // (CHA-CS5a1) If the realtime connection status transitions from `CONNECTED` to `DISCONNECTED`, the chat client connection status must not change. A 5 second transient disconnect timer shall be started. if previousState == .connected, currentState == .disconnected, !isTimerRunning { + #if DEBUG + for subscription in await self.transientDisconnectTimerSubscriptions { + subscription.emit(.init(active: true)) + } + #endif await timerManager.setTimer(interval: 5.0) { [timerManager, connectionStatusManager] in Task { // (CHA-CS5a4) If a transient disconnect timer expires the library shall emit a connection status change event. This event must contain the current status of of timer expiry, along with the original error that initiated the transient disconnect timer. await timerManager.cancelTimer() + #if DEBUG + for subscription in await self.transientDisconnectTimerSubscriptions { + subscription.emit(.init(active: false)) + } + #endif subscription.emit(statusChange) // update local state and error @@ -83,11 +98,16 @@ internal final class DefaultConnection: Connection { if isTimerRunning { await timerManager.cancelTimer() + #if DEBUG + for subscription in await transientDisconnectTimerSubscriptions { + subscription.emit(.init(active: false)) + } + #endif } } // (CHA-CS5b) Not withstanding CHA-CS5a. If a connection state event is observed from the underlying realtime library, the client must emit a status change event. The current status of that event shall reflect the status change in the underlying realtime library, along with the accompanying error. - subscription.emit(statusChange) +// subscription.emit(statusChange) // this call shouldn't be here - "Not withstanding CHA-CS5a" means just that I guess. Task { // update local state and error await connectionStatusManager.updateError(to: stateChange.reason) @@ -101,6 +121,23 @@ internal final class DefaultConnection: Connection { return subscription } + + #if DEBUG + internal struct TransientDisconnectTimerEvent: Equatable { + internal let active: Bool + } + + /// Subscription of transient disconnect timer events for testing purposes. + @MainActor private var transientDisconnectTimerSubscriptions: [Subscription] = [] + + /// Returns a subscription which emits transient disconnect timer events for testing purposes. + @MainActor + internal func testsOnly_subscribeToTransientDisconnectTimerEvents() -> Subscription { + let subscription = Subscription(bufferingPolicy: .unbounded) + transientDisconnectTimerSubscriptions.append(subscription) + return subscription + } + #endif } private final actor ConnectionStatusManager { diff --git a/Tests/AblyChatTests/DefaultConnectionTests.swift b/Tests/AblyChatTests/DefaultConnectionTests.swift new file mode 100644 index 00000000..e41bd5a8 --- /dev/null +++ b/Tests/AblyChatTests/DefaultConnectionTests.swift @@ -0,0 +1,266 @@ +import Ably +@testable import AblyChat +import Testing + +struct DefaultConnectionTests { + // @spec CHA-CS2a + // @spec CHA-CS2b + // @spec CHA-CS3 + @Test + func chatClientMustExposeItsCurrentStatus() async throws { + // Given: An instance of DefaultChatClient + let realtime = MockRealtime(createWrapperSDKProxyReturnValue: .init()) + let client = DefaultChatClient(realtime: realtime, clientOptions: nil) + + // When: the connection status object is constructed + let status = await client.connection.status + let error = await client.connection.error + + // Then: connection status and error exposed and initial status and error of the connection must be whatever status the realtime client returns whilst the connection status object is constructed + // Should be `initialized` but `DefaultConnection` fires `ConnectionStatusManager` actor events using `Task`, so those events are asynchronous to syncronous connection's constructor. Thus: + // TODO: revisit together with `DefaultConnection` and https://github.com/ably-labs/ably-chat-swift/issues/49 + #expect(status == .disconnected) + #expect(error == nil) + } + + // @spec CHA-CS4a + // @spec CHA-CS4b + // @spec CHA-CS4c + // @spec CHA-CS4d + @Test + func chatClientMustAllowItsConnectionStatusToBeObserved() async throws { + // Given: An instance of DefaultChatClient and a connection error + let realtime = MockRealtime(createWrapperSDKProxyReturnValue: .init()) + let client = DefaultChatClient(realtime: realtime, clientOptions: nil) + let connectionError = ARTErrorInfo.createUnknownError() + + // When + // (CHA-CS4d) Clients must be able to register a listener for connection status events and receive such events. + let subscription = client.connection.onStatusChange() + + subscription.emit(.init(current: .disconnected, previous: .connecting, error: connectionError, retryIn: 1)) // arbitrary values + + let statusChange = try #require(await subscription.first { _ in true }) + + // Then + // (CHA-CS4a) Connection status update events must contain the newly entered connection status. + // (CHA-CS4b) Connection status update events must contain the previous connection status. + // (CHA-CS4c) Connection status update events must contain the connection error (if any) that pertains to the newly entered connection status. + #expect(statusChange.current == .disconnected) + #expect(statusChange.previous == .connecting) + #expect(statusChange.error == connectionError) + } + + // @specUntested CHA-CS4e - Currently untestable due to subscription is removed once the object is removed from memory. + // @specUntested CHA-CS4f - Currently untestable due to subscription is removed once the object is removed from memory. + + // @spec CHA-CS5a1 + // @spec CHA-CS5a4 + @Test + func whenConnectionGoesFromConnectedToDisconnectedTransientDisconnectTimerStarts() async throws { + // Given: + // An instance of DefaultChatClient, connected realtime connection and default chat connection + let realtimeConnection = MockConnection(state: .connected) + let realtime = MockRealtime(createWrapperSDKProxyReturnValue: .init(connection: realtimeConnection)) + let client = DefaultChatClient(realtime: realtime, clientOptions: nil) + let defaultConnection = try #require(client.connection as? DefaultConnection) + + // Transient timer subscription + let transientTimerSubscription = await defaultConnection.testsOnly_subscribeToTransientDisconnectTimerEvents() + + // Status subscription + let statusSubscription = defaultConnection.onStatusChange() + + // When: + + // Realtime connection status transitions from CONNECTED to DISCONNECTED + let connectionError = ARTErrorInfo.create(withCode: 0, message: "Connection error") + realtimeConnection.transitionToState(.disconnected, event: .disconnected, error: connectionError) + + // Then: + + // A 5 second transient disconnect timer shall be started + let timerStartedAt = Date().timeIntervalSince1970 + let transientTimerEvent = try #require(await transientTimerSubscription.first { _ in true }) + #expect(transientTimerEvent.active) + + // (emitting artificial status change event for subscription awaiting below to return) + let fakeError = ARTErrorInfo.create(withCode: 0, message: "Fake error") + statusSubscription.emit(.init(current: .initialized, previous: .initialized, error: fakeError, retryIn: 1)) // arbitrary values + + // Then: + let statusChange1 = try #require(await statusSubscription.first { _ in true }) + let statusChange2 = try #require(await statusSubscription.first { _ in true }) + + // Transient disconnect timer interval is 5 seconds + #expect(Date().timeIntervalSince1970 - timerStartedAt >= 5) + + // Chat client connection status must not change - first emitted status was artificial and was not generated by `transitionToState:` + #expect(statusChange1.error == fakeError) + + // And the second status chage was generated by `transitionToState:` when transient timer has expired (CHA-CS5a4) + #expect(statusChange2.error == connectionError) + } + + // @spec CHA-CS5a2 + @Test + func whenConnectionGoesFromDisconnectedToConnectingNoStatusChangeIsEmitted() async throws { + // Given: + // An instance of DefaultChatClient, connected realtime connection and default chat connection + let realtimeConnection = MockConnection(state: .connected) + let realtime = MockRealtime(createWrapperSDKProxyReturnValue: .init(connection: realtimeConnection)) + let client = DefaultChatClient(realtime: realtime, clientOptions: nil) + let defaultConnection = try #require(client.connection as? DefaultConnection) + + // Transient timer subscription + let transientTimerSubscription = await defaultConnection.testsOnly_subscribeToTransientDisconnectTimerEvents() + + // Status subscription + let statusSubscription = defaultConnection.onStatusChange() + + // When: + + // Transient disconnect timer is active + realtimeConnection.transitionToState(.disconnected, event: .disconnected) // starting timer by going to DISCONNECTED + let transientTimerEvent = try #require(await transientTimerSubscription.first { _ in true }) + #expect(transientTimerEvent.active) + + // And the realtime connection status changes to CONNECTING + realtimeConnection.transitionToState(.connecting, event: .connecting) + + // Or to DISCONNECTED + realtimeConnection.transitionToState(.disconnected, event: .disconnected) + + // (emitting artificial status change event for subscription awaiting below to return) + let fakeError = ARTErrorInfo.create(withCode: 0, message: "Fake error") + statusSubscription.emit(.init(current: .initialized, previous: .initialized, error: fakeError, retryIn: 1)) // arbitrary values + + // Then: + let statusChange1 = try #require(await statusSubscription.first { _ in true }) + let statusChange2 = try #require(await statusSubscription.first { _ in true }) + + // Chat client connection status must not change - first emitted status was artificial and was not generated by the calls to `transitionToState:` + #expect(statusChange1.error == fakeError) + + // And the second status change was generated by `transitionToState:` when transient timer has expired + #expect(statusChange2.error == nil) + } + + // @spec CHA-CS5a3 + @Test + func whenConnectionGoesToConnectedStatusChangeShouldBeEmitted() async throws { + // Given: + // An instance of DefaultChatClient, connected realtime connection and default chat connection + let realtimeConnection = MockConnection(state: .connected) + let realtime = MockRealtime(createWrapperSDKProxyReturnValue: .init(connection: realtimeConnection)) + let client = DefaultChatClient(realtime: realtime, clientOptions: nil) + let defaultConnection = try #require(client.connection as? DefaultConnection) + + // Transient timer subscription + let transientTimerSubscription = await defaultConnection.testsOnly_subscribeToTransientDisconnectTimerEvents() + + // Status subscription + let statusSubscription = defaultConnection.onStatusChange() + + // When: + + // Transient disconnect timer is active + let timerStartedAt = Date().timeIntervalSince1970 + realtimeConnection.transitionToState(.disconnected, event: .disconnected) // starting timer by going to DISCONNECTED + let transientTimerEvent = try #require(await transientTimerSubscription.first { _ in true }) + #expect(transientTimerEvent.active) + + // And the realtime connection status changes to CONNECTED + realtimeConnection.transitionToState(.connected, event: .connected) + + let statusChange = try #require(await statusSubscription.first { _ in true }) + + // Then: + + // The library shall cancel the transient disconnect timer (less than 5 seconds -> was cancelled) + #expect(Date().timeIntervalSince1970 - timerStartedAt < 1) + + // The superseding status change shall be emitted + #expect(statusChange.current == .connected) + #expect(statusChange.error == nil) + } + + // @spec CHA-CS5a3 + @Test + func whenConnectionGoesToSuspendedStatusChangeShouldBeEmitted() async throws { + // Given: + // An instance of DefaultChatClient, connected realtime connection and default chat connection + let realtimeConnection = MockConnection(state: .connected) + let realtime = MockRealtime(createWrapperSDKProxyReturnValue: .init(connection: realtimeConnection)) + let client = DefaultChatClient(realtime: realtime, clientOptions: nil) + let defaultConnection = try #require(client.connection as? DefaultConnection) + + // Transient timer subscription + let transientTimerSubscription = await defaultConnection.testsOnly_subscribeToTransientDisconnectTimerEvents() + + // Status subscription + let statusSubscription = defaultConnection.onStatusChange() + + // When: + + // Transient disconnect timer is active + let timerStartedAt = Date().timeIntervalSince1970 + realtimeConnection.transitionToState(.disconnected, event: .disconnected) // starting timer by going to DISCONNECTED + let transientTimerEvent = try #require(await transientTimerSubscription.first { _ in true }) + #expect(transientTimerEvent.active) + + // And the realtime connection status changes to SUSPENDED + realtimeConnection.transitionToState(.suspended, event: .suspended) + + let statusChange = try #require(await statusSubscription.first { _ in true }) + + // Then: + + // The library shall cancel the transient disconnect timer (less than 5 seconds -> was cancelled) + #expect(Date().timeIntervalSince1970 - timerStartedAt < 1) + + // The superseding status change shall be emitted + #expect(statusChange.current == .suspended) + } + + // @spec CHA-CS5a3 + @Test + func whenConnectionGoesToFailedStatusChangeShouldBeEmitted() async throws { + // Given: + // An instance of DefaultChatClient, connected realtime connection and default chat connection + let realtimeConnection = MockConnection(state: .connected) + let realtime = MockRealtime(createWrapperSDKProxyReturnValue: .init(connection: realtimeConnection)) + let client = DefaultChatClient(realtime: realtime, clientOptions: nil) + let defaultConnection = try #require(client.connection as? DefaultConnection) + + // Transient timer subscription + let transientTimerSubscription = await defaultConnection.testsOnly_subscribeToTransientDisconnectTimerEvents() + + // Status subscription + let statusSubscription = defaultConnection.onStatusChange() + + // When: + + // Transient disconnect timer is active + let timerStartedAt = Date().timeIntervalSince1970 + realtimeConnection.transitionToState(.disconnected, event: .disconnected) // starting timer by going to DISCONNECTED + let transientTimerEvent = try #require(await transientTimerSubscription.first { _ in true }) + #expect(transientTimerEvent.active) + + // And the realtime connection status changes to FAILED + realtimeConnection.transitionToState(.failed, event: .failed, error: ARTErrorInfo.create(withCode: 0, message: "Connection error")) + + let statusChange = try #require(await statusSubscription.first { _ in true }) + + // Then: + + // The library shall cancel the transient disconnect timer (less than 5 seconds -> was cancelled) + #expect(Date().timeIntervalSince1970 - timerStartedAt < 1) + + // The superseding status change shall be emitted + #expect(statusChange.current == .failed) + #expect(statusChange.error != nil) + } + + // @specUntested CHA-CS5b - The implementation of this part is not clear. I've commented extra call for emitting event because I think it's in the wrong place, see `subscription.emit(statusChange)` call with "this call shouldn't be here" comment in "DefaultConnection.swift". +} diff --git a/Tests/AblyChatTests/Mocks/MockConnection.swift b/Tests/AblyChatTests/Mocks/MockConnection.swift index b126d265..d6616d69 100644 --- a/Tests/AblyChatTests/Mocks/MockConnection.swift +++ b/Tests/AblyChatTests/Mocks/MockConnection.swift @@ -1,7 +1,7 @@ import Ably import AblyChat -final class MockConnection: NSObject, ConnectionProtocol { +final class MockConnection: NSObject, ConnectionProtocol, @unchecked Sendable { let id: String? let key: String? @@ -14,6 +14,8 @@ final class MockConnection: NSObject, ConnectionProtocol { let recoveryKey: String? + private var stateCallback: ((ARTConnectionStateChange) -> Void)? + init(id: String? = nil, key: String? = nil, state: ARTRealtimeConnectionState = .initialized, errorReason: ARTErrorInfo? = nil, recoveryKey: String? = nil) { self.id = id self.key = key @@ -42,8 +44,9 @@ final class MockConnection: NSObject, ConnectionProtocol { fatalError("Not implemented") } - func on(_: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener { - fatalError("Not implemented") + func on(_ callback: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener { + stateCallback = callback + return ARTEventListener() } func once(_: ARTRealtimeConnectionEvent, callback _: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener { @@ -59,10 +62,15 @@ final class MockConnection: NSObject, ConnectionProtocol { } func off(_: ARTEventListener) { - fatalError("Not implemented") + stateCallback = nil } func off() { fatalError("Not implemented") } + + func transitionToState(_ state: ARTRealtimeConnectionState, event: ARTRealtimeConnectionEvent, error: ARTErrorInfo? = nil) { + let stateChange = ARTConnectionStateChange(current: state, previous: self.state, event: event, reason: error) + stateCallback?(stateChange) + } }