import Combine import XCTest @testable import bitchat @MainActor final class NostrRelayManagerTests: XCTestCase { private let expectedDefaultRelayCount = 4 func test_connect_directMode_connectsExistingDefaultRelaysWhenActivationBecomesAllowed() async { let context = makeContext(permission: .authorized, activationAllowed: false) XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) context.activationAllowed.value = true context.manager.connect() let connected = await waitUntil { context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount && context.manager.relays.allSatisfy(\.isConnected) } XCTAssertTrue(connected) } func test_permissionPublisher_addsAndRemovesDefaultRelays() async { let context = makeContext(permission: .denied, favorites: []) XCTAssertEqual(context.manager.getRelayStatuses().count, 0) context.permissionSubject.send(.authorized) let defaultRelaysConnected = await waitUntil { context.manager.getRelayStatuses().count == self.expectedDefaultRelayCount && context.manager.relays.allSatisfy(\.isConnected) } XCTAssertTrue(defaultRelaysConnected) context.permissionSubject.send(.denied) let defaultRelaysRemoved = await waitUntil { context.manager.getRelayStatuses().isEmpty } XCTAssertTrue(defaultRelaysRemoved) XCTAssertEqual(context.sessionFactory.allConnections.count, expectedDefaultRelayCount) XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.cancelCallCount >= 1 }) } func test_connect_waitsForTorReadinessBeforeCreatingSessions() async { let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false) context.manager.connect() XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) context.torWaiter.resolve(true) let connectedAfterTorReady = await waitUntil { context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount && context.manager.relays.allSatisfy(\.isConnected) } XCTAssertTrue(connectedAfterTorReady) } func test_connect_coalescesRepeatedCallsWhileWaitingForTor() async { let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false) context.manager.connect() context.manager.connect() context.manager.connect() XCTAssertEqual(context.torWaiter.awaitCallCount, 1) XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) context.torWaiter.resolve(true) let connectedAfterTorReady = await waitUntil { context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount && context.manager.relays.allSatisfy(\.isConnected) } XCTAssertTrue(connectedAfterTorReady) } func test_connect_whenTorReadinessFailsDoesNotCreateSessions() async { let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false) context.manager.connect() context.torWaiter.resolve(false) try? await Task.sleep(nanoseconds: 20_000_000) XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) XCTAssertFalse(context.manager.isConnected) } func test_connect_retriesTorWaitAndConnectsWhenTorBecomesReady() async { let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false) context.manager.connect() XCTAssertEqual(context.torWaiter.awaitCallCount, 1) context.torWaiter.resolve(false) // A failed wait re-queues the same targets and waits again instead of dropping them. XCTAssertEqual(context.torWaiter.awaitCallCount, 2) XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) context.torWaiter.resolve(true) let connected = await waitUntil { context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount && context.manager.relays.allSatisfy(\.isConnected) } XCTAssertTrue(connected) } func test_subscribe_unblocksDeferredEOSEWhenTorWaitAttemptsExhausted() async { let relayURL = "wss://tor-eose-unblock.example" let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) var eoseCount = 0 context.manager.subscribe( filter: makeFilter(), id: "tor-sub-unblock", relayUrls: [relayURL], handler: { _ in }, onEOSE: { eoseCount += 1 } ) for _ in 0.. = [], activationAllowed: Bool = true, userTorEnabled: Bool = false, torEnforced: Bool = false, torIsReady: Bool = true, torIsForeground: Bool = true, jitterUnit: @escaping () -> Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter) ) -> RelayManagerTestContext { let permissionSubject = CurrentValueSubject(permission) let favoritesSubject = CurrentValueSubject, Never>(favorites) let sessionFactory = MockRelaySessionFactory() let scheduler = MockRelayScheduler() let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000)) let torWaiter = MockTorWaiter(isReady: torIsReady) let torForeground = MutableBool(value: torIsForeground) let activationFlag = MutableBool(value: activationAllowed) let manager = NostrRelayManager( dependencies: NostrRelayManagerDependencies( activationAllowed: { activationFlag.value }, userTorEnabled: { userTorEnabled }, hasMutualFavorites: { !favoritesSubject.value.isEmpty }, hasLocationPermission: { permissionSubject.value == .authorized }, mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(), locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(), torEnforced: { torEnforced }, torIsReady: { torWaiter.isReady }, torIsForeground: { torForeground.value }, awaitTorReady: torWaiter.await(completion:), makeSession: { sessionFactory }, scheduleAfter: { delay, action in scheduler.schedule(delay: delay, action: action) }, now: { clock.now }, jitterUnit: jitterUnit ) ) return RelayManagerTestContext( manager: manager, permissionSubject: permissionSubject, sessionFactory: sessionFactory, scheduler: scheduler, clock: clock, activationAllowed: activationFlag, torWaiter: torWaiter, torForeground: torForeground ) } private func makeFilter() -> NostrFilter { var filter = NostrFilter() filter.kinds = [NostrProtocol.EventKind.textNote.rawValue] filter.limit = 10 return filter } private func makeSignedEvent(content: String) throws -> NostrEvent { let identity = try NostrIdentity.generate() let event = NostrEvent( pubkey: identity.publicKeyHex, createdAt: Date(), kind: .textNote, tags: [], content: content ) return try event.sign(with: identity.schnorrSigningKey()) } private func invalidSignatureCopy(of event: NostrEvent) -> NostrEvent { var invalid = event invalid.sig = String(repeating: "0", count: 128) return invalid } 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 RelayManagerTestContext { let manager: NostrRelayManager let permissionSubject: CurrentValueSubject let sessionFactory: MockRelaySessionFactory let scheduler: MockRelayScheduler let clock: MutableClock let activationAllowed: MutableBool let torWaiter: MockTorWaiter let torForeground: MutableBool } private final class MutableClock { var now: Date init(now: Date) { self.now = now } } /// Deterministic jitter source: returns the queued values in order, then a /// neutral 0.5 (jitter factor 1.0) once exhausted. private final class JitterSequence { private var values: [Double] init(_ values: [Double]) { self.values = values } func next() -> Double { values.isEmpty ? 0.5 : values.removeFirst() } } private final class MutableBool { var value: Bool init(value: Bool) { self.value = value } } private final class MockTorWaiter { private var completions: [(Bool) -> Void] = [] private(set) var awaitCallCount = 0 var isReady: Bool init(isReady: Bool) { self.isReady = isReady } func await(completion: @escaping (Bool) -> Void) { awaitCallCount += 1 completions.append(completion) } func resolve(_ ready: Bool) { isReady = ready let pending = completions completions.removeAll() pending.forEach { $0(ready) } } } private final class MockRelayScheduler: @unchecked Sendable { struct ScheduledAction { let delay: TimeInterval let action: @Sendable () -> Void } private(set) var scheduled: [ScheduledAction] = [] func schedule(delay: TimeInterval, action: @escaping @Sendable () -> Void) { scheduled.append(ScheduledAction(delay: delay, action: action)) } func runNext() { guard !scheduled.isEmpty else { return } let next = scheduled.removeFirst() next.action() } } private final class MockRelaySessionFactory: NostrRelaySessionProtocol { private(set) var requestedURLs: [String] = [] private(set) var connectionsByURL: [String: [MockRelayConnection]] = [:] var pingErrorByURL: [String: Error?] = [:] var sendErrorByURL: [String: Error?] = [:] var allConnections: [MockRelayConnection] { connectionsByURL.values.flatMap { $0 } } func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol { requestedURLs.append(url.absoluteString) let connection = MockRelayConnection( url: url.absoluteString, pingError: pingErrorByURL[url.absoluteString] ?? nil, sendError: sendErrorByURL[url.absoluteString] ?? nil ) connectionsByURL[url.absoluteString, default: []].append(connection) return connection } func latestConnection(for url: String) -> MockRelayConnection? { connectionsByURL[url]?.last } } private final class MockRelayConnection: NostrRelayConnectionProtocol { private let pingError: Error? private let sendError: Error? private var receiveHandler: ((Result) -> Void)? private(set) var resumeCallCount = 0 private(set) var cancelCallCount = 0 private(set) var sentMessages: [URLSessionWebSocketTask.Message] = [] var sentStrings: [String] { sentMessages.compactMap { switch $0 { case .string(let string): string case .data(let data): String(data: data, encoding: .utf8) @unknown default: nil } } } init(url _: String, pingError: Error? = nil, sendError: Error? = nil) { self.pingError = pingError self.sendError = sendError } func resume() { resumeCallCount += 1 } func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { cancelCallCount += 1 } var deferSendCompletions = false private var deferredSendCompletions: [(Error?) -> Void] = [] func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) { sentMessages.append(message) if deferSendCompletions { deferredSendCompletions.append(completionHandler) } else { completionHandler(sendError) } } func flushDeferredSendCompletions() { let pending = deferredSendCompletions deferredSendCompletions = [] pending.forEach { $0(sendError) } } func receive(completionHandler: @escaping (Result) -> Void) { receiveHandler = completionHandler } func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) { pongReceiveHandler(pingError) } func fail(error: Error) { let handler = receiveHandler receiveHandler = nil handler?(.failure(error)) } func emitEventMessage(subscriptionID: String, event: NostrEvent) throws { let eventData = try JSONEncoder().encode(event) let eventJSONObject = try JSONSerialization.jsonObject(with: eventData) as! [String: Any] let payload: [Any] = ["EVENT", subscriptionID, eventJSONObject] try emit(jsonObject: payload) } func emitEOSE(subscriptionID: String) throws { try emit(jsonObject: ["EOSE", subscriptionID]) } func emitOK(eventID: String, success: Bool, reason: String) throws { try emit(jsonObject: ["OK", eventID, success, reason]) } func emitNotice(message: String) throws { try emit(jsonObject: ["NOTICE", message]) } func emitRawString(_ string: String) throws { let handler = receiveHandler receiveHandler = nil handler?(.success(.string(string))) } private func emit(jsonObject: Any) throws { let data = try JSONSerialization.data(withJSONObject: jsonObject) let handler = receiveHandler receiveHandler = nil handler?(.success(.data(data))) } }