import Combine import XCTest @testable import bitchat /// Covers the reachability-gate decision logic (pure debounce) and the /// `NetworkActivationService` wiring that suppresses Tor/relay startup when /// there is provably no network. @MainActor final class NetworkReachabilityGateTests: XCTestCase { // MARK: - Pure debounce logic func test_debounce_satisfiedStaysReachable() { var d = ReachabilityDebounce(interval: 2.5, initial: true) let t0 = Date() // An interface remains present: no change, no pending. XCTAssertNil(d.observe(reachable: true, at: t0)) XCTAssertTrue(d.committed) XCTAssertFalse(d.hasPendingChange) } func test_debounce_unsatisfiedSuppressesAfterInterval() { var d = ReachabilityDebounce(interval: 2.5, initial: true) let t0 = Date() // Path drops: not committed immediately (within debounce window). XCTAssertNil(d.observe(reachable: false, at: t0)) XCTAssertTrue(d.committed) XCTAssertTrue(d.hasPendingChange) // Still within window. XCTAssertNil(d.flush(at: t0.addingTimeInterval(1.0))) XCTAssertTrue(d.committed) // Past the window: commit unreachable. XCTAssertEqual(d.flush(at: t0.addingTimeInterval(2.5)), false) XCTAssertFalse(d.committed) XCTAssertFalse(d.hasPendingChange) } func test_debounce_flapIsIgnored() { var d = ReachabilityDebounce(interval: 2.5, initial: true) let t0 = Date() // Drop then recover well within the window — must never commit a change. XCTAssertNil(d.observe(reachable: false, at: t0)) XCTAssertTrue(d.hasPendingChange) XCTAssertNil(d.observe(reachable: true, at: t0.addingTimeInterval(0.5))) XCTAssertFalse(d.hasPendingChange, "recovery should cancel the pending drop") // A late flush after the original deadline is a no-op (nothing pending). XCTAssertNil(d.flush(at: t0.addingTimeInterval(3.0))) XCTAssertTrue(d.committed) } func test_debounce_recoverAfterOutageCommitsAfterInterval() { var d = ReachabilityDebounce(interval: 2.5, initial: false) let t0 = Date() XCTAssertNil(d.observe(reachable: true, at: t0)) XCTAssertTrue(d.hasPendingChange) XCTAssertEqual(d.flush(at: t0.addingTimeInterval(2.5)), true) XCTAssertTrue(d.committed) } func test_debounce_duplicateObservationsPreservePendingDeadline() { var d = ReachabilityDebounce(interval: 2.5, initial: true) let t0 = Date() XCTAssertNil(d.observe(reachable: false, at: t0)) // Duplicate unsatisfied updates mid-window keep the original deadline. XCTAssertNil(d.observe(reachable: false, at: t0.addingTimeInterval(1.0))) XCTAssertEqual(d.pendingRemaining(at: t0.addingTimeInterval(1.0)), 1.5) // A duplicate arriving past the deadline commits immediately. XCTAssertEqual(d.observe(reachable: false, at: t0.addingTimeInterval(2.5)), false) XCTAssertNil(d.pendingRemaining(at: t0.addingTimeInterval(2.5))) } /// Wiring only: a duplicate mid-window still yields exactly one committed /// `false`, published through the monitor's debounce. /// /// This deliberately makes no assertion about *when* the flush fires. It /// used to bound elapsed wall-clock time at 1.4 s to prove the deadline was /// not restarted, which flaked on loaded CI runners — one observed run took /// 3.75 s, because `Task.sleep` and the `asyncAfter` flush are both real /// time and neither is bounded above on a busy machine. No wall-clock bound /// can distinguish "deadline preserved" from "runner is slow", so the timing /// property is asserted where it is computable instead: /// `test_debounce_duplicateObservationsPreservePendingDeadline` drives /// `ReachabilityDebounce` with injected timestamps and checks /// `pendingRemaining` directly. /// /// The clock is injected here so the debounce arithmetic is deterministic /// even though the flush itself is scheduled in real time. func test_monitor_duplicateUpdatesCommitOnceThroughTheDebounce() async { let clock = MutableDate(now: Date(timeIntervalSince1970: 1_784_000_000)) let monitor = NWPathReachabilityMonitor( debounceInterval: 0.2, now: { clock.now } ) var received: [Bool] = [] let cancellable = monitor.reachabilityPublisher.sink { received.append($0) } defer { cancellable.cancel() } monitor.ingest(reachable: false) // Duplicate unsatisfied update mid-window (e.g. an interface detail // change while still offline). clock.now = clock.now.addingTimeInterval(0.1) monitor.ingest(reachable: false) // Past the original deadline, so the scheduled flush commits. clock.now = clock.now.addingTimeInterval(0.2) // Generous: this is a liveness check, not a latency bound. A real // regression — never committing — still fails, just later. let committed = await waitUntil(timeout: 10.0) { !received.isEmpty } XCTAssertTrue(committed) XCTAssertEqual(received, [false]) } // MARK: - Service gating func test_start_whenUnreachable_suppressesTorAndRelays() { let ctx = makeService(permission: .authorized, reachable: false) ctx.service.start() XCTAssertFalse(ctx.service.activationAllowed) XCTAssertFalse(ctx.service.isNetworkReachable) XCTAssertTrue(ctx.reachability.startCalled) XCTAssertEqual(ctx.torController.startIfNeededCallCount, 0) XCTAssertEqual(ctx.torController.autoStartAllowedValues, [false]) XCTAssertEqual(ctx.relayController.connectCallCount, 0) XCTAssertEqual(ctx.relayController.disconnectCallCount, 1) } func test_start_whenReachable_allowsTorAndRelays() { let ctx = makeService(permission: .authorized, reachable: true) ctx.service.start() XCTAssertTrue(ctx.service.activationAllowed) XCTAssertEqual(ctx.torController.startIfNeededCallCount, 1) XCTAssertEqual(ctx.relayController.connectCallCount, 1) } func test_reachabilityRecovery_resumesTorAndRelays() async { let ctx = makeService(permission: .authorized, reachable: false) ctx.service.start() XCTAssertFalse(ctx.service.activationAllowed) ctx.reachability.set(true) let resumed = await waitUntil { ctx.service.activationAllowed } XCTAssertTrue(resumed) XCTAssertTrue(ctx.service.isNetworkReachable) XCTAssertGreaterThanOrEqual(ctx.torController.startIfNeededCallCount, 1) XCTAssertGreaterThanOrEqual(ctx.relayController.connectCallCount, 1) } func test_reachabilityLoss_disconnectsRelaysAndStopsTor() async { let ctx = makeService(permission: .authorized, reachable: true) ctx.service.start() XCTAssertTrue(ctx.service.activationAllowed) let disconnectsBefore = ctx.relayController.disconnectCallCount ctx.reachability.set(false) let suppressed = await waitUntil { !ctx.service.activationAllowed } XCTAssertTrue(suppressed) XCTAssertFalse(ctx.service.isNetworkReachable) XCTAssertGreaterThan(ctx.relayController.disconnectCallCount, disconnectsBefore) XCTAssertTrue(ctx.torController.autoStartAllowedValues.contains(false)) XCTAssertGreaterThanOrEqual(ctx.torController.shutdownCompletelyCallCount, 1) } // MARK: - Harness private func makeService( permission: LocationChannelManager.PermissionState, reachable: Bool ) -> Context { let suiteName = "NetworkReachabilityGateTests-\(UUID().uuidString)" let storage = UserDefaults(suiteName: suiteName)! storage.removePersistentDomain(forName: suiteName) let permissionSubject = CurrentValueSubject(permission) let favoritesSubject = CurrentValueSubject, Never>([]) let reachability = ControllableReachabilityMonitor(initial: reachable) let torController = GateMockTorController() let relayController = GateMockRelayController() let proxyController = GateMockProxyController() let service = NetworkActivationService( storage: storage, locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(), mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(), permissionProvider: { permissionSubject.value }, mutualFavoritesProvider: { favoritesSubject.value }, reachabilityMonitor: reachability, torController: torController, relayController: relayController, proxyController: proxyController, notificationCenter: NotificationCenter() ) return Context( service: service, reachability: reachability, torController: torController, relayController: relayController ) } private func waitUntil( timeout: TimeInterval = 1.0, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if condition() { return true } try? await Task.sleep(nanoseconds: 10_000_000) } return condition() } } @MainActor private struct Context { let service: NetworkActivationService let reachability: ControllableReachabilityMonitor let torController: GateMockTorController let relayController: GateMockRelayController } @MainActor private final class ControllableReachabilityMonitor: NetworkReachabilityMonitoring { private let subject: CurrentValueSubject private(set) var startCalled = false init(initial: Bool) { subject = CurrentValueSubject(initial) } var isReachable: Bool { subject.value } var reachabilityPublisher: AnyPublisher { subject.removeDuplicates().dropFirst().eraseToAnyPublisher() } func start() { startCalled = true } func stop() { startCalled = false } func set(_ reachable: Bool) { subject.send(reachable) } } @MainActor private final class GateMockTorController: NetworkActivationTorControlling { private(set) var autoStartAllowedValues: [Bool] = [] private(set) var startIfNeededCallCount = 0 private(set) var shutdownCompletelyCallCount = 0 func setAutoStartAllowed(_ allowed: Bool) { autoStartAllowedValues.append(allowed) } func startIfNeeded() { startIfNeededCallCount += 1 } func shutdownCompletely() { shutdownCompletelyCallCount += 1 } } @MainActor private final class GateMockRelayController: NetworkActivationRelayControlling { private(set) var connectCallCount = 0 private(set) var disconnectCallCount = 0 func connect() { connectCallCount += 1 } func disconnect() { disconnectCallCount += 1 } } private final class GateMockProxyController: NetworkActivationProxyControlling { private(set) var proxyModes: [Bool] = [] func setProxyMode(useTor: Bool) { proxyModes.append(useTor) } } /// Controllable clock, so debounce arithmetic is deterministic even where the /// flush itself is scheduled in real time. private final class MutableDate: @unchecked Sendable { var now: Date init(now: Date) { self.now = now } }