Inject notification/favorites singletons through coordinator contexts

NotificationService.shared and FavoritesPersistenceService.shared
accesses across nine coordinators/components consolidate into
ChatViewModel witnesses behind intent-named context members
(notifyPrivateMessage, notifyMention, favoriteRelationship(forNoiseKey:),
allFavoriteRelationships, postLocalNotification, ...). Singleton reach
from coordinators is now zero; nine new tests cover previously
untestable notification/favorites flows.

Also root-causes the long-flaky gift-wrap dedup test: parallel tests
share LocationChannelManager.shared, so channel-switch tests trigger
clearProcessedNostrEvents() on every live ChatViewModel, wiping the
dedup record mid-test. The invariant (forged-signature copies never
poison dedup) now lives as a deterministic NostrInboundPipeline
mock-context test; two Schnorr concurrency probes added along the way
stay as regression guards for the P256K shared-context assumptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-10 23:04:20 +02:00
co-authored by Claude Fable 5
parent 638f3f5005
commit cc76086615
19 changed files with 820 additions and 116 deletions
@@ -7,12 +7,12 @@
// `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Scope note: the mesh/Nostr read-receipt branch of
// `markPrivateMessagesAsRead` consults `FavoritesPersistenceService.shared`
// and the geohash-screenshot branch publishes via `NostrRelayManager.shared` /
// `GeoRelayDirectory.shared`; those stay covered by the full view-model tests.
// The GeoDM read pass, message merging, screenshot notices, and lifecycle
// persistence flows are covered here.
// Scope note: the geohash-screenshot branch publishes via
// `NostrRelayManager.shared` / `GeoRelayDirectory.shared`; that stays covered
// by the full view-model tests. The GeoDM read pass, the favorites-backed
// mesh/Nostr read-receipt branch (favorites are injected through the
// context), message merging, screenshot notices, and lifecycle persistence
// flows are covered here.
//
import Testing
@@ -101,6 +101,13 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { Self.dummyIdentity }
func recordGeoParticipant(pubkeyHex: String) { recordedGeoParticipants.append(pubkeyHex) }
// Favorites
var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:]
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
favoriteRelationshipsByNoiseKey[noiseKey]
}
// Identity persistence
private(set) var forceSaveIdentityCount = 0
private(set) var verifyIdentityKeyExistsCount = 0
@@ -123,6 +130,24 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
// MARK: - Helpers
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)
)
}
@MainActor
private func makePrivateMessage(
id: String,
@@ -280,4 +305,36 @@ struct ChatLifecycleCoordinatorContextTests {
}
#expect(context.ownerLevelReadPasses == [peerID])
}
@Test @MainActor
func markPrivateMessagesAsRead_routesReceiptsOnlyForNostrReachableFavorites() {
let context = MockChatLifecycleContext()
let coordinator = ChatLifecycleCoordinator(context: context)
let noiseKey = Data(repeating: 0xAB, count: 32)
let peerID = PeerID(hexData: noiseKey)
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
nostrPublicKey: "npub1alice"
)
context.privateChats[peerID] = [
makePrivateMessage(id: "in-1", senderPeerID: peerID),
makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true),
]
coordinator.markPrivateMessagesAsRead(from: peerID)
// Favorite with a Nostr key: READ receipts routed for non-relay
// inbound messages and recorded as sent.
#expect(context.managerReadMarks == [peerID])
#expect(context.routedReadReceipts.map(\.messageID) == ["in-1"])
#expect(context.routedReadReceipts.map(\.peerID) == [peerID])
#expect(context.sentReadReceipts.contains("in-1"))
// No favorite relationship (no Nostr key): the receipt pass is skipped.
let otherKey = Data(repeating: 0xCD, count: 32)
let otherPeer = PeerID(hexData: otherKey)
context.privateChats[otherPeer] = [makePrivateMessage(id: "in-2", senderPeerID: otherPeer)]
coordinator.markPrivateMessagesAsRead(from: otherPeer)
#expect(context.routedReadReceipts.map(\.messageID) == ["in-1"])
}
}
@@ -215,6 +215,32 @@ private final class MockChatNostrContext: ChatNostrContext {
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
@@ -228,14 +254,34 @@ private func drainMainQueue() async {
}
}
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, and embedded ack flows. Flows that hit live singletons
/// (`NostrRelayManager.shared` subscriptions, `TorManager`,
/// `FavoritesPersistenceService`) remain covered by the full view-model tests.
/// 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
@@ -361,6 +407,30 @@ struct ChatNostrCoordinatorContextTests {
#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()
@@ -546,4 +616,86 @@ struct GeoPresenceTrackerTests {
#expect(context.appendedGeohashMessages.count == 1)
#expect(context.synchronizedGeohashes.isEmpty)
}
@Test @MainActor
func handleFavoriteNotification_persistsFavoriteAndPostsLocalNotification() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let sender = try NostrIdentity.generate()
let noiseKey = Data(repeating: 0x42, count: 32)
// The favorites store bridges the sender's npub back to a Noise key.
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
nostrPublicKey: sender.npub
)
coordinator.handleFavoriteNotification(content: "FAVORITE:TRUE|alice", from: sender.publicKeyHex)
#expect(context.addedFavorites.count == 1)
#expect(context.addedFavorites.first?.noiseKey == noiseKey)
#expect(context.addedFavorites.first?.nostrPublicKey == sender.publicKeyHex)
#expect(context.addedFavorites.first?.nickname == "alice")
#expect(context.postedLocalNotifications.count == 1)
#expect(context.postedLocalNotifications.first?.title == "New Favorite")
#expect(context.postedLocalNotifications.first?.body == "alice favorited you")
// Unfavorite: no store write, but the removal notification still posts.
coordinator.handleFavoriteNotification(content: "FAVORITE:FALSE|alice", from: sender.publicKeyHex)
#expect(context.addedFavorites.count == 1)
#expect(context.postedLocalNotifications.last?.title == "Favorite Removed")
#expect(context.postedLocalNotifications.last?.body == "alice unfavorited you")
}
@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, store synced, 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.synchronizedGeohashes == ["u4pruyd"])
#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) + ""
)
}
}
@@ -7,11 +7,10 @@
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Scope note: flows that hit the `FavoritesPersistenceService.shared`
// singleton (`isFavorite` / `toggleFavoriteForNoiseKey` / favorite
// notifications / `nicknameForPeer` fallbacks) remain covered by the full
// view-model tests; the session, migration, encryption-status, and nickname
// resolution flows are covered here.
// Scope note: favorites are injected through the context
// (`favoriteRelationship(forNoiseKey:)` / `addFavorite` / `removeFavorite`),
// so the favorite toggle and lookup flows are covered here alongside the
// session, migration, encryption-status, and nickname resolution flows.
//
import Testing
@@ -171,10 +170,50 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext {
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
nostrFavoriteNotifications.append((noisePublicKey, isFavorite))
}
// Favorites
var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:]
var favoriteRelationshipsByPeerID: [PeerID: FavoritesPersistenceService.FavoriteRelationship] = [:]
private(set) var addedFavorites: [(noiseKey: Data, nostrPublicKey: String?, nickname: String)] = []
private(set) var removedFavorites: [Data] = []
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
favoriteRelationshipsByNoiseKey[noiseKey]
}
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? {
favoriteRelationshipsByPeerID[peerID]
}
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) {
addedFavorites.append((noiseKey, nostrPublicKey, nickname))
}
func removeFavorite(noiseKey: Data) {
removedFavorites.append(noiseKey)
}
}
// MARK: - Helpers
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)
)
}
@MainActor
private func makePrivateMessage(
id: String,
@@ -355,4 +394,40 @@ struct ChatPeerIdentityCoordinatorContextTests {
context.peerIDsByNickname["carol"] = meshPeer
#expect(coordinator.getPeerIDForNickname("carol") == meshPeer)
}
@Test @MainActor
func toggleFavorite_forNoiseKeyPeer_usesInjectedFavoritesStore() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let noiseKey = Data(repeating: 0xAB, count: 32)
let peerID = PeerID(hexData: noiseKey)
// No prior relationship: adds a favorite, no Nostr notification yet.
coordinator.toggleFavorite(peerID: peerID)
#expect(context.addedFavorites.count == 1)
#expect(context.addedFavorites.first?.noiseKey == noiseKey)
#expect(context.addedFavorites.first?.nickname == "Unknown")
#expect(context.nostrFavoriteNotifications.isEmpty)
#expect(coordinator.isFavorite(peerID: peerID) == false)
// They already favorite us: adding sends the mutual notification.
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
theyFavoritedUs: true
)
coordinator.toggleFavorite(peerID: peerID)
#expect(context.addedFavorites.count == 2)
#expect(context.addedFavorites.last?.nickname == "alice")
#expect(context.nostrFavoriteNotifications.map(\.isFavorite) == [true])
// Existing favorite: toggling removes it and notifies the unfavorite.
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
isFavorite: true
)
#expect(coordinator.isFavorite(peerID: peerID) == true)
coordinator.toggleFavorite(peerID: peerID)
#expect(context.removedFavorites == [noiseKey])
#expect(context.nostrFavoriteNotifications.map(\.isFavorite) == [true, false])
}
}
@@ -7,10 +7,9 @@
// `ChatDeliveryCoordinatorContextTests` /
// `ChatTransportEventCoordinatorContextTests` exemplars.
//
// Scope note: the network-availability notification path posts through
// `NotificationService.shared` (a singleton) and arms wall-clock timers. These
// tests keep every peer mesh-inactive (`isPeerConnected`/`isPeerReachable`
// both false) so that path is never reached; the timer-driven reset flows are
// 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.
//
@@ -54,6 +53,13 @@ private final class MockChatPeerListContext: ChatPeerListContext {
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)
}
}
// MARK: - Helpers
@@ -162,4 +168,40 @@ struct ChatPeerListCoordinatorContextTests {
#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_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)
}
}
@@ -132,6 +132,23 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
embeddedDeliveryAckMessageIDs.append(message.id)
}
// Favorites & notifications
var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:]
private(set) var peerFavoritedUsUpdates: [(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)] = []
private(set) var privateMessageNotifications: [(senderName: String, message: String, peerID: PeerID)] = []
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
favoriteRelationshipsByNoiseKey[noiseKey]
}
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
peerFavoritedUsUpdates.append((noiseKey, favorited, nickname, nostrPublicKey))
}
func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) {
privateMessageNotifications.append((senderName, message, peerID))
}
// System messages & chat hygiene
private(set) var systemMessages: [String] = []
private(set) var meshOnlySystemMessages: [String] = []
@@ -191,13 +208,33 @@ private func isRead(_ status: DeliveryStatus?, by expected: String) -> Bool {
return false
}
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 `ChatPrivateConversationCoordinator` against
/// `MockChatPrivateConversationContext` with no `ChatViewModel`. Scoped to the
/// pure-state and ack flows; flows that hit `NotificationService` /
/// `FavoritesPersistenceService` singletons remain covered by the full
/// view-model tests.
/// pure-state and ack flows plus now that notifications and favorites are
/// injected through the context (`notifyPrivateMessage`,
/// `favoriteRelationship(forNoiseKey:)`, `updatePeerFavoritedUs`) the
/// notification and favorite-transition flows that previously required the
/// live singletons.
struct ChatPrivateConversationCoordinatorContextTests {
@Test @MainActor
@@ -402,4 +439,99 @@ struct ChatPrivateConversationCoordinatorContextTests {
#expect(context.sanitizedPeerIDs == [newPeerID])
#expect(context.notifyUIChangedCount == 1)
}
@Test @MainActor
func handlePrivateMessage_postsNotificationOnlyWhenNotViewingChat() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let peerID = PeerID(str: "0102030405060708")
// Not viewing: marked unread and a local notification is posted.
coordinator.handlePrivateMessage(
makeIncomingMessage(id: "pm-1", content: "hi there", senderPeerID: peerID)
)
#expect(context.unreadPrivateMessages == [peerID])
#expect(context.privateMessageNotifications.count == 1)
#expect(context.privateMessageNotifications.first?.senderName == "alice")
#expect(context.privateMessageNotifications.first?.message == "hi there")
#expect(context.privateMessageNotifications.first?.peerID == peerID)
#expect(context.meshReadReceipts.isEmpty)
// Viewing the chat: a READ ack is sent instead and no notification fires.
context.selectedPrivateChatPeer = peerID
coordinator.handlePrivateMessage(makeIncomingMessage(id: "pm-2", senderPeerID: peerID))
#expect(context.meshReadReceipts.map(\.messageID) == ["pm-2"])
#expect(context.sentReadReceipts.contains("pm-2"))
#expect(context.privateMessageNotifications.count == 1)
}
@Test @MainActor
func handleFavoriteNotificationFromMesh_persistsAndAnnouncesTransitionsOnly() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let noiseKey = Data(repeating: 0xAB, count: 32)
let peerID = PeerID(hexData: noiseKey)
// First [FAVORITED] flips theyFavoritedUs: store write + announcement.
coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
#expect(context.peerFavoritedUsUpdates.count == 1)
#expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey)
#expect(context.peerFavoritedUsUpdates.first?.favorited == true)
#expect(context.peerFavoritedUsUpdates.first?.nostrPublicKey == "npub1alice")
#expect(context.meshOnlySystemMessages == ["alice favorited you"])
// Same state again: store write still happens, but no repeat announcement.
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
theyFavoritedUs: true
)
coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
#expect(context.peerFavoritedUsUpdates.count == 2)
#expect(context.meshOnlySystemMessages == ["alice favorited you"])
// [UNFAVORITED] transition announces again.
coordinator.handleFavoriteNotificationFromMesh("[UNFAVORITED]", from: peerID, senderNickname: "alice")
#expect(context.peerFavoritedUsUpdates.last?.favorited == false)
#expect(context.meshOnlySystemMessages == ["alice favorited you", "alice unfavorited you"])
}
@Test @MainActor
func sendPrivateMessage_routesViaMutualFavoriteNostrWhenPeerOffline() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let noiseKey = Data(repeating: 0xCD, count: 32)
let peerID = PeerID(hexData: noiseKey)
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
nostrPublicKey: "npub1bob",
nickname: "bob",
isFavorite: true,
theyFavoritedUs: true
)
coordinator.sendPrivateMessage("hello bob", to: peerID)
// Offline but mutual favorite with a Nostr key: routed, marked sent,
// and the nickname falls back to the favorite relationship.
#expect(context.routedPrivateMessages.map(\.content) == ["hello bob"])
#expect(context.privateChats[peerID]?.first?.deliveryStatus == .sent)
#expect(context.privateChats[peerID]?.first?.recipientNickname == "bob")
#expect(context.systemMessages.isEmpty)
}
@Test @MainActor
func sendPrivateMessage_failsWhenOfflineWithoutMutualFavorite() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let peerID = PeerID(hexData: Data(repeating: 0xCD, count: 32))
coordinator.sendPrivateMessage("hello?", to: peerID)
#expect(context.routedPrivateMessages.isEmpty)
#expect(context.systemMessages.count == 1)
guard case .failed = context.privateChats[peerID]?.first?.deliveryStatus else {
Issue.record("expected .failed delivery status")
return
}
}
}
@@ -7,11 +7,11 @@
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Scope note: flows that hit process-wide singletons are intentionally not
// exercised here `checkForMentions` / haptics (NotificationService.shared,
// UIApplication) and the geohash branch of `sendPublicRaw`
// (NostrRelayManager.shared, GeoRelayDirectory.shared). The mesh branch of
// `sendPublicRaw` and all timeline/store/blocking flows are covered.
// Scope note: haptics (UIApplication) and the geohash branch of
// `sendPublicRaw` (NostrRelayManager.shared, GeoRelayDirectory.shared) are
// intentionally not exercised here. `checkForMentions` posts through the
// injected context (`notifyMention(from:message:)`) and is covered, as are
// the mesh branch of `sendPublicRaw` and all timeline/store/blocking flows.
//
import Testing
@@ -249,6 +249,13 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
prewarmedMessageIDs.append(message.id)
}
// Notifications
private(set) var mentionNotifications: [(sender: String, message: String)] = []
func notifyMention(from sender: String, message: String) {
mentionNotifications.append((sender, message))
}
static let dummyIdentity = NostrIdentity(
privateKey: Data(repeating: 0x11, count: 32),
publicKey: Data(repeating: 0x22, count: 32),
@@ -502,4 +509,51 @@ struct ChatPublicConversationCoordinatorContextTests {
let unknownHex = String(repeating: "12", count: 32)
#expect(coordinator.displayNameForNostrPubkey(unknownHex) == "anon#" + unknownHex.suffix(4))
}
@Test @MainActor
func checkForMentions_postsMentionNotificationOnlyForOthersMentioningMe() async {
let context = MockChatPublicConversationContext()
let coordinator = ChatPublicConversationCoordinator(context: context)
// A mention of my nickname from someone else notifies.
coordinator.checkForMentions(
BitchatMessage(
id: "mention-1",
sender: "alice",
content: "hey @me",
timestamp: Date(),
isRelay: false,
mentions: ["me"]
)
)
#expect(context.mentionNotifications.count == 1)
#expect(context.mentionNotifications.first?.sender == "alice")
#expect(context.mentionNotifications.first?.message == "hey @me")
// Mentioning someone else does not notify.
coordinator.checkForMentions(
BitchatMessage(
id: "mention-2",
sender: "alice",
content: "hey @bob",
timestamp: Date(),
isRelay: false,
mentions: ["bob"]
)
)
#expect(context.mentionNotifications.count == 1)
// My own message mentioning myself does not notify.
coordinator.checkForMentions(
BitchatMessage(
id: "mention-3",
sender: "me",
content: "talking about @me",
timestamp: Date(),
isRelay: false,
mentions: ["me"]
)
)
#expect(context.mentionNotifications.count == 1)
}
}
@@ -7,11 +7,12 @@
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Scope note: `handleVerifyResponsePayload` requires a real Ed25519 signature
// and posts via `NotificationService.shared`; it remains covered by the full
// view-model/integration tests. Challenge handling, QR kickoff, fingerprint
// verification, and verified-set loading are covered here
// (`VerificationService.shared` is only used for pure payload build/parse).
// Scope note: `handleVerifyResponsePayload` requires a real Ed25519
// signature; it remains covered by the full view-model/integration tests.
// Challenge handling, QR kickoff, fingerprint verification, verified-set
// loading, and the mutual-verification notification (posted through the
// injected context) are covered here (`VerificationService.shared` is only
// used for pure payload build/parse).
//
import Testing
@@ -117,6 +118,13 @@ private final class MockChatVerificationContext: ChatVerificationContext {
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
sentResponses.append((peerID, noiseKeyHex, nonceA))
}
// Notifications
private(set) var postedLocalNotifications: [(title: String, body: String, identifier: String)] = []
func postLocalNotification(title: String, body: String, identifier: String) {
postedLocalNotifications.append((title, body, identifier))
}
}
// MARK: - Helpers
@@ -272,6 +280,35 @@ struct ChatVerificationCoordinatorContextTests {
await waitForMainQueue()
#expect(context.encryptionStatuses[peerID] == .noiseHandshaking)
}
@Test @MainActor
func handleVerifyChallengePayload_postsMutualVerificationToastOncePerMinute() async {
let context = MockChatVerificationContext()
let coordinator = ChatVerificationCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
let myHex = context.myNoiseStaticKey.hexEncodedString()
context.fingerprintsByPeerID[peerID] = "fp-mutual"
context.verifiedFingerprints = ["fp-mutual"]
coordinator.handleVerifyChallengePayload(
from: peerID,
payload: makeVerifyChallengeTLV(noiseKeyHex: myHex, nonceA: Data(repeating: 0x07, count: 16))
)
// Already-verified peer challenging us: mutual-verification toast.
#expect(context.postedLocalNotifications.count == 1)
#expect(context.postedLocalNotifications.first?.title == "Mutual verification")
#expect(context.postedLocalNotifications.first?.body.hasSuffix("verified each other") == true)
#expect(context.postedLocalNotifications.first?.identifier.hasPrefix("verify-mutual-") == true)
// A fresh nonce inside the per-fingerprint toast cooldown stays silent.
coordinator.handleVerifyChallengePayload(
from: peerID,
payload: makeVerifyChallengeTLV(noiseKeyHex: myHex, nonceA: Data(repeating: 0x08, count: 16))
)
#expect(context.postedLocalNotifications.count == 1)
#expect(context.sentResponses.count == 2)
}
}
/// The installed callbacks hop through `DispatchQueue.main.async`; tests must
@@ -398,40 +398,6 @@ struct ChatViewModelNostrExtensionTests {
#expect(viewModel.privateChats.isEmpty)
}
@Test @MainActor
func handleNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try #require(try viewModel.idBridge.getCurrentNostrIdentity())
let giftWrap = try NostrProtocol.createPrivateMessage(
content: "verify:noop",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
var invalidGiftWrap = giftWrap
invalidGiftWrap.sig = String(repeating: "0", count: 128)
// Verification and recording now happen on a detached task; give the
// invalid copy time to be verified-and-rejected, then assert it never
// entered the dedup set.
viewModel.handleNostrMessage(invalidGiftWrap)
try await Task.sleep(nanoseconds: 150_000_000)
#expect(!viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id))
// Generous deadline: the detached verification task can be starved
// under parallel test load.
viewModel.handleNostrMessage(giftWrap)
var recorded = false
for _ in 0..<600 {
if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
recorded = true
break
}
try await Task.sleep(nanoseconds: 10_000_000)
}
#expect(recorded)
}
@Test @MainActor
func switchLocationChannel_clearsNostrDedupCache() async {
let (viewModel, _) = makeTestableViewModel()
@@ -459,6 +459,12 @@ private final class PerfNostrContext: ChatNostrContext {
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {}
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {}
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {}
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { nil }
func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] { [] }
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) {}
func postLocalNotification(title: String, body: String, identifier: String) {}
func notifyGeohashActivity(geohash: String, bodyPreview: String) {}
}
// MARK: - Mock ChatDeliveryContext
@@ -0,0 +1,82 @@
import Testing
import Foundation
@testable import bitchat
struct SchnorrConcurrencyRepro {
@Test func concurrentVerificationOfValidEventAlwaysSucceeds() async throws {
let identity = try NostrIdentity.generate()
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [["g", "sn"]],
content: "concurrency probe"
)
let signed = try event.sign(with: identity.schnorrSigningKey())
#expect(signed.isValidSignature())
let failures = await withTaskGroup(of: Int.self) { group in
for _ in 0..<8 {
group.addTask {
var localFailures = 0
for _ in 0..<250 {
if !signed.isValidSignature() { localFailures += 1 }
}
return localFailures
}
}
var total = 0
for await f in group { total += f }
return total
}
#expect(failures == 0, "Schnorr verification returned false \(failures)/2000 times under concurrency")
}
@Test func verificationSurvivesConcurrentSigningAndKeyGeneration() async throws {
let identity = try NostrIdentity.generate()
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [["g", "sn"]],
content: "concurrency probe"
)
let signed = try event.sign(with: identity.schnorrSigningKey())
#expect(signed.isValidSignature())
// Signing and key generation may mutate shared library state
// (secp256k1 context randomization); verification must stay correct
// while they run on other tasks.
let failures = await withTaskGroup(of: Int.self) { group in
for worker in 0..<8 {
group.addTask {
if worker < 4 {
var localFailures = 0
for _ in 0..<250 {
if !signed.isValidSignature() { localFailures += 1 }
}
return localFailures
} else {
for i in 0..<100 {
if let id = try? NostrIdentity.generate() {
let e = NostrEvent(
pubkey: id.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [],
content: "signer \(worker)-\(i)"
)
_ = try? e.sign(with: id.schnorrSigningKey())
}
}
return 0
}
}
}
var total = 0
for await f in group { total += f }
return total
}
#expect(failures == 0, "Schnorr verification returned false \(failures)/1000 times while signing ran concurrently")
}
}