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