Skip to content

Commit c4a2750

Browse files
committed
Added connection tests.
1 parent aa85fba commit c4a2750

File tree

3 files changed

+312
-5
lines changed

3 files changed

+312
-5
lines changed

Sources/AblyChat/DefaultConnection.swift

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ internal final class DefaultConnection: Connection {
5959
// (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.
6060
if isTimerRunning, currentState == .connected || currentState == .suspended || currentState == .failed {
6161
await timerManager.cancelTimer()
62+
#if DEBUG
63+
for subscription in await transientDisconnectTimerSubscriptions {
64+
subscription.emit(.init(active: false))
65+
}
66+
#endif
6267
subscription.emit(statusChange)
6368
// update local state and error
6469
await connectionStatusManager.updateError(to: stateChange.reason)
@@ -67,10 +72,20 @@ internal final class DefaultConnection: Connection {
6772

6873
// (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.
6974
if previousState == .connected, currentState == .disconnected, !isTimerRunning {
75+
#if DEBUG
76+
for subscription in await self.transientDisconnectTimerSubscriptions {
77+
subscription.emit(.init(active: true))
78+
}
79+
#endif
7080
await timerManager.setTimer(interval: 5.0) { [timerManager, connectionStatusManager] in
7181
Task {
7282
// (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.
7383
await timerManager.cancelTimer()
84+
#if DEBUG
85+
for subscription in await self.transientDisconnectTimerSubscriptions {
86+
subscription.emit(.init(active: false))
87+
}
88+
#endif
7489
subscription.emit(statusChange)
7590

7691
// update local state and error
@@ -83,11 +98,16 @@ internal final class DefaultConnection: Connection {
8398

8499
if isTimerRunning {
85100
await timerManager.cancelTimer()
101+
#if DEBUG
102+
for subscription in await transientDisconnectTimerSubscriptions {
103+
subscription.emit(.init(active: false))
104+
}
105+
#endif
86106
}
87107
}
88108

89109
// (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.
90-
subscription.emit(statusChange)
110+
// subscription.emit(statusChange) // this call shouldn't be here - "Not withstanding CHA-CS5a" means just that I guess.
91111
Task {
92112
// update local state and error
93113
await connectionStatusManager.updateError(to: stateChange.reason)
@@ -101,6 +121,23 @@ internal final class DefaultConnection: Connection {
101121

102122
return subscription
103123
}
124+
125+
#if DEBUG
126+
internal struct TransientDisconnectTimerEvent: Equatable {
127+
internal let active: Bool
128+
}
129+
130+
/// Subscription of transient disconnect timer events for testing purposes.
131+
@MainActor private var transientDisconnectTimerSubscriptions: [Subscription<TransientDisconnectTimerEvent>] = []
132+
133+
/// Returns a subscription which emits transient disconnect timer events for testing purposes.
134+
@MainActor
135+
internal func testsOnly_subscribeToTransientDisconnectTimerEvents() -> Subscription<TransientDisconnectTimerEvent> {
136+
let subscription = Subscription<TransientDisconnectTimerEvent>(bufferingPolicy: .unbounded)
137+
transientDisconnectTimerSubscriptions.append(subscription)
138+
return subscription
139+
}
140+
#endif
104141
}
105142

106143
private final actor ConnectionStatusManager {
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import Ably
2+
@testable import AblyChat
3+
import Testing
4+
5+
struct DefaultConnectionTests {
6+
// @spec CHA-CS2a
7+
// @spec CHA-CS2b
8+
// @spec CHA-CS3
9+
@Test
10+
func chatClientMustExposeItsCurrentStatus() async throws {
11+
// Given: An instance of DefaultChatClient
12+
let realtime = MockRealtime(createWrapperSDKProxyReturnValue: .init())
13+
let client = DefaultChatClient(realtime: realtime, clientOptions: nil)
14+
15+
// When: the connection status object is constructed
16+
let status = await client.connection.status
17+
let error = await client.connection.error
18+
19+
// 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
20+
// Should be `initialized` but `DefaultConnection` fires `ConnectionStatusManager` actor events using `Task`, so those events are asynchronous to syncronous connection's constructor. Thus:
21+
// TODO: revisit together with `DefaultConnection` and https://github.com/ably-labs/ably-chat-swift/issues/49
22+
#expect(status == .disconnected)
23+
#expect(error == nil)
24+
}
25+
26+
// CHA-CS4e, CHA-CS4f - currently untestable due to subscription is removed once the object is removed from memory
27+
// @spec CHA-CS4a
28+
// @spec CHA-CS4b
29+
// @spec CHA-CS4c
30+
// @spec CHA-CS4d
31+
@Test
32+
func chatClientMustAllowItsConnectionStatusToBeObserved() async throws {
33+
// Given: An instance of DefaultChatClient and a connection error
34+
let realtime = MockRealtime(createWrapperSDKProxyReturnValue: .init())
35+
let client = DefaultChatClient(realtime: realtime, clientOptions: nil)
36+
let connectionError = ARTErrorInfo.createUnknownError()
37+
38+
// When
39+
// (CHA-CS4d) Clients must be able to register a listener for connection status events and receive such events.
40+
let subscription = client.connection.onStatusChange()
41+
42+
subscription.emit(.init(current: .disconnected, previous: .connecting, error: connectionError, retryIn: 1)) // arbitrary values
43+
44+
let statusChange = try #require(await subscription.first { _ in true })
45+
46+
// Then
47+
// (CHA-CS4a) Connection status update events must contain the newly entered connection status.
48+
// (CHA-CS4b) Connection status update events must contain the previous connection status.
49+
// (CHA-CS4c) Connection status update events must contain the connection error (if any) that pertains to the newly entered connection status.
50+
#expect(statusChange.current == .disconnected)
51+
#expect(statusChange.previous == .connecting)
52+
#expect(statusChange.error == connectionError)
53+
}
54+
55+
// @spec CHA-CS5a1
56+
// @spec CHA-CS5a4
57+
@Test
58+
func whenConnectionGoesFromConnectedToDisconnectedTransientDisconnectTimerStarts() async throws {
59+
// Given:
60+
// An instance of DefaultChatClient, connected realtime connection and default chat connection
61+
let realtimeConnection = MockConnection(state: .connected)
62+
let realtime = MockRealtime(createWrapperSDKProxyReturnValue: .init(connection: realtimeConnection))
63+
let client = DefaultChatClient(realtime: realtime, clientOptions: nil)
64+
let defaultConnection = try #require(client.connection as? DefaultConnection)
65+
66+
// Transient timer subscription
67+
let transientTimerSubscription = await defaultConnection.testsOnly_subscribeToTransientDisconnectTimerEvents()
68+
69+
// Status subscription
70+
let statusSubscription = defaultConnection.onStatusChange()
71+
72+
// When:
73+
74+
// Realtime connection status transitions from CONNECTED to DISCONNECTED
75+
let connectionError = ARTErrorInfo.create(withCode: 0, message: "Connection error")
76+
realtimeConnection.transitionToState(.disconnected, event: .disconnected, error: connectionError)
77+
78+
// Then:
79+
80+
// A 5 second transient disconnect timer shall be started
81+
let timerStartedAt = Date().timeIntervalSince1970
82+
let transientTimerEvent = try #require(await transientTimerSubscription.first { _ in true })
83+
#expect(transientTimerEvent.active)
84+
85+
// (emitting artificial status change event for subscription awaiting below to return)
86+
let fakeError = ARTErrorInfo.create(withCode: 0, message: "Fake error")
87+
statusSubscription.emit(.init(current: .initialized, previous: .initialized, error: fakeError, retryIn: 1)) // arbitrary values
88+
89+
// Then:
90+
let statusChange1 = try #require(await statusSubscription.first { _ in true })
91+
let statusChange2 = try #require(await statusSubscription.first { _ in true })
92+
93+
// Transient disconnect timer interval is 5 seconds
94+
#expect(Date().timeIntervalSince1970 - timerStartedAt >= 5)
95+
96+
// Chat client connection status must not change - first emitted status was artificial and was not generated by `transitionToState:`
97+
#expect(statusChange1.error == fakeError)
98+
99+
// And the second status chage was generated by `transitionToState:` when transient timer has expired (CHA-CS5a4)
100+
#expect(statusChange2.error == connectionError)
101+
}
102+
103+
// @spec CHA-CS5a2
104+
@Test
105+
func whenConnectionGoesFromDisconnectedToConnectingNoStatusChangeIsEmitted() async throws {
106+
// Given:
107+
// An instance of DefaultChatClient, connected realtime connection and default chat connection
108+
let realtimeConnection = MockConnection(state: .connected)
109+
let realtime = MockRealtime(createWrapperSDKProxyReturnValue: .init(connection: realtimeConnection))
110+
let client = DefaultChatClient(realtime: realtime, clientOptions: nil)
111+
let defaultConnection = try #require(client.connection as? DefaultConnection)
112+
113+
// Transient timer subscription
114+
let transientTimerSubscription = await defaultConnection.testsOnly_subscribeToTransientDisconnectTimerEvents()
115+
116+
// Status subscription
117+
let statusSubscription = defaultConnection.onStatusChange()
118+
119+
// When:
120+
121+
// Transient disconnect timer is active
122+
realtimeConnection.transitionToState(.disconnected, event: .disconnected) // starting timer by going to DISCONNECTED
123+
let transientTimerEvent = try #require(await transientTimerSubscription.first { _ in true })
124+
#expect(transientTimerEvent.active)
125+
126+
// And the realtime connection status changes to CONNECTING
127+
realtimeConnection.transitionToState(.connecting, event: .connecting)
128+
129+
// Or to DISCONNECTED
130+
realtimeConnection.transitionToState(.disconnected, event: .disconnected)
131+
132+
// (emitting artificial status change event for subscription awaiting below to return)
133+
let fakeError = ARTErrorInfo.create(withCode: 0, message: "Fake error")
134+
statusSubscription.emit(.init(current: .initialized, previous: .initialized, error: fakeError, retryIn: 1)) // arbitrary values
135+
136+
// Then:
137+
let statusChange1 = try #require(await statusSubscription.first { _ in true })
138+
let statusChange2 = try #require(await statusSubscription.first { _ in true })
139+
140+
// Chat client connection status must not change - first emitted status was artificial and was not generated by the calls to `transitionToState:`
141+
#expect(statusChange1.error == fakeError)
142+
143+
// And the second status change was generated by `transitionToState:` when transient timer has expired
144+
#expect(statusChange2.error == nil)
145+
}
146+
147+
// @spec CHA-CS5a3
148+
@Test
149+
func whenConnectionGoesToConnectedStatusChangeShouldBeEmitted() async throws {
150+
// Given:
151+
// An instance of DefaultChatClient, connected realtime connection and default chat connection
152+
let realtimeConnection = MockConnection(state: .connected)
153+
let realtime = MockRealtime(createWrapperSDKProxyReturnValue: .init(connection: realtimeConnection))
154+
let client = DefaultChatClient(realtime: realtime, clientOptions: nil)
155+
let defaultConnection = try #require(client.connection as? DefaultConnection)
156+
157+
// Transient timer subscription
158+
let transientTimerSubscription = await defaultConnection.testsOnly_subscribeToTransientDisconnectTimerEvents()
159+
160+
// Status subscription
161+
let statusSubscription = defaultConnection.onStatusChange()
162+
163+
// When:
164+
165+
// Transient disconnect timer is active
166+
let timerStartedAt = Date().timeIntervalSince1970
167+
realtimeConnection.transitionToState(.disconnected, event: .disconnected) // starting timer by going to DISCONNECTED
168+
let transientTimerEvent = try #require(await transientTimerSubscription.first { _ in true })
169+
#expect(transientTimerEvent.active)
170+
171+
// And the realtime connection status changes to CONNECTED
172+
realtimeConnection.transitionToState(.connected, event: .connected)
173+
174+
let statusChange = try #require(await statusSubscription.first { _ in true })
175+
176+
// Then:
177+
178+
// The library shall cancel the transient disconnect timer (less than 5 seconds -> was cancelled)
179+
#expect(Date().timeIntervalSince1970 - timerStartedAt < 1)
180+
181+
// The superseding status change shall be emitted
182+
#expect(statusChange.current == .connected)
183+
#expect(statusChange.error == nil)
184+
}
185+
186+
// @spec CHA-CS5a3
187+
@Test
188+
func whenConnectionGoesToSuspendedStatusChangeShouldBeEmitted() async throws {
189+
// Given:
190+
// An instance of DefaultChatClient, connected realtime connection and default chat connection
191+
let realtimeConnection = MockConnection(state: .connected)
192+
let realtime = MockRealtime(createWrapperSDKProxyReturnValue: .init(connection: realtimeConnection))
193+
let client = DefaultChatClient(realtime: realtime, clientOptions: nil)
194+
let defaultConnection = try #require(client.connection as? DefaultConnection)
195+
196+
// Transient timer subscription
197+
let transientTimerSubscription = await defaultConnection.testsOnly_subscribeToTransientDisconnectTimerEvents()
198+
199+
// Status subscription
200+
let statusSubscription = defaultConnection.onStatusChange()
201+
202+
// When:
203+
204+
// Transient disconnect timer is active
205+
let timerStartedAt = Date().timeIntervalSince1970
206+
realtimeConnection.transitionToState(.disconnected, event: .disconnected) // starting timer by going to DISCONNECTED
207+
let transientTimerEvent = try #require(await transientTimerSubscription.first { _ in true })
208+
#expect(transientTimerEvent.active)
209+
210+
// And the realtime connection status changes to SUSPENDED
211+
realtimeConnection.transitionToState(.suspended, event: .suspended)
212+
213+
let statusChange = try #require(await statusSubscription.first { _ in true })
214+
215+
// Then:
216+
217+
// The library shall cancel the transient disconnect timer (less than 5 seconds -> was cancelled)
218+
#expect(Date().timeIntervalSince1970 - timerStartedAt < 1)
219+
220+
// The superseding status change shall be emitted
221+
#expect(statusChange.current == .suspended)
222+
}
223+
224+
// @spec CHA-CS5a3
225+
@Test
226+
func whenConnectionGoesToFailedStatusChangeShouldBeEmitted() async throws {
227+
// Given:
228+
// An instance of DefaultChatClient, connected realtime connection and default chat connection
229+
let realtimeConnection = MockConnection(state: .connected)
230+
let realtime = MockRealtime(createWrapperSDKProxyReturnValue: .init(connection: realtimeConnection))
231+
let client = DefaultChatClient(realtime: realtime, clientOptions: nil)
232+
let defaultConnection = try #require(client.connection as? DefaultConnection)
233+
234+
// Transient timer subscription
235+
let transientTimerSubscription = await defaultConnection.testsOnly_subscribeToTransientDisconnectTimerEvents()
236+
237+
// Status subscription
238+
let statusSubscription = defaultConnection.onStatusChange()
239+
240+
// When:
241+
242+
// Transient disconnect timer is active
243+
let timerStartedAt = Date().timeIntervalSince1970
244+
realtimeConnection.transitionToState(.disconnected, event: .disconnected) // starting timer by going to DISCONNECTED
245+
let transientTimerEvent = try #require(await transientTimerSubscription.first { _ in true })
246+
#expect(transientTimerEvent.active)
247+
248+
// And the realtime connection status changes to FAILED
249+
realtimeConnection.transitionToState(.failed, event: .failed, error: ARTErrorInfo.create(withCode: 0, message: "Connection error"))
250+
251+
let statusChange = try #require(await statusSubscription.first { _ in true })
252+
253+
// Then:
254+
255+
// The library shall cancel the transient disconnect timer (less than 5 seconds -> was cancelled)
256+
#expect(Date().timeIntervalSince1970 - timerStartedAt < 1)
257+
258+
// The superseding status change shall be emitted
259+
#expect(statusChange.current == .failed)
260+
#expect(statusChange.error != nil)
261+
}
262+
}

Tests/AblyChatTests/Mocks/MockConnection.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import Ably
22
import AblyChat
33

4-
final class MockConnection: NSObject, ConnectionProtocol {
4+
final class MockConnection: NSObject, ConnectionProtocol, @unchecked Sendable {
55
let id: String?
66

77
let key: String?
@@ -14,6 +14,8 @@ final class MockConnection: NSObject, ConnectionProtocol {
1414

1515
let recoveryKey: String?
1616

17+
private var stateCallback: ((ARTConnectionStateChange) -> Void)?
18+
1719
init(id: String? = nil, key: String? = nil, state: ARTRealtimeConnectionState = .initialized, errorReason: ARTErrorInfo? = nil, recoveryKey: String? = nil) {
1820
self.id = id
1921
self.key = key
@@ -42,8 +44,9 @@ final class MockConnection: NSObject, ConnectionProtocol {
4244
fatalError("Not implemented")
4345
}
4446

45-
func on(_: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener {
46-
fatalError("Not implemented")
47+
func on(_ callback: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener {
48+
stateCallback = callback
49+
return ARTEventListener()
4750
}
4851

4952
func once(_: ARTRealtimeConnectionEvent, callback _: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener {
@@ -59,10 +62,15 @@ final class MockConnection: NSObject, ConnectionProtocol {
5962
}
6063

6164
func off(_: ARTEventListener) {
62-
fatalError("Not implemented")
65+
stateCallback = nil
6366
}
6467

6568
func off() {
6669
fatalError("Not implemented")
6770
}
71+
72+
func transitionToState(_ state: ARTRealtimeConnectionState, event: ARTRealtimeConnectionEvent, error: ARTErrorInfo? = nil) {
73+
let stateChange = ARTConnectionStateChange(current: state, previous: self.state, event: event, reason: error)
74+
stateCallback?(stateChange)
75+
}
6876
}

0 commit comments

Comments
 (0)