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,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)
}
}