Skip to content

Commit 3b2f333

Browse files
committed
Added connection tests.
1 parent 5bf2afd commit 3b2f333

File tree

3 files changed

+316
-5
lines changed

3 files changed

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

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)