// // ChatNostrCoordinatorContextTests.swift // bitchatTests // // Exercises `ChatNostrCoordinator` against a mock `ChatNostrContext` — // proving the coordinator works without a `ChatViewModel`, following the // `ChatDeliveryCoordinatorContextTests` / `ChatPrivateConversationCoordinatorContextTests` // exemplars. // import Testing import Foundation import BitFoundation @testable import bitchat // MARK: - Mock Context /// Lightweight stand-in for `ChatNostrContext` proving that /// `ChatNostrCoordinator` is testable without a `ChatViewModel`. @MainActor private final class MockChatNostrContext: ChatNostrContext { // Channel & subscription state var activeChannel: ChannelID = .mesh var currentGeohash: String? var geoSubscriptionID: String? var geoDmSubscriptionID: String? var geoSamplingSubs: [String: String] = [:] var lastGeoNotificationAt: [String: Date] = [:] var nostrRelayManager: NostrRelayManager? { nil } // Public timeline & pipeline var messages: [BitchatMessage] = [] private(set) var pipelineResetCount = 0 private(set) var pipelineChannelUpdates: [ChannelID] = [] private(set) var refreshedChannels: [ChannelID?] = [] private(set) var publicSystemMessages: [String] = [] var pendingGeohashSystemMessages: [String] = [] private(set) var appendedGeohashMessages: [(message: BitchatMessage, geohash: String)] = [] private(set) var synchronizedGeohashes: [String] = [] func resetPublicMessagePipeline() { pipelineResetCount += 1 } func updatePublicMessagePipelineChannel(_ channel: ChannelID) { pipelineChannelUpdates.append(channel) } func refreshVisibleMessages(from channel: ChannelID?) { refreshedChannels.append(channel) } func addPublicSystemMessage(_ content: String) { publicSystemMessages.append(content) } func drainPendingGeohashSystemMessages() -> [String] { defer { pendingGeohashSystemMessages.removeAll() } return pendingGeohashSystemMessages } func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { guard !appendedGeohashMessages.contains(where: { $0.message.id == message.id && $0.geohash == geohash }) else { return false } appendedGeohashMessages.append((message, geohash)) return true } func synchronizePublicConversationStore(forGeohash geohash: String) { synchronizedGeohashes.append(geohash) } // Inbound public messages private(set) var handledPublicMessages: [BitchatMessage] = [] private(set) var mentionCheckedMessageIDs: [String] = [] private(set) var hapticMessageIDs: [String] = [] func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessages.append(message) } func checkForMentions(_ message: BitchatMessage) { mentionCheckedMessageIDs.append(message.id) } func sendHapticFeedback(for message: BitchatMessage) { hapticMessageIDs.append(message.id) } func parseMentions(from content: String) -> [String] { [] } // Inbound private (geohash DM) payloads var selectedPrivateChatPeer: PeerID? var nostrKeyMapping: [PeerID: String] = [:] private(set) var handledPrivateMessages: [(payload: NoisePayload, senderPubkey: String, convKey: PeerID, timestamp: Date)] = [] private(set) var handledDelivered: [(senderPubkey: String, convKey: PeerID)] = [] private(set) var handledReadReceipts: [(senderPubkey: String, convKey: PeerID)] = [] private(set) var startedPrivateChats: [PeerID] = [] func handlePrivateMessage( _ payload: NoisePayload, senderPubkey: String, convKey: PeerID, id: NostrIdentity, messageTimestamp: Date ) { handledPrivateMessages.append((payload, senderPubkey, convKey, messageTimestamp)) } func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { handledDelivered.append((senderPubkey, convKey)) } func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { handledReadReceipts.append((senderPubkey, convKey)) } func startPrivateChat(with peerID: PeerID) { startedPrivateChats.append(peerID) } // Nostr identity & blocking var geohashIdentities: [String: NostrIdentity] = [:] var nostrIdentity: NostrIdentity? var blockedNostrPubkeys: Set = [] var displayNamesByPubkey: [String: String] = [:] private struct NoIdentity: Error {} func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { guard let identity = geohashIdentities[geohash] else { throw NoIdentity() } return identity } func currentNostrIdentity() -> NostrIdentity? { nostrIdentity } func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { blockedNostrPubkeys.contains(pubkeyHexLowercased.lowercased()) } func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { displayNamesByPubkey[pubkeyHex] ?? "anon" } // Event dedup private(set) var recordedNostrEventIDs: [String] = [] private var processedNostrEventIDs: Set = [] private(set) var clearProcessedNostrEventsCount = 0 func hasProcessedNostrEvent(_ eventID: String) -> Bool { processedNostrEventIDs.contains(eventID) } func recordProcessedNostrEvent(_ eventID: String) { processedNostrEventIDs.insert(eventID) recordedNostrEventIDs.append(eventID) } func clearProcessedNostrEvents() { processedNostrEventIDs.removeAll() clearProcessedNostrEventsCount += 1 } // Geo participants & presence var geoNicknames: [String: String] = [:] private(set) var teleportedKeys: Set = [] var teleportedGeoCount: Int { teleportedKeys.count } private(set) var refreshTimerStartCount = 0 private(set) var refreshTimerStopCount = 0 private(set) var activeParticipantGeohashes: [String?] = [] private(set) var recordedParticipants: [String] = [] private(set) var recordedSampledParticipants: [(pubkeyHex: String, geohash: String)] = [] private(set) var clearTeleportedGeoCount = 0 private(set) var clearGeoNicknamesCount = 0 var visiblePeople: [GeoPerson] = [] func startGeoParticipantRefreshTimer() { refreshTimerStartCount += 1 } func stopGeoParticipantRefreshTimer() { refreshTimerStopCount += 1 } func setActiveParticipantGeohash(_ geohash: String?) { activeParticipantGeohashes.append(geohash) } func recordGeoParticipant(pubkeyHex: String) { recordedParticipants.append(pubkeyHex) } func recordGeoParticipant(pubkeyHex: String, geohash: String) { recordedSampledParticipants.append((pubkeyHex, geohash)) } func geoParticipantCount(for geohash: String) -> Int { recordedSampledParticipants.filter { $0.geohash == geohash }.count } func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) { geoNicknames[pubkeyHex.lowercased()] = nickname } func markGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.insert(pubkeyHexLowercased) } func clearGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.remove(pubkeyHexLowercased) } func clearTeleportedGeo() { teleportedKeys.removeAll() clearTeleportedGeoCount += 1 } func clearGeoNicknames() { geoNicknames.removeAll() clearGeoNicknamesCount += 1 } func visibleGeohashPeople() -> [GeoPerson] { visiblePeople } // Location channels var isTeleported = false var regionalGeohashes: Set = [] func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool { !regionalGeohashes.isEmpty && !regionalGeohashes.contains(geohash) } // Routing & acknowledgements private(set) var routedFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = [] private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = [] private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = [] func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { routedFavoriteNotifications.append((peerID, isFavorite)) } func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { geoDeliveryAcks.append((messageID, recipientHex)) } func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { geoReadReceipts.append((messageID, recipientHex)) } } // MARK: - Helpers /// Let the inner `Task { @MainActor in ... }` hops the coordinator schedules /// run to completion. @MainActor private func drainMainQueue() async { for _ in 0..<5 { await Task.yield() } } // MARK: - Coordinator Tests Against Mock Context /// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no /// `ChatViewModel`. Scoped to the inbound event pipeline (dedup, presence, /// public-message ingest), gift-wrap DM ingest, key mapping, channel-switch /// teardown, and embedded ack flows. Flows that hit live singletons /// (`NostrRelayManager.shared` subscriptions, `TorManager`, /// `FavoritesPersistenceService`) remain covered by the full view-model tests. struct ChatNostrCoordinatorContextTests { @Test @MainActor func handleNostrEvent_ingestsPublicMessageOnceAndDeduplicates() async throws { let context = MockChatNostrContext() let coordinator = ChatNostrCoordinator(context: context) let sender = try NostrIdentity.generate() let event = try NostrProtocol.createEphemeralGeohashEvent( content: "hello geohash", geohash: "u4pruyd", senderIdentity: sender, nickname: "alice" ) context.displayNamesByPubkey[event.pubkey] = "alice#1234" coordinator.handleNostrEvent(event) await drainMainQueue() // Dedup recorded exactly once, presence and key mapping updated. #expect(context.recordedNostrEventIDs == [event.id]) #expect(context.geoNicknames[event.pubkey.lowercased()] == "alice") #expect(context.recordedParticipants == [event.pubkey]) #expect(context.nostrKeyMapping[PeerID(nostr: event.pubkey)] == event.pubkey) #expect(context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] == event.pubkey) // The message reached the public ingest path with the resolved name. #expect(context.handledPublicMessages.map(\.id) == [event.id]) #expect(context.handledPublicMessages.first?.sender == "alice#1234") #expect(context.handledPublicMessages.first?.content == "hello geohash") #expect(context.mentionCheckedMessageIDs == [event.id]) #expect(context.hapticMessageIDs == [event.id]) // A replay of the same event is dropped before any processing. coordinator.handleNostrEvent(event) await drainMainQueue() #expect(context.recordedNostrEventIDs == [event.id]) #expect(context.handledPublicMessages.count == 1) #expect(context.recordedParticipants.count == 1) } @Test @MainActor func handleNostrEvent_marksTeleportedPeerWithoutIngestingEmptyContent() async throws { let context = MockChatNostrContext() let coordinator = ChatNostrCoordinator(context: context) let sender = try NostrIdentity.generate() let event = try NostrProtocol.createEphemeralGeohashEvent( content: "", geohash: "u4pruyd", senderIdentity: sender, teleported: true ) coordinator.handleNostrEvent(event) await drainMainQueue() // Teleport detection fires even though the empty message is dropped. #expect(context.teleportedKeys == [event.pubkey.lowercased()]) #expect(context.recordedParticipants == [event.pubkey]) #expect(context.handledPublicMessages.isEmpty) } @Test @MainActor func handleNostrEvent_skipsBlockedSender() async throws { let context = MockChatNostrContext() let coordinator = ChatNostrCoordinator(context: context) let sender = try NostrIdentity.generate() let event = try NostrProtocol.createEphemeralGeohashEvent( content: "spam", geohash: "u4pruyd", senderIdentity: sender ) context.blockedNostrPubkeys.insert(event.pubkey.lowercased()) coordinator.handleNostrEvent(event) await drainMainQueue() // The event is still recorded for dedup but nothing else happens. #expect(context.recordedNostrEventIDs == [event.id]) #expect(context.recordedParticipants.isEmpty) #expect(context.handledPublicMessages.isEmpty) } @Test @MainActor func handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates() async throws { let context = MockChatNostrContext() let coordinator = ChatNostrCoordinator(context: context) let recipient = try NostrIdentity.generate() let sender = try NostrIdentity.generate() let embedded = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient( content: "psst", messageID: "gm-1", senderPeerID: PeerID(str: "aabbccddeeff0011") )) let giftWrap = try NostrProtocol.createPrivateMessage( content: embedded, recipientPubkey: recipient.publicKeyHex, senderIdentity: sender ) coordinator.handleGiftWrap(giftWrap, id: recipient) let convKey = PeerID(nostr_: sender.publicKeyHex) #expect(context.recordedNostrEventIDs == [giftWrap.id]) #expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex) #expect(context.handledPrivateMessages.count == 1) #expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex) #expect(context.handledPrivateMessages.first?.convKey == convKey) // The embedded Noise payload survives the round trip intact. let payload = try #require(context.handledPrivateMessages.first?.payload) #expect(payload.type == .privateMessage) let pm = try #require(PrivateMessagePacket.decode(from: payload.data)) #expect(pm.messageID == "gm-1") #expect(pm.content == "psst") // The same gift wrap is dropped on replay. coordinator.handleGiftWrap(giftWrap, id: recipient) #expect(context.recordedNostrEventIDs == [giftWrap.id]) #expect(context.handledPrivateMessages.count == 1) } @Test @MainActor func switchLocationChannel_toMesh_tearsDownGeohashState() async { let context = MockChatNostrContext() let coordinator = ChatNostrCoordinator(context: context) context.activeChannel = .mesh context.currentGeohash = "u4pruyd" context.geoNicknames = ["abcd": "alice"] coordinator.switchLocationChannel(to: .mesh) #expect(context.pipelineResetCount == 1) #expect(context.activeChannel == .mesh) #expect(context.pipelineChannelUpdates == [.mesh]) #expect(context.clearProcessedNostrEventsCount == 1) #expect(context.refreshedChannels == [.mesh]) #expect(context.refreshTimerStopCount == 1) #expect(context.clearTeleportedGeoCount == 1) // Cleared once in the mesh branch, once in the shared teardown. #expect(context.activeParticipantGeohashes == [nil, nil]) #expect(context.currentGeohash == nil) #expect(context.geoSubscriptionID == nil) #expect(context.geoDmSubscriptionID == nil) #expect(context.clearGeoNicknamesCount == 1) #expect(context.geoNicknames.isEmpty) // Mesh never starts a geohash subscription or refresh timer. #expect(context.refreshTimerStartCount == 0) } @Test @MainActor func sendDeliveryAckViaNostrEmbedded_sendsReadReceiptOnlyWhenViewingUnread() async throws { let context = MockChatNostrContext() let coordinator = ChatNostrCoordinator(context: context) context.nostrIdentity = try NostrIdentity.generate() let senderPubkey = "feedface00112233" let convKey = PeerID(nostr_: senderPubkey) let message = BitchatMessage( id: "mid-1", sender: "alice#1234", content: "hi", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: convKey ) // Not viewing the chat: delivery ack only. coordinator.sendDeliveryAckViaNostrEmbedded(message, wasReadBefore: false, senderPubkey: senderPubkey, key: nil) #expect(context.geoDeliveryAcks.map(\.messageID) == ["mid-1"]) #expect(context.geoDeliveryAcks.first?.recipientHex == senderPubkey) #expect(context.geoReadReceipts.isEmpty) // Viewing the chat: delivery ack plus read receipt. context.selectedPrivateChatPeer = convKey coordinator.sendDeliveryAckViaNostrEmbedded(message, wasReadBefore: false, senderPubkey: senderPubkey, key: Data([0x01])) #expect(context.geoDeliveryAcks.count == 2) #expect(context.geoReadReceipts.map(\.messageID) == ["mid-1"]) // Already read: no further read receipt. coordinator.sendDeliveryAckViaNostrEmbedded(message, wasReadBefore: true, senderPubkey: senderPubkey, key: nil) #expect(context.geoDeliveryAcks.count == 3) #expect(context.geoReadReceipts.count == 1) } @Test @MainActor func geohashDMKeyMappingHelpers_resolveAndStartChats() async { let context = MockChatNostrContext() let coordinator = ChatNostrCoordinator(context: context) let hex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" let convKey = PeerID(nostr_: hex) context.displayNamesByPubkey[hex] = "bob#eeff" coordinator.startGeohashDM(withPubkeyHex: hex) #expect(context.nostrKeyMapping[convKey] == hex) #expect(context.startedPrivateChats == [convKey]) #expect(coordinator.fullNostrHex(forSenderPeerID: convKey) == hex) #expect(coordinator.geohashDisplayName(for: convKey) == "bob#eeff") // Unmapped conversation keys fall back to the bare peer ID. let unknown = PeerID(nostr_: "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100") #expect(coordinator.geohashDisplayName(for: unknown) == unknown.bare) // Display-name lookup prefers visible people, then nicknames. context.visiblePeople = [GeoPerson(id: "aa11", displayName: "carol#aa11", lastSeen: Date())] context.geoNicknames = ["bb22": "dave"] #expect(coordinator.nostrPubkeyForDisplayName("carol#aa11") == "aa11") #expect(coordinator.nostrPubkeyForDisplayName("dave") == "bb22") #expect(coordinator.nostrPubkeyForDisplayName("nobody") == nil) } }