Skip to content

Commit 3ef97ff

Browse files
grdsdevclaude
andcommitted
test(realtime): add comprehensive tests for Phase 1 components
This commit adds extensive unit tests for all four new actor-based components introduced in Phase 1, ensuring correct behavior and thread safety. **Test Coverage:** 1. **ConnectionStateMachineTests** (10 tests, ~200 LOC) - Initial state verification - Successful connection - Connection reuse on multiple calls - Concurrent connect calls create single connection - Disconnect behavior - Error handling and reconnection - Close handling - Disconnection triggers reconnect - Reconnection cancellation 2. **HeartbeatMonitorTests** (8 tests, ~160 LOC) - Heartbeats sent at correct intervals - Stop cancels heartbeats - Heartbeat response clears pending ref - Timeout detection when not acknowledged - Mismatched ref doesn't clear pending - Restart creates new monitor - Stop when not started is safe 3. **AuthTokenManagerTests** (12 tests, ~180 LOC) - Initialization with/without token - Token provider called when needed - Provider not called when token exists - Update token returns change status - Update to nil - Refresh token calls provider - Refresh without provider - Refresh updates internal token - Provider error handling - Concurrent access safety - Token property access 4. **MessageRouterTests** (11 tests, ~240 LOC) - Route to registered channels - Unregistered channels don't crash - System handlers receive all messages - Both system and channel handlers work - Unregister stops routing - Re-register replaces handler - Reset removes all handlers - Channel count accuracy - Multiple system handlers - Concurrent routing **Test Quality:** - ✅ Actor isolation tested - ✅ Concurrent access tested - ✅ Edge cases covered - ✅ Error conditions validated - ✅ Timing-sensitive tests made robust - ✅ Cleanup verified **Total:** 41 new tests, ~780 LOC of test code All tests passing with comprehensive coverage of component behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 30f30b6 commit 3ef97ff

File tree

4 files changed

+829
-0
lines changed

4 files changed

+829
-0
lines changed
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
//
2+
// AuthTokenManagerTests.swift
3+
// Realtime Tests
4+
//
5+
// Created on 17/01/25.
6+
//
7+
8+
import Foundation
9+
import XCTest
10+
11+
@testable import Realtime
12+
13+
final class AuthTokenManagerTests: XCTestCase {
14+
var manager: AuthTokenManager!
15+
16+
override func tearDown() async throws {
17+
manager = nil
18+
try await super.tearDown()
19+
}
20+
21+
// MARK: - Tests
22+
23+
func testInitWithToken() async {
24+
manager = AuthTokenManager(initialToken: "initial-token", tokenProvider: nil)
25+
26+
let token = await manager.getCurrentToken()
27+
28+
XCTAssertEqual(token, "initial-token")
29+
}
30+
31+
func testInitWithoutToken() async {
32+
manager = AuthTokenManager(initialToken: nil, tokenProvider: nil)
33+
34+
let token = await manager.getCurrentToken()
35+
36+
XCTAssertNil(token)
37+
}
38+
39+
func testGetCurrentTokenCallsProviderWhenNoToken() async {
40+
var providerCallCount = 0
41+
42+
manager = AuthTokenManager(
43+
initialToken: nil,
44+
tokenProvider: {
45+
providerCallCount += 1
46+
return "provider-token"
47+
}
48+
)
49+
50+
let token = await manager.getCurrentToken()
51+
52+
XCTAssertEqual(token, "provider-token")
53+
XCTAssertEqual(providerCallCount, 1)
54+
55+
// Second call should use cached token, not call provider again
56+
let token2 = await manager.getCurrentToken()
57+
58+
XCTAssertEqual(token2, "provider-token")
59+
XCTAssertEqual(providerCallCount, 1, "Should not call provider again")
60+
}
61+
62+
func testGetCurrentTokenReturnsInitialTokenWithoutCallingProvider() async {
63+
var providerCallCount = 0
64+
65+
manager = AuthTokenManager(
66+
initialToken: "initial-token",
67+
tokenProvider: {
68+
providerCallCount += 1
69+
return "provider-token"
70+
}
71+
)
72+
73+
let token = await manager.getCurrentToken()
74+
75+
XCTAssertEqual(token, "initial-token")
76+
XCTAssertEqual(providerCallCount, 0, "Should not call provider when token exists")
77+
}
78+
79+
func testUpdateTokenReturnsTrueWhenChanged() async {
80+
manager = AuthTokenManager(initialToken: "old-token", tokenProvider: nil)
81+
82+
let changed = await manager.updateToken("new-token")
83+
84+
XCTAssertTrue(changed)
85+
86+
let token = await manager.getCurrentToken()
87+
XCTAssertEqual(token, "new-token")
88+
}
89+
90+
func testUpdateTokenReturnsFalseWhenSame() async {
91+
manager = AuthTokenManager(initialToken: "same-token", tokenProvider: nil)
92+
93+
let changed = await manager.updateToken("same-token")
94+
95+
XCTAssertFalse(changed)
96+
}
97+
98+
func testUpdateTokenToNil() async {
99+
manager = AuthTokenManager(initialToken: "some-token", tokenProvider: nil)
100+
101+
let changed = await manager.updateToken(nil)
102+
103+
XCTAssertTrue(changed)
104+
105+
let token = await manager.token
106+
XCTAssertNil(token)
107+
}
108+
109+
func testRefreshTokenCallsProvider() async {
110+
var providerCallCount = 0
111+
112+
manager = AuthTokenManager(
113+
initialToken: "initial-token",
114+
tokenProvider: {
115+
providerCallCount += 1
116+
return "refreshed-token-\(providerCallCount)"
117+
}
118+
)
119+
120+
let token1 = await manager.refreshToken()
121+
122+
XCTAssertEqual(token1, "refreshed-token-1")
123+
XCTAssertEqual(providerCallCount, 1)
124+
125+
// Refresh again
126+
let token2 = await manager.refreshToken()
127+
128+
XCTAssertEqual(token2, "refreshed-token-2")
129+
XCTAssertEqual(providerCallCount, 2)
130+
}
131+
132+
func testRefreshTokenWithoutProviderReturnsCurrentToken() async {
133+
manager = AuthTokenManager(initialToken: "current-token", tokenProvider: nil)
134+
135+
let token = await manager.refreshToken()
136+
137+
XCTAssertEqual(token, "current-token")
138+
}
139+
140+
func testRefreshTokenUpdatesInternalToken() async {
141+
manager = AuthTokenManager(
142+
initialToken: "old-token",
143+
tokenProvider: { "new-token" }
144+
)
145+
146+
_ = await manager.refreshToken()
147+
148+
let token = await manager.token
149+
XCTAssertEqual(token, "new-token")
150+
}
151+
152+
func testProviderThrowingError() async {
153+
manager = AuthTokenManager(
154+
initialToken: nil,
155+
tokenProvider: {
156+
throw NSError(domain: "test", code: 1)
157+
}
158+
)
159+
160+
let token = await manager.getCurrentToken()
161+
162+
XCTAssertNil(token, "Should return nil when provider throws")
163+
}
164+
165+
func testConcurrentAccess() async {
166+
manager = AuthTokenManager(initialToken: "initial", tokenProvider: nil)
167+
168+
// Concurrent updates
169+
await withTaskGroup(of: Void.self) { group in
170+
for i in 0..<100 {
171+
group.addTask {
172+
_ = await self.manager.updateToken("token-\(i)")
173+
}
174+
}
175+
176+
await group.waitForAll()
177+
}
178+
179+
// Should have some token (race condition, but should not crash)
180+
let token = await manager.token
181+
XCTAssertNotNil(token)
182+
XCTAssertTrue(token!.starts(with: "token-"))
183+
}
184+
185+
func testTokenPropertyReturnsCurrentValue() async {
186+
manager = AuthTokenManager(initialToken: "test-token", tokenProvider: nil)
187+
188+
let token = await manager.token
189+
190+
XCTAssertEqual(token, "test-token")
191+
}
192+
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
//
2+
// ConnectionStateMachineTests.swift
3+
// Realtime Tests
4+
//
5+
// Created on 17/01/25.
6+
//
7+
8+
import Foundation
9+
import XCTest
10+
11+
@testable import Realtime
12+
13+
final class ConnectionStateMachineTests: XCTestCase {
14+
var stateMachine: ConnectionStateMachine!
15+
var mockWebSocket: FakeWebSocket!
16+
var connectCallCount = 0
17+
var lastConnectURL: URL?
18+
var lastConnectHeaders: [String: String]?
19+
20+
override func setUp() async throws {
21+
try await super.setUp()
22+
connectCallCount = 0
23+
lastConnectURL = nil
24+
lastConnectHeaders = nil
25+
(mockWebSocket, _) = FakeWebSocket.fakes()
26+
}
27+
28+
override func tearDown() async throws {
29+
stateMachine = nil
30+
mockWebSocket = nil
31+
try await super.tearDown()
32+
}
33+
34+
// MARK: - Helper
35+
36+
func makeStateMachine(
37+
url: URL = URL(string: "ws://localhost")!,
38+
headers: [String: String] = [:],
39+
reconnectDelay: TimeInterval = 0.1
40+
) -> ConnectionStateMachine {
41+
ConnectionStateMachine(
42+
transport: { [weak self] url, headers in
43+
self?.connectCallCount += 1
44+
self?.lastConnectURL = url
45+
self?.lastConnectHeaders = headers
46+
return self!.mockWebSocket
47+
},
48+
url: url,
49+
headers: headers,
50+
reconnectDelay: reconnectDelay,
51+
logger: nil
52+
)
53+
}
54+
55+
// MARK: - Tests
56+
57+
func testInitialStateIsDisconnected() async {
58+
stateMachine = makeStateMachine()
59+
60+
let connection = await stateMachine.connection
61+
let isConnected = await stateMachine.isConnected
62+
63+
XCTAssertNil(connection)
64+
XCTAssertFalse(isConnected)
65+
}
66+
67+
func testConnectSuccessfully() async throws {
68+
stateMachine = makeStateMachine(
69+
url: URL(string: "ws://example.com")!,
70+
headers: ["Authorization": "Bearer token"]
71+
)
72+
73+
let connection = try await stateMachine.connect()
74+
75+
XCTAssertNotNil(connection)
76+
XCTAssertEqual(connectCallCount, 1)
77+
XCTAssertEqual(lastConnectURL?.absoluteString, "ws://example.com")
78+
XCTAssertEqual(lastConnectHeaders?["Authorization"], "Bearer token")
79+
80+
let isConnected = await stateMachine.isConnected
81+
XCTAssertTrue(isConnected)
82+
}
83+
84+
func testMultipleConnectCallsReuseConnection() async throws {
85+
stateMachine = makeStateMachine()
86+
87+
let connection1 = try await stateMachine.connect()
88+
let connection2 = try await stateMachine.connect()
89+
let connection3 = try await stateMachine.connect()
90+
91+
XCTAssertEqual(connectCallCount, 1, "Should only connect once")
92+
XCTAssertTrue(connection1 === mockWebSocket)
93+
XCTAssertTrue(connection2 === mockWebSocket)
94+
XCTAssertTrue(connection3 === mockWebSocket)
95+
}
96+
97+
func testConcurrentConnectCallsCreateSingleConnection() async throws {
98+
stateMachine = makeStateMachine()
99+
100+
// Simulate concurrent connect calls
101+
async let connection1 = stateMachine.connect()
102+
async let connection2 = stateMachine.connect()
103+
async let connection3 = stateMachine.connect()
104+
105+
let results = try await [connection1, connection2, connection3]
106+
107+
XCTAssertEqual(connectCallCount, 1, "Should only connect once despite concurrent calls")
108+
XCTAssertTrue(results.allSatisfy { $0 === mockWebSocket })
109+
}
110+
111+
func testDisconnectClosesConnection() async throws {
112+
stateMachine = makeStateMachine()
113+
114+
_ = try await stateMachine.connect()
115+
XCTAssertFalse(mockWebSocket.isClosed)
116+
117+
await stateMachine.disconnect(reason: "test disconnect")
118+
119+
XCTAssertTrue(mockWebSocket.isClosed)
120+
XCTAssertEqual(mockWebSocket.closeReason, "test disconnect")
121+
122+
let isConnected = await stateMachine.isConnected
123+
XCTAssertFalse(isConnected)
124+
}
125+
126+
func testDisconnectWhenDisconnectedIsNoop() async {
127+
stateMachine = makeStateMachine()
128+
129+
// Should not crash
130+
await stateMachine.disconnect()
131+
132+
let isConnected = await stateMachine.isConnected
133+
XCTAssertFalse(isConnected)
134+
}
135+
136+
func testHandleErrorTriggersReconnect() async throws {
137+
stateMachine = makeStateMachine(reconnectDelay: 0.05)
138+
139+
_ = try await stateMachine.connect()
140+
XCTAssertEqual(connectCallCount, 1)
141+
142+
// Simulate error
143+
await stateMachine.handleError(NSError(domain: "test", code: 1))
144+
145+
// Wait for reconnect delay
146+
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
147+
148+
// Should have reconnected
149+
XCTAssertEqual(connectCallCount, 2, "Should have reconnected after error")
150+
}
151+
152+
func testHandleCloseDisconnects() async throws {
153+
stateMachine = makeStateMachine()
154+
155+
_ = try await stateMachine.connect()
156+
157+
await stateMachine.handleClose(code: 1000, reason: "normal closure")
158+
159+
let isConnected = await stateMachine.isConnected
160+
XCTAssertFalse(isConnected)
161+
}
162+
163+
func testHandleDisconnectedTriggersReconnect() async throws {
164+
stateMachine = makeStateMachine(reconnectDelay: 0.05)
165+
166+
_ = try await stateMachine.connect()
167+
XCTAssertEqual(connectCallCount, 1)
168+
169+
await stateMachine.handleDisconnected()
170+
171+
// Wait for reconnect
172+
try await Task.sleep(nanoseconds: 100_000_000)
173+
174+
XCTAssertEqual(connectCallCount, 2, "Should have reconnected")
175+
}
176+
177+
func testDisconnectCancelsReconnection() async throws {
178+
stateMachine = makeStateMachine(reconnectDelay: 0.2)
179+
180+
_ = try await stateMachine.connect()
181+
182+
// Trigger reconnection
183+
await stateMachine.handleError(NSError(domain: "test", code: 1))
184+
185+
// Immediately disconnect before reconnection completes
186+
await stateMachine.disconnect()
187+
188+
// Wait longer than reconnect delay
189+
try await Task.sleep(nanoseconds: 300_000_000)
190+
191+
// Should only have connected once (reconnection was cancelled)
192+
XCTAssertEqual(connectCallCount, 1)
193+
}
194+
}

0 commit comments

Comments
 (0)