mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05:19 +00:00
* Friend-courier store-and-forward: mutual favorites carry sealed messages to offline peers When a private message has no reachable transport, the router now seals it to the recipient's Noise static key (new one-way Noise X pattern) and hands the envelope to up to three connected mutual favorites. Couriers store the opaque ciphertext under strict quotas (20 total, 5 per depositor, 16 KiB, 24 h) and hand it over when the recipient's announce matches a rotating HMAC recipient tag; the recipient opens it and the message flows through the normal private-message pipeline, so dedup and delivery acks just work. - CourierEnvelope TLV + courierEnvelope (0x04) message type in BitFoundation - Noise X one-way pattern reusing the existing handshake machinery, domain-separated by a courier prologue; sender identity authenticated via the ss DH (no forward secrecy - documented tradeoff) - CourierStore with eviction, file persistence, and panic-wipe integration - Rotating recipient tags (HMAC over epoch day) so carried envelopes don't correlate for observers who don't already know the recipient's key - New "carried" delivery status with figure.walk glyph; header indicator while carrying mail for others - Three-node end-to-end test ferrying packets through real BLEService instances, plus codec/crypto/store/router suites (986 tests green) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix courier handoff verification and directed sends * Authenticate courier deposits by ingress peer * Gate courier handover on direct announces and isolate store test Envelopes are removed from the courier store optimistically, so releasing them on a relayed (multi-hop) announce risks losing carried mail to a speculative flood that never reaches the recipient. Handover now also requires the announce to have arrived directly (full TTL), i.e. an actual encounter with a live link; regression test builds a relayed copy of a genuinely signed announce (TTL is excluded from announce signatures). Also make CourierStore's on-disk location injectable so the persistence test round-trips through a temp directory instead of wiping the real Application Support store, and reattach BLEAnnounceHandler's doc comment to the class it describes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Use Xcode-bundled Swift in CI instead of a standalone toolchain The unpinned setup-swift action installs Swift 6.1, which refuses the SDK on runner images that have rolled to Xcode 26.5 ("this SDK is not supported by the compiler"). Jobs passed or failed depending on which image they landed on. The Xcode-bundled toolchain always matches the image's SDK, and matches local development. Cache keys now include the toolchain version so artifacts from one compiler are never restored into builds with another. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Drop couriered mail from blocked senders at envelope open The UI-layer block check (isPeerBlocked in the transport event coordinator) resolves a fingerprint from the live session or peer list, but a couriered message arrives precisely when its sender is absent — no session, no registry entry — so the check failed open and a blocked identity's mail was delivered anyway. Gate in openCourierEnvelope, where the sealed sender's full static key is in hand. End-to-end test ferries a full deposit→carry→handover round and verifies the envelope from a blocked sender never reaches the delegate (confirmed failing without the gate). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix favorites end-to-end: peer-list dedup, Nostr sync, /fav key corruption - UnifiedPeerService: dedup offline favorites against mesh peers by noise key. Phase 2 compared a 64-hex noise-key PeerID against 16-hex mesh IDs (never equal), leaving only a nickname+isConnected heuristic — a mutual favorite that was reachable-but-not-connected or renamed rendered twice, and a same-nick stranger could suppress a favorite entirely. - Nostr inbound: intercept [FAVORITED]/[UNFAVORITED] markers in the live PM handler so they update theyFavoritedUs instead of rendering as chat text; mutual favorites can now form over Nostr. Delete the dead favorite-aware PM variant and ChatNostrCoordinator.handleFavoriteNotification (unwired, parsed a stale FAVORITE:TRUE|… format no sender emits). - NostrTransport.isPeerReachable: match short form regardless of incoming ID width — toggling an offline favorite (addressed by 64-hex noise key) was silently dropped with no reachable transport. - BLEService.sendPrivateMessage: normalize recipient to the short ID like sendFilePrivate, so a 64-hex target hits the existing Noise session instead of initiating a handshake with a 32-byte wire recipient ID. - /fav, /unfav: stop writing Data(hexString: peerID.id) — the 8-byte routing ID for mesh peers — into the favorites store as a "noise key", and stop double-sending the favorite notification; delegate to toggleFavorite with a proper state check. - FavoritesPersistenceService.updatePeerFavoritedUs: keep the stored nickname when the caller passes the "Unknown" placeholder. - Bump marketing version to 1.5.4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Route DMs to mutual favorites via Nostr when a mesh-keyed peer goes offline Field-tested on device: with a DM window opened while the peer was on mesh (conversation keyed by the short 16-hex ID), walking out of range and sending failed instantly with "peer not reachable" even though the header showed the peer as Nostr-reachable (mutual favorite, npub known). sendPrivateMessage derived the favorites key as Data(hexString: peerID.id) — for a short mesh ID that is the 8-byte routing ID, never the noise key — so the mutual-favorite/Nostr-key checks always came up empty and the send failed before reaching MessageRouter. Conversations keyed by the full 64-hex noise-key ID (opened from the offline favorite row) were unaffected, which is why later tests appeared to work. Resolve the noise key properly (peerID.noiseKey, then the unified peer row, then the favorites store by derived short ID) and add a regression test for the mesh-keyed-peer-goes-offline case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Label Nostr DMs from favorites with their stored nickname Field-tested: a DM delivered over the Nostr fallback rendered as "anon#678e" instead of the sender's name. The inbound handler named the sender via displayNameForNostrPubkey, which only knows geohash-scoped names — even though the pipeline had already resolved the sender's noise key (the conversation is keyed by it). When the conversation key carries a noise key, prefer the favorite's stored nickname; geohash DMs (nostr_ keys) keep the anon geo name. This also stops an inbound Nostr [FAVORITED] from overwriting the stored nickname with the anon fallback, since the same name feeds updatePeerFavoritedUs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix courier path for offline favorites addressed by noise-key IDs Two Codex review findings, both the same ID-width confusion this PR targets, in the courier flow: - CourierDirectory.favoritesBacked resolved recipients only via getFavoriteStatus(forPeerID:), which requires a short 16-hex ID — offline favorites are addressed by the full 64-hex noise-key ID, so attemptCourierDeposit silently bailed for exactly the peers couriers exist to serve. The 64-hex ID now yields its own key directly. - openCourierEnvelope emitted the derived short mesh ID even when the sender has no live mesh identity, landing couriered mail in an unresolvable short-ID thread labeled "Unknown". Absent senders now emit the full noise-key ID so the message joins the stable favorite conversation; present senders keep the live short-ID thread. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
665 lines
28 KiB
Swift
665 lines
28 KiB
Swift
//
|
|
// 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<String> = []
|
|
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<String> = []
|
|
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<String> = []
|
|
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<String> = []
|
|
|
|
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) + "…"
|
|
)
|
|
}
|
|
|
|
}
|