Skip to content

Commit 2b1468e

Browse files
committed
Add connection tests.
1 parent b6189ff commit 2b1468e

File tree

8 files changed

+381
-21
lines changed

8 files changed

+381
-21
lines changed

Sources/AblyChat/ChatClient.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ public actor DefaultChatClient: ChatClient {
7474
self.init(realtime: suppliedRealtime, clientOptions: clientOptions, internalRealtimeClientFactory: DefaultInternalRealtimeClientFactory())
7575
}
7676

77-
internal init(realtime suppliedRealtime: any SuppliedRealtimeClientProtocol, clientOptions: ChatClientOptions?, internalRealtimeClientFactory: any InternalRealtimeClientFactory) {
77+
internal init(realtime suppliedRealtime: any SuppliedRealtimeClientProtocol, clientOptions: ChatClientOptions?, internalRealtimeClientFactory: any InternalRealtimeClientFactory, timerManager: TimerManagerProtocol = TimerManager()) {
7878
self.realtime = suppliedRealtime
7979
self.clientOptions = clientOptions ?? .init()
8080

@@ -84,7 +84,7 @@ public actor DefaultChatClient: ChatClient {
8484
logger = DefaultInternalLogger(logHandler: self.clientOptions.logHandler, logLevel: self.clientOptions.logLevel)
8585
let roomFactory = DefaultRoomFactory()
8686
rooms = DefaultRooms(realtime: internalRealtime, clientOptions: self.clientOptions, logger: logger, roomFactory: roomFactory)
87-
connection = DefaultConnection(realtime: internalRealtime)
87+
connection = DefaultConnection(realtime: internalRealtime, timerManager: timerManager)
8888
}
8989

9090
public nonisolated var clientID: String {

Sources/AblyChat/DefaultConnection.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@ internal final class DefaultConnection: Connection {
1313
}
1414

1515
private let realtime: any InternalRealtimeClientProtocol
16-
private let timerManager = TimerManager()
16+
private let timerManager: TimerManagerProtocol
1717
private let connectionStatusManager = ConnectionStatusManager()
1818

19-
internal init(realtime: any InternalRealtimeClientProtocol) {
19+
internal init(realtime: any InternalRealtimeClientProtocol, timerManager: TimerManagerProtocol) {
2020
// (CHA-CS3) The initial status and error of the connection will be whatever status the realtime client returns whilst the connection status object is constructed.
2121
self.realtime = realtime
22+
self.timerManager = timerManager
2223
Task {
2324
await connectionStatusManager.updateStatus(to: .init(from: realtime.connection.state))
2425
await connectionStatusManager.updateError(to: realtime.connection.errorReason)
@@ -87,7 +88,7 @@ internal final class DefaultConnection: Connection {
8788
}
8889

8990
// (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)
91+
// subscription.emit(statusChange) // this call shouldn't be here - "Not withstanding CHA-CS5a" means just that I guess.
9192
Task {
9293
// update local state and error
9394
await connectionStatusManager.updateError(to: stateChange.reason)

Sources/AblyChat/TimerManager.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import Foundation
22

3-
internal final actor TimerManager {
3+
internal protocol TimerManagerProtocol: Actor {
4+
func setTimer(interval: TimeInterval, handler: @escaping @Sendable () -> Void)
5+
func cancelTimer()
6+
func hasRunningTask() -> Bool
7+
}
8+
9+
internal final actor TimerManager: TimerManagerProtocol {
410
private var currentTask: Task<Void, Never>?
511

612
internal func setTimer(interval: TimeInterval, handler: @escaping @Sendable () -> Void) {
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
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 options = ARTClientOptions(key: "fake:key")
13+
options.autoConnect = false
14+
let realtime = ARTRealtime(options: options)
15+
let client = DefaultChatClient(realtime: realtime, clientOptions: nil)
16+
17+
// When: the connection status object is constructed
18+
let status = await client.connection.status
19+
let error = await client.connection.error
20+
21+
// Then: CHA-CS3 - "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"
22+
// Should be `initialized` but the `ConnectionStatusManager` initial status is `disconnected` and `DefaultConnection` fires its `connectionStatusManager.updateStatus` wrapped into `Task {...}`, so the status remains `disconnected` by the time of check. Thus:
23+
// TODO: revisit together with `DefaultConnection` and https://github.com/ably-labs/ably-chat-swift/issues/49
24+
#expect(status == .disconnected)
25+
#expect(error == nil)
26+
}
27+
28+
// @spec CHA-CS4a
29+
// @spec CHA-CS4b
30+
// @spec CHA-CS4c
31+
// @spec CHA-CS4d
32+
@Test
33+
func chatClientMustAllowItsConnectionStatusToBeObserved() async throws {
34+
// Given: An instance of DefaultChatClient and a connection error
35+
let options = ARTClientOptions(key: "fake:key")
36+
options.autoConnect = false
37+
let realtime = ARTRealtime(options: options)
38+
let client = DefaultChatClient(realtime: realtime, clientOptions: nil)
39+
let connectionError = ARTErrorInfo.createUnknownError()
40+
41+
// When
42+
// (CHA-CS4d) Clients must be able to register a listener for connection status events and receive such events.
43+
let subscription = client.connection.onStatusChange()
44+
45+
subscription.emit(.init(current: .disconnected, previous: .connecting, error: connectionError, retryIn: 1)) // arbitrary values
46+
47+
let statusChange = try #require(await subscription.first { _ in true })
48+
49+
// Then
50+
// (CHA-CS4a) Connection status update events must contain the newly entered connection status.
51+
// (CHA-CS4b) Connection status update events must contain the previous connection status.
52+
// (CHA-CS4c) Connection status update events must contain the connection error (if any) that pertains to the newly entered connection status.
53+
#expect(statusChange.current == .disconnected)
54+
#expect(statusChange.previous == .connecting)
55+
#expect(statusChange.error == connectionError)
56+
}
57+
58+
// @specUntested CHA-CS4e - Currently untestable due to subscription is removed once the object is removed from memory.
59+
// @specUntested CHA-CS4f - Currently untestable due to subscription is removed once the object is removed from memory.
60+
61+
// @spec CHA-CS5a1
62+
// @spec CHA-CS5a4
63+
@Test
64+
func whenConnectionGoesFromConnectedToDisconnectedTransientDisconnectTimerStarts() async throws {
65+
// Given:
66+
// An instance of DefaultChatClient, connected realtime connection and default chat connection
67+
let connection = MockSuppliedRealtime.Connection(state: .connected)
68+
let suppliedRealtime = MockSuppliedRealtime(createWrapperSDKProxyReturnValue: .init(connection: connection))
69+
let mockTimerManager = MockTimerManager()
70+
let client = DefaultChatClient(realtime: suppliedRealtime, clientOptions: nil, internalRealtimeClientFactory: DefaultInternalRealtimeClientFactory(), timerManager: mockTimerManager)
71+
let defaultConnection = try #require(client.connection as? DefaultConnection)
72+
73+
// Status subscription
74+
let statusSubscription = defaultConnection.onStatusChange()
75+
76+
// When:
77+
78+
// Realtime connection status transitions from CONNECTED to DISCONNECTED
79+
let connectionError = ARTErrorInfo.create(withCode: 0, message: "Connection error")
80+
connection.transitionToState(.disconnected, event: .disconnected, error: connectionError)
81+
82+
// Then:
83+
84+
// Transient disconnect timer interval is 5 seconds
85+
#expect(mockTimerManager.callRecorder.waitUntil(
86+
hasMatching: "setTimer(interval:handler:)",
87+
arguments: ["interval": 5.0]
88+
)
89+
)
90+
91+
// (emitting artificial status change event for subscription awaiting below to return)
92+
let fakeError = ARTErrorInfo.create(withCode: 0, message: "Fake error")
93+
statusSubscription.emit(.init(current: .disconnected, previous: .connected, error: fakeError, retryIn: 1)) // arbitrary values
94+
95+
await mockTimerManager.expireTimer()
96+
97+
// Then:
98+
let statusChange1 = try #require(await statusSubscription.first { _ in true })
99+
let statusChange2 = try #require(await statusSubscription.first { _ in true })
100+
101+
// First emitted status was artificial and was not generated by `transitionToState:` (CHA-CS5a1 "the chat client connection status must not change")
102+
#expect(statusChange1.error == fakeError)
103+
104+
// And the second status chage was generated by `transitionToState:` when transient timer has expired (CHA-CS5a4)
105+
#expect(statusChange2.error == connectionError)
106+
}
107+
108+
// @spec CHA-CS5a2
109+
@Test
110+
func whenConnectionGoesFromDisconnectedToConnectingNoStatusChangeIsEmitted() async throws {
111+
// Given:
112+
// An instance of DefaultChatClient, connected realtime connection and default chat connection
113+
let realtimeConnection = MockSuppliedRealtime.Connection(state: .connected)
114+
let suppliedRealtime = MockSuppliedRealtime(createWrapperSDKProxyReturnValue: .init(connection: realtimeConnection))
115+
let mockTimerManager = MockTimerManager()
116+
let client = DefaultChatClient(realtime: suppliedRealtime, clientOptions: nil, internalRealtimeClientFactory: DefaultInternalRealtimeClientFactory(), timerManager: mockTimerManager)
117+
let defaultConnection = try #require(client.connection as? DefaultConnection)
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+
127+
#expect(mockTimerManager.callRecorder.waitUntil(
128+
hasMatching: "setTimer(interval:handler:)",
129+
arguments: ["interval": 5.0]
130+
)
131+
)
132+
133+
// And the realtime connection status changes to CONNECTING
134+
realtimeConnection.transitionToState(.connecting, event: .connecting)
135+
136+
// Or to DISCONNECTED
137+
realtimeConnection.transitionToState(.disconnected, event: .disconnected)
138+
139+
// (emitting artificial status change event for subscription awaiting below to return)
140+
let fakeError = ARTErrorInfo.create(withCode: 0, message: "Fake error")
141+
statusSubscription.emit(.init(current: .initialized, previous: .initialized, error: fakeError, retryIn: 1)) // arbitrary values
142+
143+
await mockTimerManager.expireTimer()
144+
145+
// Then:
146+
let statusChange1 = try #require(await statusSubscription.first { _ in true })
147+
let statusChange2 = try #require(await statusSubscription.first { _ in true })
148+
149+
// Chat client connection status must not change - first emitted status was artificial and was not generated by the calls to `transitionToState:`
150+
#expect(statusChange1.error == fakeError)
151+
152+
// And the second status change was generated by `transitionToState:` when transient timer has expired
153+
#expect(statusChange2.error == nil)
154+
}
155+
156+
// @spec CHA-CS5a3
157+
@Test
158+
func whenConnectionGoesToConnectedStatusChangeShouldBeEmitted() async throws {
159+
// Given:
160+
// An instance of DefaultChatClient, connected realtime connection and default chat connection
161+
let realtimeConnection = MockSuppliedRealtime.Connection(state: .connected)
162+
let suppliedRealtime = MockSuppliedRealtime(createWrapperSDKProxyReturnValue: .init(connection: realtimeConnection))
163+
let mockTimerManager = MockTimerManager()
164+
let client = DefaultChatClient(realtime: suppliedRealtime, clientOptions: nil, internalRealtimeClientFactory: DefaultInternalRealtimeClientFactory(), timerManager: mockTimerManager)
165+
let defaultConnection = try #require(client.connection as? DefaultConnection)
166+
167+
// Status subscription
168+
let statusSubscription = defaultConnection.onStatusChange()
169+
170+
// When:
171+
172+
// Transient disconnect timer is active
173+
realtimeConnection.transitionToState(.disconnected, event: .disconnected) // starting timer by going to DISCONNECTED
174+
175+
#expect(mockTimerManager.callRecorder.waitUntil(
176+
hasMatching: "setTimer(interval:handler:)",
177+
arguments: ["interval": 5.0]
178+
)
179+
)
180+
181+
// And the realtime connection status changes to CONNECTED
182+
realtimeConnection.transitionToState(.connected, event: .connected)
183+
184+
#expect(mockTimerManager.callRecorder.waitUntil(
185+
hasMatching: "cancelTimer",
186+
arguments: [:]
187+
)
188+
)
189+
190+
let statusChange = try #require(await statusSubscription.first { _ in true })
191+
192+
// Then:
193+
194+
// The superseding status change shall be emitted
195+
#expect(statusChange.current == .connected)
196+
#expect(statusChange.error == nil)
197+
}
198+
199+
// @spec CHA-CS5a3
200+
@Test
201+
func whenConnectionGoesToSuspendedStatusChangeShouldBeEmitted() async throws {
202+
// Given:
203+
// An instance of DefaultChatClient, connected realtime connection and default chat connection
204+
let realtimeConnection = MockSuppliedRealtime.Connection(state: .connected)
205+
let suppliedRealtime = MockSuppliedRealtime(createWrapperSDKProxyReturnValue: .init(connection: realtimeConnection))
206+
let mockTimerManager = MockTimerManager()
207+
let client = DefaultChatClient(realtime: suppliedRealtime, clientOptions: nil, internalRealtimeClientFactory: DefaultInternalRealtimeClientFactory(), timerManager: mockTimerManager)
208+
let defaultConnection = try #require(client.connection as? DefaultConnection)
209+
210+
// Status subscription
211+
let statusSubscription = defaultConnection.onStatusChange()
212+
213+
// When:
214+
215+
// Transient disconnect timer is active
216+
realtimeConnection.transitionToState(.disconnected, event: .disconnected) // starting timer by going to DISCONNECTED
217+
218+
#expect(mockTimerManager.callRecorder.waitUntil(
219+
hasMatching: "setTimer(interval:handler:)",
220+
arguments: ["interval": 5.0]
221+
)
222+
)
223+
224+
// And the realtime connection status changes to SUSPENDED
225+
realtimeConnection.transitionToState(.suspended, event: .suspended)
226+
227+
#expect(mockTimerManager.callRecorder.waitUntil(
228+
hasMatching: "cancelTimer",
229+
arguments: [:]
230+
)
231+
)
232+
233+
let statusChange = try #require(await statusSubscription.first { _ in true })
234+
235+
// Then:
236+
237+
// The superseding status change shall be emitted
238+
#expect(statusChange.current == .suspended)
239+
#expect(statusChange.error == nil)
240+
}
241+
242+
// @spec CHA-CS5a3
243+
@Test
244+
func whenConnectionGoesToFailedStatusChangeShouldBeEmitted() async throws {
245+
// Given:
246+
// An instance of DefaultChatClient, connected realtime connection and default chat connection
247+
let realtimeConnection = MockSuppliedRealtime.Connection(state: .connected)
248+
let suppliedRealtime = MockSuppliedRealtime(createWrapperSDKProxyReturnValue: .init(connection: realtimeConnection))
249+
let mockTimerManager = MockTimerManager()
250+
let client = DefaultChatClient(realtime: suppliedRealtime, clientOptions: nil, internalRealtimeClientFactory: DefaultInternalRealtimeClientFactory(), timerManager: mockTimerManager)
251+
let defaultConnection = try #require(client.connection as? DefaultConnection)
252+
253+
// Status subscription
254+
let statusSubscription = defaultConnection.onStatusChange()
255+
256+
// When:
257+
258+
// Transient disconnect timer is active
259+
realtimeConnection.transitionToState(.disconnected, event: .disconnected) // starting timer by going to DISCONNECTED
260+
261+
#expect(mockTimerManager.callRecorder.waitUntil(
262+
hasMatching: "setTimer(interval:handler:)",
263+
arguments: ["interval": 5.0]
264+
)
265+
)
266+
267+
// And the realtime connection status changes to FAILED
268+
realtimeConnection.transitionToState(.failed, event: .failed, error: ARTErrorInfo.createUnknownError())
269+
270+
#expect(mockTimerManager.callRecorder.waitUntil(
271+
hasMatching: "cancelTimer",
272+
arguments: [:]
273+
)
274+
)
275+
276+
let statusChange = try #require(await statusSubscription.first { _ in true })
277+
278+
// Then:
279+
280+
// The superseding status change shall be emitted
281+
#expect(statusChange.current == .failed)
282+
#expect(statusChange.error != nil)
283+
}
284+
285+
// @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".
286+
}

Tests/AblyChatTests/Helpers/Helpers.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ func compareAny(_ any1: Any?, with any2: Any?) -> Bool {
9898
}
9999
if let any1 = any1 as? Int, let any2 = any2 as? Int {
100100
return any1 == any2
101+
} else if let any1 = any1 as? Double, let any2 = any2 as? Double {
102+
return any1 == any2
101103
} else if let any1 = any1 as? Bool, let any2 = any2 as? Bool {
102104
return any1 == any2
103105
} else if let any1 = any1 as? String, let any2 = any2 as? String {
@@ -159,6 +161,16 @@ class MockMethodCallRecorder: @unchecked Sendable {
159161
mutex.unlock()
160162
return result
161163
}
164+
165+
func waitUntil(hasMatching signature: String, arguments: [String: Any], forMaxTimeout timeout: TimeInterval = 0.1) -> Bool {
166+
let startedAt = Date()
167+
while !hasRecord(matching: signature, arguments: arguments) {
168+
if startedAt.distance(to: Date()) > timeout {
169+
return false
170+
}
171+
}
172+
return true
173+
}
162174
}
163175

164176
private extension [MockMethodCallRecorder.MethodArgument] {

0 commit comments

Comments
 (0)