Skip to content

Commit acb8dfd

Browse files
committed
Added connection tests.
1 parent 3fafdf5 commit acb8dfd

File tree

3 files changed

+320
-5
lines changed

3 files changed

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

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

Tests/AblyChatTests/Mocks/MockConnection.swift

Lines changed: 17 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,20 @@ 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,
73+
event: ARTRealtimeConnectionEvent,
74+
error: ARTErrorInfo? = nil) {
75+
let stateChange = ARTConnectionStateChange(current: state,
76+
previous: self.state,
77+
event: event,
78+
reason: error)
79+
stateCallback?(stateChange)
80+
}
6881
}

0 commit comments

Comments
 (0)