mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
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:
@@ -53,6 +53,10 @@ protocol ChatLifecycleContext: AnyObject {
|
||||
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
|
||||
func recordGeoParticipant(pubkeyHex: String)
|
||||
|
||||
// MARK: Favorites (shared with `ChatPrivateConversationContext`)
|
||||
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
||||
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
|
||||
// MARK: Identity persistence
|
||||
/// Forces the identity manager to persist its state now.
|
||||
func forceSaveIdentity()
|
||||
@@ -69,7 +73,8 @@ extension ChatViewModel: ChatLifecycleContext {
|
||||
// `synchronizePrivateConversationStore()`, `addSystemMessage(_:)`,
|
||||
// `peerNickname(for:)`, `unifiedPeer(for:)`, `noiseSessionState(for:)`,
|
||||
// the routing/ack members, `isTeleported`,
|
||||
// `deriveNostrIdentity(forGeohash:)`, and `recordGeoParticipant(pubkeyHex:)`
|
||||
// `deriveNostrIdentity(forGeohash:)`, `recordGeoParticipant(pubkeyHex:)`,
|
||||
// and `favoriteRelationship(forNoiseKey:)`
|
||||
// are shared requirements with the other contexts or satisfied by
|
||||
// existing `ChatViewModel` members. The members below flatten nested
|
||||
// service accesses into intent-named calls.
|
||||
@@ -192,12 +197,12 @@ final class ChatLifecycleCoordinator {
|
||||
var peerNostrPubkey: String?
|
||||
|
||||
if let noiseKey = Data(hexString: peerID.id),
|
||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
||||
let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey) {
|
||||
noiseKeyHex = peerID
|
||||
peerNostrPubkey = favoriteStatus.peerNostrPublicKey
|
||||
} else if let peer = context.unifiedPeer(for: peerID) {
|
||||
noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey)
|
||||
let favoriteStatus = context.favoriteRelationship(forNoiseKey: peer.noisePublicKey)
|
||||
peerNostrPubkey = favoriteStatus?.peerNostrPublicKey
|
||||
|
||||
if let noiseKeyHex, context.unreadPrivateMessages.contains(noiseKeyHex) {
|
||||
|
||||
@@ -20,12 +20,23 @@ protocol ChatNostrContext: GeohashSubscriptionContext, NostrInboundPipelineConte
|
||||
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)
|
||||
|
||||
// MARK: Favorites & notifications (shared with the other contexts)
|
||||
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
||||
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
/// Adds (or updates) a favorite in the favorites store.
|
||||
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String)
|
||||
/// Posts a generic local user notification.
|
||||
func postLocalNotification(title: String, body: String, identifier: String)
|
||||
}
|
||||
|
||||
extension ChatViewModel: ChatNostrContext {
|
||||
// All requirements — including the component-context witnesses declared
|
||||
// in `GeohashSubscriptionManager.swift`, `NostrInboundPipeline.swift`,
|
||||
// and `GeoPresenceTracker.swift` — already exist on `ChatViewModel`.
|
||||
// `GeoPresenceTracker.swift`, and the favorites/notification witnesses in
|
||||
// `ChatPrivateConversationCoordinator.swift`,
|
||||
// `ChatPeerIdentityCoordinator.swift`, and
|
||||
// `ChatVerificationCoordinator.swift` — already exist on `ChatViewModel`.
|
||||
}
|
||||
|
||||
/// Thin facade over the Nostr stack: owns and wires the three components and
|
||||
@@ -89,16 +100,17 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
|
||||
guard let context else { return }
|
||||
guard let senderNoiseKey = inbound.findNoiseKey(for: nostrPubkey) else { return }
|
||||
|
||||
let isFavorite = content.contains("FAVORITE:TRUE")
|
||||
let senderNickname = content.components(separatedBy: "|").last ?? "Unknown"
|
||||
|
||||
if isFavorite {
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: senderNoiseKey,
|
||||
peerNostrPublicKey: nostrPubkey,
|
||||
peerNickname: senderNickname
|
||||
context.addFavorite(
|
||||
noiseKey: senderNoiseKey,
|
||||
nostrPublicKey: nostrPubkey,
|
||||
nickname: senderNickname
|
||||
)
|
||||
}
|
||||
|
||||
@@ -123,14 +135,14 @@ final class ChatNostrCoordinator {
|
||||
"💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...",
|
||||
category: .session
|
||||
)
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: senderNoiseKey,
|
||||
peerNostrPublicKey: extractedNostrPubkey,
|
||||
peerNickname: senderNickname
|
||||
context.addFavorite(
|
||||
noiseKey: senderNoiseKey,
|
||||
nostrPublicKey: extractedNostrPubkey,
|
||||
nickname: senderNickname
|
||||
)
|
||||
}
|
||||
|
||||
NotificationService.shared.sendLocalNotification(
|
||||
context.postLocalNotification(
|
||||
title: isFavorite ? "New Favorite" : "Favorite Removed",
|
||||
body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you",
|
||||
identifier: "fav-\(UUID().uuidString)"
|
||||
@@ -140,7 +152,7 @@ final class ChatNostrCoordinator {
|
||||
@MainActor
|
||||
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
||||
guard let context else { return }
|
||||
guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey),
|
||||
guard let relationship = context.favoriteRelationship(forNoiseKey: noisePublicKey),
|
||||
relationship.peerNostrPublicKey != nil else {
|
||||
SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session)
|
||||
return
|
||||
|
||||
@@ -76,6 +76,16 @@ protocol ChatPeerIdentityContext: AnyObject {
|
||||
func invalidateStoredEncryptionCache(for peerID: PeerID?)
|
||||
func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity?
|
||||
|
||||
// MARK: Favorites
|
||||
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
||||
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
/// The persisted favorite relationship for a short (ephemeral) peer ID, if any.
|
||||
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
/// Adds (or updates) a favorite in the favorites store.
|
||||
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String)
|
||||
/// Removes a favorite from the favorites store.
|
||||
func removeFavorite(noiseKey: Data)
|
||||
|
||||
// MARK: Geohash & Nostr
|
||||
var geoNicknames: [String: String] { get }
|
||||
func visibleGeohashPeople() -> [GeoPerson]
|
||||
@@ -183,6 +193,26 @@ extension ChatViewModel: ChatPeerIdentityContext {
|
||||
func bridgedNostrPublicKey(for noiseKey: Data) -> String? {
|
||||
idBridge.getNostrPublicKey(for: noiseKey)
|
||||
}
|
||||
|
||||
// `favoriteRelationship(forNoiseKey:)` is shared with
|
||||
// `ChatPrivateConversationContext`; its witness lives in
|
||||
// `ChatPrivateConversationCoordinator.swift`.
|
||||
|
||||
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? {
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)
|
||||
}
|
||||
|
||||
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) {
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: nostrPublicKey,
|
||||
peerNickname: nickname
|
||||
)
|
||||
}
|
||||
|
||||
func removeFavorite(noiseKey: Data) {
|
||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey)
|
||||
}
|
||||
}
|
||||
|
||||
final class ChatPeerIdentityCoordinator {
|
||||
@@ -255,7 +285,7 @@ final class ChatPeerIdentityCoordinator {
|
||||
@MainActor
|
||||
func isFavorite(peerID: PeerID) -> Bool {
|
||||
if let noisePublicKey = peerID.noiseKey {
|
||||
return FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)?.isFavorite ?? false
|
||||
return context.favoriteRelationship(forNoiseKey: noisePublicKey)?.isFavorite ?? false
|
||||
}
|
||||
|
||||
return context.unifiedPeer(for: peerID)?.isFavorite ?? false
|
||||
@@ -499,12 +529,12 @@ final class ChatPeerIdentityCoordinator {
|
||||
if let name = context.peerNickname(for: peerID) {
|
||||
return name
|
||||
}
|
||||
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||
if let favorite = context.favoriteRelationship(forPeerID: peerID),
|
||||
!favorite.peerNickname.isEmpty {
|
||||
return favorite.peerNickname
|
||||
}
|
||||
if let noiseKey = Data(hexString: peerID.id),
|
||||
let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
let favorite = context.favoriteRelationship(forNoiseKey: noiseKey),
|
||||
!favorite.peerNickname.isEmpty {
|
||||
return favorite.peerNickname
|
||||
}
|
||||
@@ -579,7 +609,7 @@ private extension ChatPeerIdentityCoordinator {
|
||||
if let nickname = context.peerNickname(for: peerID) {
|
||||
return nickname
|
||||
}
|
||||
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: peerPublicKey) {
|
||||
if let favorite = context.favoriteRelationship(forNoiseKey: peerPublicKey) {
|
||||
return favorite.peerNickname
|
||||
}
|
||||
return "Unknown"
|
||||
@@ -602,7 +632,7 @@ private extension ChatPeerIdentityCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
let currentStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)
|
||||
let currentStatus = context.favoriteRelationship(forNoiseKey: noisePublicKey)
|
||||
let fallbackNickname = context.privateChats[peerID]?.first { $0.senderPeerID == peerID }?.sender
|
||||
let plan = ChatFavoriteTogglePolicy.plan(
|
||||
currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init),
|
||||
@@ -612,14 +642,14 @@ private extension ChatPeerIdentityCoordinator {
|
||||
|
||||
switch plan.persistenceAction {
|
||||
case .add(let nickname, let nostrKey):
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: noisePublicKey,
|
||||
peerNostrPublicKey: nostrKey,
|
||||
peerNickname: nickname
|
||||
context.addFavorite(
|
||||
noiseKey: noisePublicKey,
|
||||
nostrPublicKey: nostrKey,
|
||||
nickname: nickname
|
||||
)
|
||||
|
||||
case .remove:
|
||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
||||
context.removeFavorite(noiseKey: noisePublicKey)
|
||||
}
|
||||
|
||||
context.notifyUIChanged()
|
||||
|
||||
@@ -28,6 +28,10 @@ protocol ChatPeerListContext: AnyObject {
|
||||
func activeMeshPeerCount() -> Int
|
||||
func registerEphemeralSession(peerID: PeerID)
|
||||
func updateEncryptionStatusForPeers()
|
||||
|
||||
// MARK: Notifications
|
||||
/// Posts the "bitchatters nearby" local notification.
|
||||
func notifyNetworkAvailable(peerCount: Int)
|
||||
}
|
||||
|
||||
extension ChatViewModel: ChatPeerListContext {
|
||||
@@ -48,6 +52,10 @@ extension ChatViewModel: ChatPeerListContext {
|
||||
}
|
||||
.count
|
||||
}
|
||||
|
||||
func notifyNetworkAvailable(peerCount: Int) {
|
||||
NotificationService.shared.sendNetworkAvailableNotification(peerCount: peerCount)
|
||||
}
|
||||
}
|
||||
|
||||
final class ChatPeerListCoordinator: @unchecked Sendable {
|
||||
@@ -115,7 +123,7 @@ private extension ChatPeerListCoordinator {
|
||||
if Date().timeIntervalSince(lastNetworkNotificationTime) >= cooldown {
|
||||
recentlySeenPeers.formUnion(newPeers)
|
||||
lastNetworkNotificationTime = Date()
|
||||
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
|
||||
context.notifyNetworkAvailable(peerCount: meshPeers.count)
|
||||
SecureLogger.info(
|
||||
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
|
||||
category: .session
|
||||
|
||||
@@ -69,6 +69,14 @@ protocol ChatPrivateConversationContext: AnyObject {
|
||||
func addSystemMessage(_ content: String)
|
||||
func addMeshOnlySystemMessage(_ content: String)
|
||||
func sanitizeChat(for peerID: PeerID)
|
||||
|
||||
// MARK: Favorites & notifications
|
||||
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
||||
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
/// Persists that the peer favorited/unfavorited us (favorites store write).
|
||||
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)
|
||||
/// Posts the incoming-private-message local notification.
|
||||
func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID)
|
||||
}
|
||||
|
||||
extension ChatViewModel: ChatPrivateConversationContext {
|
||||
@@ -161,6 +169,23 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
||||
privateChatManager.sanitizeChat(for: peerID)
|
||||
}
|
||||
|
||||
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
|
||||
}
|
||||
|
||||
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
|
||||
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
favorited: favorited,
|
||||
peerNickname: nickname,
|
||||
peerNostrPublicKey: nostrPublicKey
|
||||
)
|
||||
}
|
||||
|
||||
func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) {
|
||||
NotificationService.shared.sendPrivateMessageNotification(from: senderName, message: message, peerID: peerID)
|
||||
}
|
||||
|
||||
private func makeGeohashNostrTransport() -> NostrTransport {
|
||||
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
transport.senderPeerID = meshService.myPeerID
|
||||
@@ -199,7 +224,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
guard let noiseKey = Data(hexString: peerID.id) else { return }
|
||||
let isConnected = context.isPeerConnected(peerID)
|
||||
let isReachable = context.isPeerReachable(peerID)
|
||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
|
||||
let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey)
|
||||
let isMutualFavorite = favoriteStatus?.isMutual ?? false
|
||||
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
|
||||
|
||||
@@ -393,11 +418,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
}
|
||||
|
||||
if !isViewing && shouldMarkUnread {
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: senderName,
|
||||
message: pm.content,
|
||||
peerID: convKey
|
||||
)
|
||||
context.notifyPrivateMessage(from: senderName, message: pm.content, peerID: convKey)
|
||||
}
|
||||
|
||||
context.notifyUIChanged()
|
||||
@@ -592,11 +613,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
context.markReadReceiptSent(message.id)
|
||||
} else {
|
||||
context.unreadPrivateMessages.insert(peerID)
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: message.sender,
|
||||
message: message.content,
|
||||
peerID: peerID
|
||||
)
|
||||
context.notifyPrivateMessage(from: message.sender, message: message.content, peerID: peerID)
|
||||
}
|
||||
|
||||
context.notifyUIChanged()
|
||||
@@ -692,11 +709,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
context.unreadPrivateMessages.insert(ephemeralPeerID)
|
||||
}
|
||||
if isRecentMessage {
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: senderNickname,
|
||||
message: messageContent,
|
||||
peerID: targetPeerID
|
||||
)
|
||||
context.notifyPrivateMessage(from: senderNickname, message: messageContent, peerID: targetPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -716,12 +729,12 @@ final class ChatPrivateConversationCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
let prior = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey)?.theyFavoritedUs ?? false
|
||||
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
|
||||
peerNoisePublicKey: finalNoiseKey,
|
||||
let prior = context.favoriteRelationship(forNoiseKey: finalNoiseKey)?.theyFavoritedUs ?? false
|
||||
context.updatePeerFavoritedUs(
|
||||
noiseKey: finalNoiseKey,
|
||||
favorited: isFavorite,
|
||||
peerNickname: senderNickname,
|
||||
peerNostrPublicKey: nostrPubkey
|
||||
nickname: senderNickname,
|
||||
nostrPublicKey: nostrPubkey
|
||||
)
|
||||
|
||||
if isFavorite && nostrPubkey != nil {
|
||||
|
||||
@@ -88,6 +88,10 @@ protocol ChatPublicConversationContext: AnyObject {
|
||||
func recordContentKey(_ key: String, timestamp: Date)
|
||||
/// Pre-renders the message so the formatting cache is warm before display.
|
||||
func prewarmMessageFormatting(_ message: BitchatMessage)
|
||||
|
||||
// MARK: Notifications
|
||||
/// Posts the you-were-mentioned local notification.
|
||||
func notifyMention(from sender: String, message: String)
|
||||
}
|
||||
|
||||
extension ChatViewModel: ChatPublicConversationContext {
|
||||
@@ -184,6 +188,10 @@ extension ChatViewModel: ChatPublicConversationContext {
|
||||
func prewarmMessageFormatting(_ message: BitchatMessage) {
|
||||
_ = formatMessageAsText(message, colorScheme: currentColorScheme)
|
||||
}
|
||||
|
||||
func notifyMention(from sender: String, message: String) {
|
||||
NotificationService.shared.sendMentionNotification(from: sender, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -548,7 +556,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
|
||||
if isMentioned && message.sender != context.nickname {
|
||||
SecureLogger.info("🔔 Mention from \(message.sender)", category: .session)
|
||||
NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content)
|
||||
context.notifyMention(from: message.sender, message: message.content)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,10 @@ protocol ChatVerificationContext: AnyObject {
|
||||
func triggerHandshake(with peerID: PeerID)
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
|
||||
// MARK: Notifications (shared with `ChatNostrContext`)
|
||||
/// Posts a generic local user notification.
|
||||
func postLocalNotification(title: String, body: String, identifier: String)
|
||||
}
|
||||
|
||||
extension ChatViewModel: ChatVerificationContext {
|
||||
@@ -110,6 +114,10 @@ extension ChatViewModel: ChatVerificationContext {
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||
meshService.sendVerifyResponse(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
|
||||
}
|
||||
|
||||
func postLocalNotification(title: String, body: String, identifier: String) {
|
||||
NotificationService.shared.sendLocalNotification(title: title, body: body, identifier: identifier)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -313,7 +321,7 @@ final class ChatVerificationCoordinator {
|
||||
|
||||
let peerName = context.unifiedPeer(for: peerID)?.nickname
|
||||
?? context.resolveNickname(for: peerID)
|
||||
NotificationService.shared.sendLocalNotification(
|
||||
context.postLocalNotification(
|
||||
title: "Verified",
|
||||
body: "You verified \(peerName)",
|
||||
identifier: "verify-success-\(peerID)-\(UUID().uuidString)"
|
||||
@@ -347,7 +355,7 @@ private extension ChatVerificationCoordinator {
|
||||
guard now.timeIntervalSince(lastToast) > 60 else { return }
|
||||
|
||||
lastMutualToastAt[fingerprint] = now
|
||||
NotificationService.shared.sendLocalNotification(
|
||||
context.postLocalNotification(
|
||||
title: title,
|
||||
body: "You and \(bodyName) verified each other",
|
||||
identifier: "\(notificationPrefix)-\(peerID)-\(UUID().uuidString)"
|
||||
|
||||
@@ -25,6 +25,9 @@ protocol GeoPresenceContext: AnyObject {
|
||||
|
||||
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool
|
||||
func synchronizePublicConversationStore(forGeohash geohash: String)
|
||||
|
||||
/// Posts the sampled-geohash-activity local notification.
|
||||
func notifyGeohashActivity(geohash: String, bodyPreview: String)
|
||||
}
|
||||
|
||||
extension ChatViewModel: GeoPresenceContext {
|
||||
@@ -52,6 +55,10 @@ extension ChatViewModel: GeoPresenceContext {
|
||||
func markGeoTeleported(_ pubkeyHexLowercased: String) {
|
||||
locationPresenceStore.markTeleported(pubkeyHexLowercased)
|
||||
}
|
||||
|
||||
func notifyGeohashActivity(geohash: String, bodyPreview: String) {
|
||||
NotificationService.shared.sendGeohashActivityNotification(geohash: geohash, bodyPreview: bodyPreview)
|
||||
}
|
||||
}
|
||||
|
||||
/// Geohash presence bookkeeping that is independent of relay subscriptions:
|
||||
@@ -160,7 +167,7 @@ final class GeoPresenceTracker {
|
||||
)
|
||||
if context.appendGeohashMessageIfAbsent(message, toGeohash: gh) {
|
||||
context.synchronizePublicConversationStore(forGeohash: gh)
|
||||
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
|
||||
context.notifyGeohashActivity(geohash: gh, bodyPreview: preview)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ protocol NostrInboundPipelineContext: AnyObject {
|
||||
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
|
||||
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String
|
||||
|
||||
// MARK: Favorites bridge
|
||||
/// All favorite relationships, used to bridge a Nostr pubkey back to a
|
||||
/// Noise key on the inbound DM path.
|
||||
func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship]
|
||||
|
||||
// MARK: Presence & key mapping
|
||||
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String)
|
||||
/// Records the Nostr pubkey behind a (possibly virtual) peer ID.
|
||||
@@ -53,6 +58,10 @@ extension ChatViewModel: NostrInboundPipelineContext {
|
||||
deduplicationService.hasProcessedNostrEvent(eventID)
|
||||
}
|
||||
|
||||
func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] {
|
||||
Array(FavoritesPersistenceService.shared.favorites.values)
|
||||
}
|
||||
|
||||
func recordProcessedNostrEvent(_ eventID: String) {
|
||||
deduplicationService.recordNostrEvent(eventID)
|
||||
}
|
||||
@@ -437,7 +446,8 @@ final class NostrInboundPipeline {
|
||||
/// the favorites glue in `ChatNostrCoordinator` delegates to it.
|
||||
@MainActor
|
||||
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
||||
let favorites = FavoritesPersistenceService.shared.favorites.values
|
||||
guard let context else { return nil }
|
||||
let favorites = context.allFavoriteRelationships()
|
||||
var npubToMatch = nostrPubkey
|
||||
|
||||
if !nostrPubkey.hasPrefix("npub") {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user