// // ChatNostrCoordinatorContextTests.swift // bitchatTests // // Exercises the `ChatNostrCoordinator` facade and its components // (`NostrInboundPipeline`, `GeohashSubscriptionManager`, `GeoPresenceTracker`) // against a mock `ChatNostrContext` — proving the stack 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` (and, via protocol /// inheritance, the component contexts) proving that the Nostr stack 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 } func setGeoChatSubscriptionID(_ id: String?) { geoSubscriptionID = id } func setGeoDmSubscriptionID(_ id: String?) { geoDmSubscriptionID = id } func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) { geoSamplingSubs[subID] = geohash } func removeGeoSamplingSub(_ subID: String) { geoSamplingSubs.removeValue(forKey: subID) } func clearGeoSamplingSubs() -> [String] { defer { geoSamplingSubs.removeAll() } return Array(geoSamplingSubs.keys) } // Public timeline & pipeline var messages: [BitchatMessage] = [] private(set) var pipelineFlushCount = 0 private(set) var refreshedChannels: [ChannelID?] = [] private(set) var publicSystemMessages: [String] = [] var pendingGeohashSystemMessages: [String] = [] private(set) var appendedGeohashMessages: [(message: BitchatMessage, geohash: String)] = [] func flushPublicMessagePipeline() { pipelineFlushCount += 1 } 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 } // 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] = [:] func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey } 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)) } // Favorites & notifications var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:] private(set) var addedFavorites: [(noiseKey: Data, nostrPublicKey: String?, nickname: String)] = [] private(set) var postedLocalNotifications: [(title: String, body: String, identifier: String)] = [] private(set) var geohashActivityNotifications: [(geohash: String, bodyPreview: String)] = [] func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { favoriteRelationshipsByNoiseKey[noiseKey] } func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] { Array(favoriteRelationshipsByNoiseKey.values) } func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) { addedFavorites.append((noiseKey, nostrPublicKey, nickname)) } func postLocalNotification(title: String, body: String, identifier: String) { postedLocalNotifications.append((title, body, identifier)) } func notifyGeohashActivity(geohash: String, bodyPreview: String) { geohashActivityNotifications.append((geohash, bodyPreview)) } } // 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() } } private func makeFavoriteRelationship( noiseKey: Data, nostrPublicKey: String? = nil, nickname: String = "alice", isFavorite: Bool = false, theyFavoritedUs: Bool = false ) -> FavoritesPersistenceService.FavoriteRelationship { FavoritesPersistenceService.FavoriteRelationship( peerNoisePublicKey: noiseKey, peerNostrPublicKey: nostrPublicKey, peerNickname: nickname, isFavorite: isFavorite, theyFavoritedUs: theyFavoritedUs, favoritedAt: Date(timeIntervalSince1970: 0), lastUpdated: Date(timeIntervalSince1970: 0) ) } // 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, embedded ack flows, and — now that favorites and notifications /// are injected through the context — the favorite-notification ingest and /// the sampled-geohash notification cooldown. Flows that hit live singletons /// (`NostrRelayManager.shared` subscriptions, `TorManager`) 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.inbound.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.inbound.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.inbound.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.inbound.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.inbound.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.inbound.handleGiftWrap(giftWrap, id: recipient) #expect(context.recordedNostrEventIDs == [giftWrap.id]) #expect(context.handledPrivateMessages.count == 1) } @Test @MainActor func processNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws { let context = MockChatNostrContext() let coordinator = ChatNostrCoordinator(context: context) let recipient = try NostrIdentity.generate() let sender = try NostrIdentity.generate() let giftWrap = try NostrProtocol.createPrivateMessage( content: "verify:noop", recipientPubkey: recipient.publicKeyHex, senderIdentity: sender ) var invalidGiftWrap = giftWrap invalidGiftWrap.sig = String(repeating: "0", count: 128) // A forged-signature copy is rejected WITHOUT entering the dedup set... await coordinator.inbound.processNostrMessage(invalidGiftWrap) #expect(context.recordedNostrEventIDs.isEmpty) // ...so the genuine event with the same ID still processes and records. await coordinator.inbound.processNostrMessage(giftWrap) #expect(context.recordedNostrEventIDs == [giftWrap.id]) } @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.subscriptions.switchLocationChannel(to: .mesh) #expect(context.pipelineFlushCount == 1) #expect(context.activeChannel == .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) } } // MARK: - GeoPresenceTracker Tests /// Focused tests for seams the coordinator split made independently /// testable: the sampling-event LRU dedup and the per-geohash notification /// cooldown. The cooldown tests stop short of the live notification center by /// pre-seeding the timeline append as a duplicate. struct GeoPresenceTrackerTests { @Test @MainActor func samplingEventDedup_evictsOldestBeyondLRUCap() { let context = MockChatNostrContext() let tracker = GeoPresenceTracker(context: context) let cap = TransportConfig.geoSamplingEventLRUCap // Empty IDs are never deduplicated. #expect(tracker.shouldProcessGeoSamplingEvent("")) #expect(tracker.shouldProcessGeoSamplingEvent("")) // First sight passes; a replay is rejected. #expect(tracker.shouldProcessGeoSamplingEvent("ev-0")) #expect(!tracker.shouldProcessGeoSamplingEvent("ev-0")) // Fill one past the cap: the oldest entry is evicted and accepted // again, while a still-resident entry stays deduplicated. for i in 1...cap { #expect(tracker.shouldProcessGeoSamplingEvent("ev-\(i)")) } #expect(tracker.shouldProcessGeoSamplingEvent("ev-0")) #expect(!tracker.shouldProcessGeoSamplingEvent("ev-\(cap)")) // Clearing resets the dedup entirely. tracker.clearGeoSamplingEventDedup() #expect(tracker.shouldProcessGeoSamplingEvent("ev-\(cap)")) } @Test @MainActor func notificationCooldown_skipsWithinWindow() async throws { let context = MockChatNostrContext() let tracker = GeoPresenceTracker(context: context) let sender = try NostrIdentity.generate() let event = try NostrProtocol.createEphemeralGeohashEvent( content: "sampled activity", geohash: "9q8yy", senderIdentity: sender, nickname: "alice" ) // Within the cooldown window nothing is appended or re-stamped. let recent = Date() context.lastGeoNotificationAt["9q8yy"] = recent tracker.cooldownPerGeohash("9q8yy", content: "sampled activity", event: event) await drainMainQueue() #expect(context.appendedGeohashMessages.isEmpty) #expect(context.lastGeoNotificationAt["9q8yy"] == recent) } @Test @MainActor func notificationCooldown_stampsGeohashOnceWindowElapses() async throws { let context = MockChatNostrContext() let tracker = GeoPresenceTracker(context: context) let sender = try NostrIdentity.generate() let event = try NostrProtocol.createEphemeralGeohashEvent( content: "sampled activity", geohash: "9q8yy", senderIdentity: sender, nickname: "alice" ) // Pre-seed the same event ID so the timeline append reports a // duplicate and the flow never reaches the live notification center. let placeholder = BitchatMessage( id: event.id, sender: "seed", content: "seed", timestamp: Date(), isRelay: false ) #expect(context.appendGeohashMessageIfAbsent(placeholder, toGeohash: "9q8yy")) // Cooldown elapsed: the geohash is re-stamped and the append is // attempted (and rejected as a duplicate, so no notification either). let stale = Date().addingTimeInterval(-TransportConfig.uiGeoNotifyCooldownSeconds - 1) context.lastGeoNotificationAt["9q8yy"] = stale tracker.cooldownPerGeohash("9q8yy", content: "sampled activity", event: event) await drainMainQueue() let stamped = try #require(context.lastGeoNotificationAt["9q8yy"]) #expect(stamped > stale) #expect(context.appendedGeohashMessages.count == 1) } @Test @MainActor func geoPresence_sampledActivityNotificationRespectsPerGeohashCooldown() async throws { let context = MockChatNostrContext() let coordinator = ChatNostrCoordinator(context: context) let sender = try NostrIdentity.generate() context.geoNicknames[sender.publicKeyHex.lowercased()] = "alice" let first = try NostrProtocol.createEphemeralGeohashEvent( content: "hello geohash", geohash: "u4pruyd", senderIdentity: sender, nickname: "alice" ) coordinator.presence.cooldownPerGeohash("u4pruyd", content: "hello geohash", event: first) await drainMainQueue() // Sampled message recorded in the store and notification posted. #expect(context.appendedGeohashMessages.map(\.message.id) == [first.id]) #expect(context.appendedGeohashMessages.first?.message.sender == "alice#" + String(first.pubkey.suffix(4))) #expect(context.geohashActivityNotifications.count == 1) #expect(context.geohashActivityNotifications.first?.geohash == "u4pruyd") #expect(context.geohashActivityNotifications.first?.bodyPreview == "hello geohash") #expect(context.lastGeoNotificationAt["u4pruyd"] != nil) // A second sampled event inside the cooldown window is fully suppressed. let second = try NostrProtocol.createEphemeralGeohashEvent( content: "again", geohash: "u4pruyd", senderIdentity: sender, nickname: "alice" ) coordinator.presence.cooldownPerGeohash("u4pruyd", content: "again", event: second) await drainMainQueue() #expect(context.geohashActivityNotifications.count == 1) #expect(context.appendedGeohashMessages.count == 1) // Long previews are truncated to the snippet cap with an ellipsis. let longContent = String(repeating: "x", count: TransportConfig.uiGeoNotifySnippetMaxLen + 20) let third = try NostrProtocol.createEphemeralGeohashEvent( content: longContent, geohash: "9q8yyk", senderIdentity: sender, nickname: "alice" ) coordinator.presence.cooldownPerGeohash("9q8yyk", content: longContent, event: third) await drainMainQueue() #expect( context.geohashActivityNotifications.last?.bodyPreview == String(repeating: "x", count: TransportConfig.uiGeoNotifySnippetMaxLen) + "…" ) } }