import Foundation import HapiProtocol import Testing @testable import HapiClient /// State-machine tests for `SSEClient`, all against a fake transport and a /// manually advanced clock (no real timers, no sockets). Real-time waits /// appear only in bounded polls that synchronize with the actor's async /// processing. @Suite("SSEClient state machine") struct SSEClientTests { typealias Harness = ( client: SSEClient, transport: FakeTransport, clock: ManualClock, tokens: TokenSource, box: EventBox ) private func makeHarness( scope: SSEScope = .global, visibility: String = "visible", tokens: [String?] = ["tok-1"], pathObserver: FakePathObserver? = nil, transport: FakeTransport = FakeTransport(), clock: ManualClock = ManualClock() ) async -> Harness { let tokenSource = TokenSource(tokens) let configuration = SSEClientConfiguration( baseUrl: URL(string: "https://hub.test")!, tokenProvider: { tokenSource.next() }, scope: scope, visibility: visibility, jitterMs: { 0 } ) let client = SSEClient( configuration: configuration, transport: transport, clock: clock, pathObserver: pathObserver ) let stream = await client.start() let (box, _) = collect(stream) return (client, transport, clock, tokenSource, box) } /// Advances the manual clock once the watchdog (or any sleeper) has /// actually parked — the watchdog registers its sleep asynchronously, so /// blind advances could set a deadline the test never crosses. private func advanceOnceSleeperParked(_ clock: ManualClock, byMs delta: Int) async { #expect(await waitUntil { clock.activeSleeperCount() >= 1 }) clock.advance(byMs: delta) } // MARK: - Handshake @Test func handshakeGatesConnectedState() async throws { let h = await makeHarness() let connection = try #require(await h.transport.nextConnection()) // Query-param auth, exactly like the web reference client. #expect(connection.queryItems["token"] == "tok-1") #expect(connection.queryItems["visibility"] == "visible") #expect(connection.queryItems["all"] == "true") #expect(connection.queryItems["sessionId"] == nil) #expect(connection.queryItems["lastEventId"] == nil) #expect(connection.request.value(forHTTPHeaderField: "Accept") == "text/event-stream") #expect(connection.request.value(forHTTPHeaderField: "Accept-Encoding") == nil) h.clock.advance(byMs: 5) connection.open() #expect(await waitUntil { await h.client.transportOpen }) // A 200 alone is not "up": the state machine stays .connecting until // the hub's connection-changed handshake, heartbeats included. h.clock.advance(byMs: 1) connection.sendHeartbeat() #expect(await waitUntil { await h.client.lastActivityAtMs == 6 }) #expect(await h.client.state == .connecting) #expect(h.box.handshakes.isEmpty) #expect(!h.box.states.contains(.connected)) connection.sendHandshake(resume: "ok", subscriptionId: "sub-9") #expect(await waitUntil { !h.box.handshakes.isEmpty }) #expect(h.box.handshakes == [.handshake(resume: .ok, subscriptionId: "sub-9")]) #expect(await h.client.state == .connected) #expect(await h.client.subscriptionId == "sub-9") // stateChanged(.connected) precedes the handshake yield. let events = h.box.snapshot let connectedIndex = try #require(events.firstIndex(of: .stateChanged(.connected))) let handshakeIndex = try #require(events.firstIndex(of: .handshake(resume: .ok, subscriptionId: "sub-9"))) #expect(connectedIndex < handshakeIndex) await h.client.stop() } @Test func resumeVerdictSurfacesGapWhenAbsent() async throws { let h = await makeHarness() let connection = try #require(await h.transport.nextConnection()) connection.open() connection.sendHandshake(resume: nil, subscriptionId: "sub-2") #expect(await waitUntil { !h.box.handshakes.isEmpty }) // Older hubs omit the verdict; absence must read as gap (full resync). #expect(h.box.handshakes == [.handshake(resume: .gap, subscriptionId: "sub-2")]) await h.client.stop() } // MARK: - Cursor @Test func cursorSentOnReconnectAndIsolatedPerSubscription() async throws { let h = await makeHarness() let first = try #require(await h.transport.nextConnection()) first.open() first.sendHandshake() first.sendEvent( "{\"type\":\"session-removed\",\"namespace\":\"ns\",\"sessionId\":\"s-9\"}", id: "018f:41:aa" ) #expect(await waitUntil { !h.box.syncEvents.isEmpty }) #expect(h.box.syncEvents == [.sessionRemoved(namespace: "ns", sessionId: "s-9")]) #expect(await h.client.lastEventId == "018f:41:aa") // Server closes; attempt counter was reset on open, so the reconnect // is immediate and replays the cursor of THIS subscription. first.finish() let second = try #require(await h.transport.nextConnection()) #expect(second.queryItems["lastEventId"] == "018f:41:aa") #expect(second.queryItems["all"] == "true") // A different subscription must never inherit that cursor: fresh // client, session scope, same hub. let other = await makeHarness( scope: .session("s-42"), visibility: "hidden", tokens: ["tok-B"], transport: h.transport, clock: h.clock ) let otherConnection = try #require(await other.transport.nextConnection()) #expect(otherConnection.queryItems["sessionId"] == "s-42") #expect(otherConnection.queryItems["all"] == nil) #expect(otherConnection.queryItems["visibility"] == "hidden") #expect(otherConnection.queryItems["token"] == "tok-B") #expect(otherConnection.queryItems["lastEventId"] == nil) await h.client.stop() await other.client.stop() } @Test func heartbeatKeepsWatchdogAliveButDoesNotMoveCursor() async throws { let h = await makeHarness() let connection = try #require(await h.transport.nextConnection()) connection.open() connection.sendHandshake() connection.sendEvent( "{\"type\":\"scheduled-matured\",\"namespace\":\"ns\",\"sessionId\":\"s-1\"}", id: "018f:7:aa" ) #expect(await waitUntil { await h.client.lastEventId == "018f:7:aa" }) // 50 s of silence, then a heartbeat: watchdog stays quiet and the // cursor keeps the last real id (heartbeat frames carry no id). await advanceOnceSleeperParked(h.clock, byMs: 50_000) connection.sendHeartbeat() #expect(await waitUntil { await h.client.lastActivityAtMs == 50_000 }) await advanceOnceSleeperParked(h.clock, byMs: 50_000) // 100 s total, only 50 s since the heartbeat #expect(await h.transport.expectNoConnection()) #expect(h.transport.totalConnections() == 1) #expect(await h.client.state == .connected) #expect(await h.client.lastEventId == "018f:7:aa") // Full silence from here: the 90 s threshold tears it down and the // reconnect replays the heartbeat-untouched cursor. await advanceOnceSleeperParked(h.clock, byMs: 90_000) let second = try #require(await h.transport.nextConnection()) #expect(second.queryItems["lastEventId"] == "018f:7:aa") #expect(connection.isTerminated) await h.client.stop() } // MARK: - Watchdog @Test func ninetySecondsOfSilenceTriggersReconnect() async throws { let h = await makeHarness() let first = try #require(await h.transport.nextConnection()) first.open() first.sendHandshake() #expect(await waitUntil { !h.box.handshakes.isEmpty }) await advanceOnceSleeperParked(h.clock, byMs: 90_000) _ = try #require(await h.transport.nextConnection()) #expect(await waitUntil { h.box.states.contains(.backoff(attempt: 0)) }) #expect(first.isTerminated) await h.client.stop() } @Test func hungConnectAttemptIsAbandonedAfterTimeout() async throws { let h = await makeHarness() let first = try #require(await h.transport.nextConnection()) // Never reaches OPEN; the 10 s connect deadline abandons it. await advanceOnceSleeperParked(h.clock, byMs: 10_000) _ = try #require(await h.transport.nextConnection()) #expect(await waitUntil { h.box.states.contains(.backoff(attempt: 0)) }) #expect(first.isTerminated) await h.client.stop() } // MARK: - Suspend / resume @Test func suspendDefersRetriesAndResumeRunsThemImmediately() async throws { let h = await makeHarness() let first = try #require(await h.transport.nextConnection()) first.finish() // attempt 0 fails → immediate retry let second = try #require(await h.transport.nextConnection()) second.finish() // attempt 1 → 1 s backoff #expect(await waitUntil { h.box.states.contains(.backoff(attempt: 1)) }) await h.client.suspend() #expect(await waitUntil { await h.client.state == .suspended }) // Arbitrary time passes; a suspended client schedules nothing. h.clock.advance(byMs: 600_000) #expect(await h.transport.expectNoConnection()) #expect(h.transport.totalConnections() == 2) await h.client.resume() _ = try #require(await h.transport.nextConnection()) #expect(h.transport.totalConnections() == 3) await h.client.stop() } @Test func resumeWithStaleConnectionReconnectsImmediately() async throws { let h = await makeHarness() let first = try #require(await h.transport.nextConnection()) first.open() first.sendHandshake() #expect(await waitUntil { !h.box.handshakes.isEmpty }) await h.client.suspend() // 50 s pass while suspended — beyond the 45 s foreground threshold. // The watchdog itself must stay quiet (checks skip while suspended). h.clock.advance(byMs: 50_000) #expect(await h.transport.expectNoConnection()) await h.client.resume() _ = try #require(await h.transport.nextConnection()) #expect(first.isTerminated) await h.client.stop() } @Test func resumeWithFreshConnectionKeepsIt() async throws { let h = await makeHarness() let first = try #require(await h.transport.nextConnection()) first.open() first.sendHandshake() #expect(await waitUntil { !h.box.handshakes.isEmpty }) await h.client.suspend() h.clock.advance(byMs: 10_000) // well under the 45 s threshold await h.client.resume() #expect(await h.transport.expectNoConnection()) #expect(!first.isTerminated) #expect(await h.client.state == .connected) await h.client.stop() } // MARK: - Event delivery @Test func eventsAreYieldedInArrivalOrder() async throws { let h = await makeHarness() let connection = try #require(await h.transport.nextConnection()) connection.open() connection.sendHandshake() connection.send( "id: e-1\ndata: {\"type\":\"session-removed\",\"sessionId\":\"s-1\"}\n\n" + "id: e-2\ndata: {\"type\":\"messages-invalidated\",\"sessionId\":\"s-2\"}\n\n" + "id: e-3\ndata: {\"type\":\"scheduled-matured\",\"sessionId\":\"s-3\"}\n\n" ) #expect(await waitUntil { h.box.syncEvents.count == 3 }) #expect(h.box.syncEvents == [ .sessionRemoved(namespace: nil, sessionId: "s-1"), .messagesInvalidated(namespace: nil, sessionId: "s-2"), .scheduledMatured(namespace: nil, sessionId: "s-3"), ]) #expect(await h.client.lastEventId == "e-3") await h.client.stop() } @Test func unknownEventTypesPassThroughWithoutKillingTheStream() async throws { let h = await makeHarness() let connection = try #require(await h.transport.nextConnection()) connection.open() connection.sendHandshake() connection.sendEvent( "{\"type\":\"quantum-flux\",\"namespace\":\"ns\",\"data\":{\"weird\":true}}", id: "e-8" ) connection.sendEvent( "{\"type\":\"session-removed\",\"namespace\":\"ns\",\"sessionId\":\"s-1\"}", id: "e-9" ) #expect(await waitUntil { h.box.syncEvents.count == 2 }) #expect(h.box.syncEvents == [ .unknown(type: "quantum-flux", namespace: "ns"), .sessionRemoved(namespace: "ns", sessionId: "s-1"), ]) #expect(await h.client.lastEventId == "e-9") #expect(await h.client.state == .connected) await h.client.stop() } @Test func malformedKnownEventIsSkippedWithoutAdvancingCursor() async throws { let h = await makeHarness() let connection = try #require(await h.transport.nextConnection()) connection.open() connection.sendHandshake() connection.sendEvent( "{\"type\":\"session-removed\",\"namespace\":\"ns\",\"sessionId\":\"s-1\"}", id: "good-1" ) #expect(await waitUntil { await h.client.lastEventId == "good-1" }) // Known type, missing required field: dropped, stream stays up, and // the cursor stays BEHIND the unhandled frame (at-least-once). connection.sendEvent("{\"type\":\"session-removed\",\"namespace\":\"ns\"}", id: "bad-1") let moved = await waitUntil(timeoutMs: 300) { await h.client.lastEventId == "bad-1" } #expect(!moved) connection.sendEvent("{\"type\":\"messages-invalidated\",\"sessionId\":\"s-2\"}", id: "good-2") #expect(await waitUntil { await h.client.lastEventId == "good-2" }) #expect(h.box.syncEvents.count == 2) #expect(await h.client.state == .connected) await h.client.stop() } // MARK: - Auth @Test func unauthorizedConnectGetsOneTokenRefreshBypassPerCycle() async throws { let h = await makeHarness(tokens: ["tok-1", "tok-2", "tok-3"]) let first = try #require(await h.transport.nextConnection()) #expect(first.queryItems["token"] == "tok-1") first.open(status: 401) // Free retry: the provider is asked again (it refreshes) and the // reconnect is immediate, without counting a backoff attempt. let second = try #require(await h.transport.nextConnection()) #expect(second.queryItems["token"] == "tok-2") // The bypass is capped at once per connect cycle: a second // consecutive 401 fails into normal backoff. second.open(status: 401) let third = try #require(await h.transport.nextConnection()) #expect(third.queryItems["token"] == "tok-3") #expect(await waitUntil { h.box.states.contains(.backoff(attempt: 0)) }) // Any other non-2xx fails into backoff too (503: hub not ready). third.open(status: 503) #expect(await waitUntil { h.box.states.contains(.backoff(attempt: 1)) }) // Exactly two backoffs total — none between the first 401 and its // free retry. let backoffs = h.box.states.filter { if case .backoff = $0 { return true } return false } #expect(backoffs == [.backoff(attempt: 0), .backoff(attempt: 1)]) #expect(h.tokens.callCount() == 3) await h.client.stop() } // MARK: - Network path private static let wifiPath = NetworkPathUpdate( isSatisfied: true, usedInterfaces: ["en0"], gateways: ["192.168.1.1"] ) @Test func repeatedPathAndCostUpdatesKeepHealthyStreamOpen() async throws { let observer = FakePathObserver(initialPath: Self.wifiPath) let h = await makeHarness(pathObserver: observer) let connection = try #require(await h.transport.nextConnection()) connection.open() connection.sendHandshake() #expect(await waitUntil { !h.box.handshakes.isEmpty && observer.subscriberCount() >= 1 }) observer.emitChange(Self.wifiPath) var costChange = Self.wifiPath costChange.isExpensive = true observer.emitChange(costChange) observer.emitChange(Self.wifiPath) #expect(await h.transport.expectNoConnection()) #expect(!connection.isTerminated) #expect(h.box.states == [.connecting, .connected]) await h.client.stop() } @Test(arguments: [ NetworkPathUpdate(isSatisfied: false, usedInterfaces: ["en0"], gateways: ["192.168.1.1"]), NetworkPathUpdate(isSatisfied: true, isExpensive: true, usedInterfaces: ["pdp_ip0"]), NetworkPathUpdate(isSatisfied: true, usedInterfaces: ["utun0"], gateways: ["192.168.1.1"]), NetworkPathUpdate(isSatisfied: true, usedInterfaces: ["en0"], gateways: ["192.168.2.1"]), ]) func networkRouteChangeWhileConnectedForcesReconnect(_ path: NetworkPathUpdate) async throws { let observer = FakePathObserver(initialPath: Self.wifiPath) let h = await makeHarness(pathObserver: observer) let first = try #require(await h.transport.nextConnection()) first.open() first.sendHandshake() #expect(await waitUntil { !h.box.handshakes.isEmpty }) #expect(await waitUntil { observer.subscriberCount() >= 1 }) observer.emitChange(path) let second = try #require(await h.transport.nextConnection()) #expect(first.isTerminated) second.open() second.sendHandshake() #expect(await waitUntil { h.box.handshakes.count == 2 }) // The new path is now the baseline, not another reason to reconnect. observer.emitChange(path) #expect(await h.transport.expectNoConnection()) #expect(!second.isTerminated) await h.client.stop() } // MARK: - Lifecycle @Test func stopTearsDownAndFinishesTheStream() async throws { let h = await makeHarness() let first = try #require(await h.transport.nextConnection()) first.open() first.sendHandshake() #expect(await waitUntil { !h.box.handshakes.isEmpty }) await h.client.stop() #expect(await waitUntil { first.isTerminated }) #expect(await h.client.state == .idle) #expect(await waitUntil { h.box.states.last == .idle }) // start() after stop() yields a finished stream, not a revived client. let revived = await h.client.start() var iterator = revived.makeAsyncIterator() #expect(await iterator.next() == nil) } @Test func seededCursorIsSentOnFirstConnect() async throws { let transport = FakeTransport() let clock = ManualClock() let tokenSource = TokenSource(["tok-1"]) let configuration = SSEClientConfiguration( baseUrl: URL(string: "https://hub.test")!, tokenProvider: { tokenSource.next() }, scope: .global, jitterMs: { 0 } ) let client = SSEClient(configuration: configuration, transport: transport, clock: clock) await client.seedCursor("018f:99:zz") let stream = await client.start() _ = collect(stream) let connection = try #require(await transport.nextConnection()) #expect(connection.queryItems["lastEventId"] == "018f:99:zz") await client.stop() } }