import Foundation import XCTest import RastrilloNative @testable import RastrilloApple @MainActor final class ReconnectTests: XCTestCase { private func connectivity(_ status: NativePath.Status = .satisfied) async -> (NativeConnectivity, FakePathSource) { let source = FakePathSource() let connectivity = NativeConnectivity(source: source) source.send(status) for _ in 0..<200 where connectivity.path == nil { try? await Task.sleep(nanoseconds: 5_000_000) } return (connectivity, source) } /// A Retry-After of 1 s holds even when the app is foregrounded and the /// path changes during it: waking must not cross the server's deadline. func testRetryAfterSurvivesConnectivityWakes() async { let (connectivity, source) = await connectivity() let reconnect = NativeReconnect(connectivity: connectivity, backoff: Backoff(random: { $0.lowerBound })) reconnect.failed(NativeClientError.rateLimited(retryAfter: 1)) let start = Date() var finished: Date? let pausing = Task { @MainActor in await reconnect.pause(); finished = Date() } try? await Task.sleep(nanoseconds: 150_000_000) connectivity.foregrounded() source.send(.requiresConnection) try? await Task.sleep(nanoseconds: 100_000_000) source.send(.satisfied) try? await Task.sleep(nanoseconds: 300_000_000) XCTAssertNil(finished, "A wake crossed the server's Retry-After") await pausing.value XCTAssertGreaterThanOrEqual(finished?.timeIntervalSince(start) ?? 0, 0.95) } /// After the deadline, the pause still waits for the path if it is down. func testRetryAfterThenWaitsForThePath() async { let (connectivity, source) = await connectivity() let reconnect = NativeReconnect(connectivity: connectivity) reconnect.failed(NativeClientError.rateLimited(retryAfter: 0.2)) source.send(.unsatisfied) var finished = false let pausing = Task { @MainActor in await reconnect.pause(); finished = true } try? await Task.sleep(nanoseconds: 1_000_000_000) XCTAssertFalse(finished, "It retried with the path down") source.send(.satisfied) await pausing.value XCTAssertTrue(finished) } /// The stable-stream clock starts at `opened()`: an attempt that spent /// longer than 5 s failing, and never opened, does not reset the backoff. func testStableLifetimeIsTimedFromOpen() async { let (connectivity, _) = await connectivity() var clock: TimeInterval = 0 let reconnect = NativeReconnect(connectivity: connectivity, backoff: Backoff(random: { $0.lowerBound }), now: { clock }) let first = await reconnect.pause() // 0.25 clock += 10 // a 10 s refusal, never opened let second = await reconnect.pause() XCTAssertEqual(second, first * 2, "A slow failure counted as a stable stream") reconnect.opened() clock += 6 // open for 6 s, then closed let third = await reconnect.pause() XCTAssertEqual(third, first, "A stream open longer than 5 s must reset the backoff") } /// Without a Retry-After, a wake still cuts the backoff short. func testWakeCutsAnOrdinaryBackoffShort() async { let (connectivity, _) = await connectivity() let reconnect = NativeReconnect(connectivity: connectivity, backoff: Backoff(random: { _ in 30 })) let start = Date() let pausing = Task { await reconnect.pause() } try? await Task.sleep(nanoseconds: 100_000_000) connectivity.foregrounded() _ = await pausing.value XCTAssertLessThan(Date().timeIntervalSince(start), 2) } }