Skip to content

Commit 4e9f70f

Browse files
committed
Added connection tests.
1 parent aa85fba commit 4e9f70f

File tree

3 files changed

+318
-5
lines changed

3 files changed

+318
-5
lines changed

Sources/AblyChat/DefaultConnection.swift

Lines changed: 39 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,24 @@ 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
132+
private var transientDisconnectTimerSubscriptions: [Subscription<TransientDisconnectTimerEvent>] = []
133+
134+
/// Returns a subscription which emits transient disconnect timer events for testing purposes.
135+
@MainActor
136+
internal func testsOnly_subscribeToTransientDisconnectTimerEvents() -> Subscription<TransientDisconnectTimerEvent> {
137+
let subscription = Subscription<TransientDisconnectTimerEvent>(bufferingPolicy: .unbounded)
138+
transientDisconnectTimerSubscriptions.append(subscription)
139+
return subscription
140+
}
141+
#endif
104142
}
105143

106144
private final actor ConnectionStatusManager {
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
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 client = DefaultChatClient(realtime: MockRealtime(), clientOptions: nil)
13+
14+
// When: the connection status object is constructed
15+
let status = await client.connection.status
16+
let error = await client.connection.error
17+
18+
// 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
19+
// Should be `initialized` but `DefaultConnection` fires `ConnectionStatusManager` actor events using `Task`, so those events are asynchronous to syncronous connection's constructor. Thus:
20+
// TODO: revisit together with `DefaultConnection` and https://github.com/ably-labs/ably-chat-swift/issues/49
21+
#expect(status == .disconnected)
22+
#expect(error == nil)
23+
}
24+
25+
// CHA-CS4e, CHA-CS4f - currently untestable due to subscription is removed once the object is removed from memory
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 client = DefaultChatClient(realtime: MockRealtime(), clientOptions: nil)
34+
let connectionError = ARTErrorInfo.createUnknownError()
35+
36+
// When
37+
// (CHA-CS4d) Clients must be able to register a listener for connection status events and receive such events.
38+
let subscription = client.connection.onStatusChange()
39+
40+
subscription.emit(.init(current: .disconnected,
41+
previous: .connecting,
42+
error: connectionError,
43+
retryIn: 1)) // arbitrary values
44+
45+
let statusChange = try #require(await subscription.first { _ in true })
46+
47+
// Then
48+
// (CHA-CS4a) Connection status update events must contain the newly entered connection status.
49+
// (CHA-CS4b) Connection status update events must contain the previous connection status.
50+
// (CHA-CS4c) Connection status update events must contain the connection error (if any) that pertains to the newly entered connection status.
51+
#expect(statusChange.current == .disconnected)
52+
#expect(statusChange.previous == .connecting)
53+
#expect(statusChange.error == connectionError)
54+
}
55+
56+
// @spec CHA-CS5a1
57+
// @spec CHA-CS5a4
58+
@Test
59+
func whenConnectionGoesFromConnectedToDisconnectedTransientDisconnectTimerStarts() async throws {
60+
// Given:
61+
// An instance of DefaultChatClient, connected realtime connection and default chat connection
62+
let realtimeConnection = MockConnection(state: .connected)
63+
let client = DefaultChatClient(realtime: MockRealtime(connection: realtimeConnection), clientOptions: nil)
64+
let defaultConnection = 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,
88+
previous: .initialized,
89+
error: fakeError,
90+
retryIn: 1)) // arbitrary values
91+
92+
// Then:
93+
let statusChange1 = try #require(await statusSubscription.first { _ in true })
94+
let statusChange2 = try #require(await statusSubscription.first { _ in true })
95+
96+
// Transient disconnect timer interval is 5 seconds
97+
#expect(Date().timeIntervalSince1970 - timerStartedAt >= 5)
98+
99+
// Chat client connection status must not change - first emitted status was artificial and was not generated by `transitionToState:`
100+
#expect(statusChange1.error == fakeError)
101+
102+
// And the second status chage was generated by `transitionToState:` when transient timer has expired (CHA-CS5a4)
103+
#expect(statusChange2.error == connectionError)
104+
}
105+
106+
// @spec CHA-CS5a2
107+
@Test
108+
func whenConnectionGoesFromDisconnectedToConnectingNoStatusChangeIsEmitted() async throws {
109+
// Given:
110+
// An instance of DefaultChatClient, connected realtime connection and default chat connection
111+
let realtimeConnection = MockConnection(state: .connected)
112+
let client = DefaultChatClient(realtime: MockRealtime(connection: realtimeConnection), clientOptions: nil)
113+
let defaultConnection = 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,
137+
previous: .initialized,
138+
error: fakeError,
139+
retryIn: 1)) // arbitrary values
140+
141+
// Then:
142+
let statusChange1 = try #require(await statusSubscription.first { _ in true })
143+
let statusChange2 = try #require(await statusSubscription.first { _ in true })
144+
145+
// Chat client connection status must not change - first emitted status was artificial and was not generated by the calls to `transitionToState:`
146+
#expect(statusChange1.error == fakeError)
147+
148+
// And the second status change was generated by `transitionToState:` when transient timer has expired
149+
#expect(statusChange2.error == nil)
150+
}
151+
152+
// @spec CHA-CS5a3
153+
@Test
154+
func whenConnectionGoesToConnectedStatusChangeShouldBeEmitted() async throws {
155+
// Given:
156+
// An instance of DefaultChatClient, connected realtime connection and default chat connection
157+
let realtimeConnection = MockConnection(state: .connected)
158+
let client = DefaultChatClient(realtime: MockRealtime(connection: realtimeConnection), clientOptions: nil)
159+
let defaultConnection = client.connection as! DefaultConnection
160+
161+
// Transient timer subscription
162+
let transientTimerSubscription = await defaultConnection.testsOnly_subscribeToTransientDisconnectTimerEvents()
163+
164+
// Status subscription
165+
let statusSubscription = defaultConnection.onStatusChange()
166+
167+
// When:
168+
169+
// Transient disconnect timer is active
170+
let timerStartedAt = Date().timeIntervalSince1970
171+
realtimeConnection.transitionToState(.disconnected, event: .disconnected) // starting timer by going to DISCONNECTED
172+
let transientTimerEvent = try #require(await transientTimerSubscription.first { _ in true })
173+
#expect(transientTimerEvent.active)
174+
175+
// And the realtime connection status changes to CONNECTED
176+
realtimeConnection.transitionToState(.connected, event: .connected)
177+
178+
let statusChange = try #require(await statusSubscription.first { _ in true })
179+
180+
// Then:
181+
182+
// The library shall cancel the transient disconnect timer (less than 5 seconds -> was cancelled)
183+
#expect(Date().timeIntervalSince1970 - timerStartedAt < 1)
184+
185+
// The superseding status change shall be emitted
186+
#expect(statusChange.current == .connected)
187+
#expect(statusChange.error == nil)
188+
}
189+
190+
// @spec CHA-CS5a3
191+
@Test
192+
func whenConnectionGoesToSuspendedStatusChangeShouldBeEmitted() async throws {
193+
// Given:
194+
// An instance of DefaultChatClient, connected realtime connection and default chat connection
195+
let realtimeConnection = MockConnection(state: .connected)
196+
let client = DefaultChatClient(realtime: MockRealtime(connection: realtimeConnection), clientOptions: nil)
197+
let defaultConnection = client.connection as! DefaultConnection
198+
199+
// Transient timer subscription
200+
let transientTimerSubscription = await defaultConnection.testsOnly_subscribeToTransientDisconnectTimerEvents()
201+
202+
// Status subscription
203+
let statusSubscription = defaultConnection.onStatusChange()
204+
205+
// When:
206+
207+
// Transient disconnect timer is active
208+
let timerStartedAt = Date().timeIntervalSince1970
209+
realtimeConnection.transitionToState(.disconnected, event: .disconnected) // starting timer by going to DISCONNECTED
210+
let transientTimerEvent = try #require(await transientTimerSubscription.first { _ in true })
211+
#expect(transientTimerEvent.active)
212+
213+
// And the realtime connection status changes to SUSPENDED
214+
realtimeConnection.transitionToState(.suspended, event: .suspended)
215+
216+
let statusChange = try #require(await statusSubscription.first { _ in true })
217+
218+
// Then:
219+
220+
// The library shall cancel the transient disconnect timer (less than 5 seconds -> was cancelled)
221+
#expect(Date().timeIntervalSince1970 - timerStartedAt < 1)
222+
223+
// The superseding status change shall be emitted
224+
#expect(statusChange.current == .suspended)
225+
}
226+
227+
// @spec CHA-CS5a3
228+
@Test
229+
func whenConnectionGoesToFailedStatusChangeShouldBeEmitted() async throws {
230+
// Given:
231+
// An instance of DefaultChatClient, connected realtime connection and default chat connection
232+
let realtimeConnection = MockConnection(state: .connected)
233+
let client = DefaultChatClient(realtime: MockRealtime(connection: realtimeConnection), clientOptions: nil)
234+
let defaultConnection = 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+
}

Tests/AblyChatTests/Mocks/MockConnection.swift

Lines changed: 15 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,18 @@ 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,
74+
previous: self.state,
75+
event: event,
76+
reason: error)
77+
stateCallback?(stateChange)
78+
}
6879
}

0 commit comments

Comments
 (0)