// // ChatPeerListCoordinatorContextTests.swift // bitchatTests // // Exercises `ChatPeerListCoordinator` against a mock `ChatPeerListContext` — // proving the coordinator works without a `ChatViewModel`, following the // `ChatDeliveryCoordinatorContextTests` / // `ChatTransportEventCoordinatorContextTests` exemplars. // // Scope note: the network-availability notification now posts through the // injected `ChatPeerListContext` (`notifyNetworkAvailable(peerCount:)`), so // its gating is covered here; the wall-clock timer-driven reset flows are // covered by integration-level tests. // import Testing import Foundation import BitFoundation @testable import bitchat // MARK: - Mock Context /// Lightweight stand-in for `ChatPeerListContext` proving that /// `ChatPeerListCoordinator` is testable without a `ChatViewModel`. @MainActor private final class MockChatPeerListContext: ChatPeerListContext { // Connection & chat state var isConnected = false var privateChats: [PeerID: [BitchatMessage]] = [:] func privateMessages(for peerID: PeerID) -> [BitchatMessage] { privateChats[peerID] ?? [] } var unreadPrivateMessages: Set = [] var hasTrackedPrivateChatSelection = false private(set) var updatePrivateChatPeerIfNeededCount = 0 private(set) var cleanupOldReadReceiptsCount = 0 func markPrivateChatRead(_ peerID: PeerID) { unreadPrivateMessages.remove(peerID) } func updatePrivateChatPeerIfNeeded() { updatePrivateChatPeerIfNeededCount += 1 } func cleanupOldReadReceipts() { cleanupOldReadReceiptsCount += 1 } // Peers & sessions var unifiedPeers: [BitchatPeer] = [] var connectedMeshPeers: Set = [] var reachableMeshPeers: Set = [] var activeMeshPeerCountValue = 0 private(set) var registeredEphemeralSessions: [PeerID] = [] private(set) var updateEncryptionStatusForPeersCount = 0 func isPeerConnected(_ peerID: PeerID) -> Bool { connectedMeshPeers.contains(peerID) } func isPeerReachable(_ peerID: PeerID) -> Bool { reachableMeshPeers.contains(peerID) } func activeMeshPeerCount() -> Int { activeMeshPeerCountValue } func registerEphemeralSession(peerID: PeerID) { registeredEphemeralSessions.append(peerID) } func updateEncryptionStatusForPeers() { updateEncryptionStatusForPeersCount += 1 } // Notifications private(set) var networkAvailableNotifications: [Int] = [] func notifyNetworkAvailable(peerCount: Int) { networkAvailableNotifications.append(peerCount) } // Sightings private(set) var recordedSightings: [[PeerID]] = [] func recordMeshSightings(peerIDs: [PeerID]) { recordedSightings.append(peerIDs) } } // MARK: - Helpers /// Lets the coordinator's internal `Task { @MainActor … }` hops run. @MainActor private func drainMainActorTasks() async { for _ in 0..<10 { await Task.yield() } } private func makeMessage(id: String, senderPeerID: PeerID? = nil) -> BitchatMessage { BitchatMessage( id: id, sender: "alice", content: "hello", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: senderPeerID ) } // MARK: - Coordinator Tests Against Mock Context /// Exercises `ChatPeerListCoordinator` against `MockChatPeerListContext` with /// no `ChatViewModel`. struct ChatPeerListCoordinatorContextTests { @Test @MainActor func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async { let context = MockChatPeerListContext() let coordinator = ChatPeerListCoordinator(context: context) let peerA = PeerID(str: "0011223344556677") let peerB = PeerID(str: "8899aabbccddeeff") context.isConnected = true // Empty list: disconnected, read-receipt hygiene still runs, no sessions. coordinator.didUpdatePeerList([]) await drainMainActorTasks() #expect(!context.isConnected) #expect(context.cleanupOldReadReceiptsCount == 1) #expect(context.registeredEphemeralSessions.isEmpty) #expect(context.updateEncryptionStatusForPeersCount == 1) // Non-empty list: connected, every peer gets an ephemeral session. coordinator.didUpdatePeerList([peerA, peerB]) await drainMainActorTasks() #expect(context.isConnected) #expect(context.registeredEphemeralSessions == [peerA, peerB]) #expect(context.updateEncryptionStatusForPeersCount == 2) #expect(context.cleanupOldReadReceiptsCount == 2) } @Test @MainActor func didUpdatePeerList_refreshesPrivateChatPeerOnlyWhenSelectionIsTracked() async { let context = MockChatPeerListContext() let coordinator = ChatPeerListCoordinator(context: context) let peerID = PeerID(str: "0011223344556677") coordinator.didUpdatePeerList([peerID]) await drainMainActorTasks() #expect(context.updatePrivateChatPeerIfNeededCount == 0) context.hasTrackedPrivateChatSelection = true coordinator.didUpdatePeerList([peerID]) await drainMainActorTasks() #expect(context.updatePrivateChatPeerIfNeededCount == 1) } @Test @MainActor func didUpdatePeerList_removesStaleUnreadPeerIDsButKeepsBackedConversations() async { let context = MockChatPeerListContext() let coordinator = ChatPeerListCoordinator(context: context) let currentPeer = PeerID(str: "0011223344556677") let staleShortPeer = PeerID(str: "8899aabbccddeeff") let geoDMWithMessages = PeerID(str: "nostr_" + String(repeating: "ab", count: 8)) let geoDMWithoutMessages = PeerID(str: "nostr_" + String(repeating: "cd", count: 8)) let noiseKeyWithMessages = PeerID(str: String(repeating: "ef", count: 32)) context.unifiedPeers = [ BitchatPeer( peerID: currentPeer, noisePublicKey: Data(repeating: 0x01, count: 32), nickname: "alice" ) ] context.unreadPrivateMessages = [ currentPeer, staleShortPeer, geoDMWithMessages, geoDMWithoutMessages, noiseKeyWithMessages ] context.privateChats = [ geoDMWithMessages: [makeMessage(id: "geo-1")], noiseKeyWithMessages: [makeMessage(id: "noise-1")] ] coordinator.didUpdatePeerList([currentPeer]) await drainMainActorTasks() // Stale IDs without a backing conversation are dropped; geo-DM and // Noise-key IDs with stored messages survive, as does the live peer. #expect(context.unreadPrivateMessages == [currentPeer, geoDMWithMessages, noiseKeyWithMessages]) #expect(context.cleanupOldReadReceiptsCount == 1) } @Test @MainActor func didUpdatePeerList_notifiesNetworkAvailableOncePerCooldownForNewMeshPeers() async { let context = MockChatPeerListContext() let coordinator = ChatPeerListCoordinator(context: context) let peerA = PeerID(str: "0011223344556677") let peerB = PeerID(str: "8899aabbccddeeff") context.connectedMeshPeers = [peerA, peerB] // First sighting of a mesh-active peer notifies with the mesh peer count. coordinator.didUpdatePeerList([peerA]) await drainMainActorTasks() #expect(context.networkAvailableNotifications == [1]) // The same peer again is not new — no repeat notification. coordinator.didUpdatePeerList([peerA]) await drainMainActorTasks() #expect(context.networkAvailableNotifications == [1]) // A genuinely new peer inside the cooldown window stays silent too. coordinator.didUpdatePeerList([peerA, peerB]) await drainMainActorTasks() #expect(context.networkAvailableNotifications == [1]) } @Test @MainActor func didUpdatePeerList_peerJoiningExistingMeshDoesNotNotify() async { // Cooldown zero so this proves the empty-transition gate alone — a // new peer joining while already meshed must stay silent even with // the cooldown long expired (the sitting-idle re-notify bug). let context = MockChatPeerListContext() let coordinator = ChatPeerListCoordinator(context: context, notificationCooldownSeconds: 0) let peerA = PeerID(str: "0011223344556677") let peerB = PeerID(str: "8899aabbccddeeff") context.connectedMeshPeers = [peerA, peerB] coordinator.didUpdatePeerList([peerA]) await drainMainActorTasks() #expect(context.networkAvailableNotifications == [1]) // peerB arrives while peerA is still connected: no notification. coordinator.didUpdatePeerList([peerA, peerB]) await drainMainActorTasks() #expect(context.networkAvailableNotifications == [1]) // Repeat events while idle keep staying silent. coordinator.didUpdatePeerList([peerA, peerB]) await drainMainActorTasks() #expect(context.networkAvailableNotifications == [1]) } @Test @MainActor func didUpdatePeerList_briefMeshFlapDoesNotRenotify() async { let context = MockChatPeerListContext() let coordinator = ChatPeerListCoordinator(context: context, notificationCooldownSeconds: 0) let peerA = PeerID(str: "0011223344556677") context.connectedMeshPeers = [peerA] coordinator.didUpdatePeerList([peerA]) await drainMainActorTasks() #expect(context.networkAvailableNotifications == [1]) // Link flap: empty list, then the peer returns before the 30s empty // confirmation fires — silent. coordinator.didUpdatePeerList([]) await drainMainActorTasks() coordinator.didUpdatePeerList([peerA]) await drainMainActorTasks() #expect(context.networkAvailableNotifications == [1]) } @Test @MainActor func didUpdatePeerList_meshInactivePeersNeverNotify() async { let context = MockChatPeerListContext() let coordinator = ChatPeerListCoordinator(context: context) let peerA = PeerID(str: "0011223344556677") // Peer present but neither connected nor reachable: no notification. coordinator.didUpdatePeerList([peerA]) await drainMainActorTasks() #expect(context.networkAvailableNotifications.isEmpty) } }