Files
bitchat/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift
T
jackandClaude Fable 5 879d8cba12 Cut private message path over to ConversationStore
All private-message mutations now flow through store intents:
coordinators, PrivateChatManager (its @Published dicts deleted - now
read-only views over the store), outbound sends, delivery status, and
chat migration. The O(1) store dedup replaces the full-scan duplicate
check; insertion order is maintained by the store so sanitizeChat's
re-sort is a documented no-op. Both bootstrapper Combine bridges and
the Task.yield store synchronization are deleted.

ChatViewModel.privateChats/unreadPrivateMessages become get-only derived
views (measured: naive rebuild equals a change-invalidated cache within
noise, so the simpler form stays). Feature models still read the legacy
store, fed by a coalescing LegacyConversationStoreBridge (one mirror
per burst, marked for step-5 deletion).

pipeline.privateIngest: 9.6k -> 14.7k msg/s (+53%).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:19:11 +02:00

621 lines
26 KiB
Swift

//
// ChatPrivateConversationCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatPrivateConversationCoordinator` against a mock
// `ChatPrivateConversationContext` — proving the coordinator works without a
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` exemplar.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatPrivateConversationContext` proving that
/// `ChatPrivateConversationCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatPrivateConversationContext: ChatPrivateConversationContext {
// Conversation state
var privateChats: [PeerID: [BitchatMessage]] = [:]
var sentReadReceipts: Set<String> = []
var sentGeoDeliveryAcks: Set<String> = []
var unreadPrivateMessages: Set<PeerID> = []
var selectedPrivateChatPeer: PeerID?
var nickname = "me"
var activeChannel: ChannelID = .mesh
var nostrKeyMapping: [PeerID: String] = [:]
private(set) var notifyUIChangedCount = 0
@discardableResult
func markReadReceiptSent(_ messageID: String) -> Bool {
sentReadReceipts.insert(messageID).inserted
}
@discardableResult
func markGeoDeliveryAckSent(_ messageID: String) -> Bool {
sentGeoDeliveryAcks.insert(messageID).inserted
}
func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID) {
guard oldPeerIDs.contains(where: { selectedPrivateChatPeer == $0 }) else { return }
selectedPrivateChatPeer = newPeerID
}
func notifyUIChanged() {
notifyUIChangedCount += 1
}
// Conversation store intents (mirror `ConversationStore` semantics:
// ordered insert, dedup by ID, no-downgrade status, unread carry on
// migrate) while recording calls for assertions.
private(set) var upsertedMessages: [(messageID: String, peerID: PeerID)] = []
private(set) var migratedChats: [(from: PeerID, to: PeerID)] = []
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool {
var chat = privateChats[peerID] ?? []
guard !chat.contains(where: { $0.id == message.id }) else {
privateChats[peerID] = chat
return false
}
let index = chat.firstIndex(where: { $0.timestamp > message.timestamp }) ?? chat.count
chat.insert(message, at: index)
privateChats[peerID] = chat
return true
}
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
upsertedMessages.append((message.id, peerID))
if var chat = privateChats[peerID],
let index = chat.firstIndex(where: { $0.id == message.id }) {
chat[index] = message
privateChats[peerID] = chat
} else {
appendPrivateMessage(message, to: peerID)
}
}
@discardableResult
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool {
guard var chat = privateChats[peerID],
let index = chat.firstIndex(where: { $0.id == messageID }) else {
return false
}
if Conversation.shouldSkipStatusUpdate(current: chat[index].deliveryStatus, new: status) {
return false
}
chat[index].deliveryStatus = status
privateChats[peerID] = chat
return true
}
func markPrivateChatUnread(_ peerID: PeerID) {
unreadPrivateMessages.insert(peerID)
}
func markPrivateChatRead(_ peerID: PeerID) {
unreadPrivateMessages.remove(peerID)
}
func removePrivateChat(_ peerID: PeerID) {
privateChats.removeValue(forKey: peerID)
unreadPrivateMessages.remove(peerID)
}
func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) {
migratedChats.append((oldPeerID, newPeerID))
guard oldPeerID != newPeerID, let source = privateChats[oldPeerID] else { return }
for message in source {
appendPrivateMessage(message, to: newPeerID)
}
if privateChats[newPeerID] == nil {
privateChats[newPeerID] = []
}
let wasUnread = unreadPrivateMessages.contains(oldPeerID)
privateChats.removeValue(forKey: oldPeerID)
unreadPrivateMessages.remove(oldPeerID)
if wasUnread {
unreadPrivateMessages.insert(newPeerID)
}
}
func privateChatsContainMessage(withID messageID: String) -> Bool {
privateChats.values.contains { chat in
chat.contains { $0.id == messageID }
}
}
func privateChat(_ peerID: PeerID, containsMessageWithID messageID: String) -> Bool {
privateChats[peerID]?.contains { $0.id == messageID } == true
}
// Peers & identity
var myPeerID = PeerID(str: "0011223344556677")
var nicknamesByPeerID: [PeerID: String] = [:]
var connectedPeers: Set<PeerID> = []
var reachablePeers: Set<PeerID> = []
var blockedPeers: Set<PeerID> = []
var noiseKeysByPeerID: [PeerID: Data] = [:]
var ephemeralPeerIDsByNoiseKey: [Data: PeerID] = [:]
var peerIDsByNickname: [String: PeerID] = [:]
var fingerprintsByPeerID: [PeerID: String] = [:]
private(set) var clearedFingerprints: [PeerID] = []
func peerNickname(for peerID: PeerID) -> String? { nicknamesByPeerID[peerID] }
func isPeerConnected(_ peerID: PeerID) -> Bool { connectedPeers.contains(peerID) }
func isPeerReachable(_ peerID: PeerID) -> Bool { reachablePeers.contains(peerID) }
func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
func noisePublicKey(for peerID: PeerID) -> Data? { noiseKeysByPeerID[peerID] }
func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? { ephemeralPeerIDsByNoiseKey[noiseKey] }
func getPeerIDForNickname(_ nickname: String) -> PeerID? { peerIDsByNickname[nickname] }
func getFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] }
func storedFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] }
func clearStoredFingerprint(for peerID: PeerID) {
fingerprintsByPeerID.removeValue(forKey: peerID)
clearedFingerprints.append(peerID)
}
// Nostr identity
var blockedNostrPubkeys: Set<String> = []
var displayNamesByPubkey: [String: String] = [:]
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
blockedNostrPubkeys.contains(pubkeyHexLowercased)
}
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
displayNamesByPubkey[pubkeyHex] ?? "anon"
}
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { Self.dummyIdentity }
func currentNostrIdentity() -> NostrIdentity? { Self.dummyIdentity }
// Routing & acknowledgements
private(set) var routedPrivateMessages: [(content: String, peerID: PeerID, messageID: String)] = []
private(set) var routedReadReceipts: [(messageID: String, peerID: PeerID)] = []
private(set) var routedFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
private(set) var meshReadReceipts: [(messageID: String, peerID: PeerID)] = []
private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = []
private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = []
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
private(set) var embeddedDeliveryAckMessageIDs: [String] = []
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
routedPrivateMessages.append((content, peerID, messageID))
}
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
routedReadReceipts.append((receipt.originalMessageID, peerID))
}
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
routedFavoriteNotifications.append((peerID, isFavorite))
}
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
meshReadReceipts.append((receipt.originalMessageID, peerID))
}
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
geoPrivateMessages.append((content, recipientHex, messageID))
}
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
geoDeliveryAcks.append((messageID, recipientHex))
}
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
geoReadReceipts.append((messageID, recipientHex))
}
func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?) {
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
private(set) var systemMessages: [String] = []
private(set) var meshOnlySystemMessages: [String] = []
func addSystemMessage(_ content: String) {
systemMessages.append(content)
}
func addMeshOnlySystemMessage(_ content: String) {
meshOnlySystemMessages.append(content)
}
static let dummyIdentity = NostrIdentity(
privateKey: Data(repeating: 0x11, count: 32),
publicKey: Data(repeating: 0x22, count: 32),
npub: "npub1mock",
createdAt: Date(timeIntervalSince1970: 0)
)
}
// MARK: - Helpers
@MainActor
private func makeIncomingMessage(
id: String,
sender: String = "alice",
content: String = "hello",
timestamp: Date = Date(),
senderPeerID: PeerID? = nil,
recipientNickname: String? = "me"
) -> BitchatMessage {
BitchatMessage(
id: id,
sender: sender,
content: content,
timestamp: timestamp,
isRelay: false,
isPrivate: true,
recipientNickname: recipientNickname,
senderPeerID: senderPeerID,
deliveryStatus: .delivered(to: "me", at: timestamp)
)
}
private func isDelivered(_ status: DeliveryStatus?, to expected: String) -> Bool {
if case .delivered(let to, _) = status { return to == expected }
return false
}
private func isRead(_ status: DeliveryStatus?, by expected: String) -> Bool {
if case .read(let by, _) = status { return by == expected }
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 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
func addMessageToPrivateChats_upsertsByIdAndSanitizes() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let peerID = PeerID(str: "0102030405060708")
let original = makeIncomingMessage(id: "m1", content: "first")
coordinator.addMessageToPrivateChatsIfNeeded(original, targetPeerID: peerID)
#expect(context.privateChats[peerID]?.map(\.id) == ["m1"])
// Same id again must replace in place, not append a duplicate.
let updated = makeIncomingMessage(id: "m1", content: "edited")
coordinator.addMessageToPrivateChatsIfNeeded(updated, targetPeerID: peerID)
#expect(context.privateChats[peerID]?.count == 1)
#expect(context.privateChats[peerID]?.first?.content == "edited")
// A different id appends.
coordinator.addMessageToPrivateChatsIfNeeded(makeIncomingMessage(id: "m2"), targetPeerID: peerID)
#expect(context.privateChats[peerID]?.map(\.id) == ["m1", "m2"])
// Every add went through the store's upsert intent.
#expect(context.upsertedMessages.map(\.peerID) == [peerID, peerID, peerID])
#expect(context.upsertedMessages.map(\.messageID) == ["m1", "m1", "m2"])
#expect(coordinator.isDuplicateMessage("m1", targetPeerID: peerID))
#expect(!coordinator.isDuplicateMessage("m3", targetPeerID: peerID))
}
@Test @MainActor
func geoDeliveredAndReadAcks_updateStatusAndNotify() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let convKey = PeerID(str: "nostr_abcdef12")
let senderPubkey = "feedface00112233"
context.displayNamesByPubkey[senderPubkey] = "alice#1234"
context.privateChats[convKey] = [
makeIncomingMessage(id: "mine-1", sender: "me"),
makeIncomingMessage(id: "mine-2", sender: "me"),
]
coordinator.handleDelivered(
NoisePayload(type: .delivered, data: Data("mine-1".utf8)),
senderPubkey: senderPubkey,
convKey: convKey
)
#expect(isDelivered(context.privateChats[convKey]?.first?.deliveryStatus, to: "alice#1234"))
#expect(context.notifyUIChangedCount == 1)
coordinator.handleReadReceipt(
NoisePayload(type: .readReceipt, data: Data("mine-2".utf8)),
senderPubkey: senderPubkey,
convKey: convKey
)
#expect(isRead(context.privateChats[convKey]?.last?.deliveryStatus, by: "alice#1234"))
#expect(context.notifyUIChangedCount == 2)
// Unknown message id: no state change, no UI notification.
coordinator.handleDelivered(
NoisePayload(type: .delivered, data: Data("missing".utf8)),
senderPubkey: senderPubkey,
convKey: convKey
)
#expect(context.notifyUIChangedCount == 2)
}
@Test @MainActor
func geoPrivateMessage_sendsDeliveryAckOnceAndDeduplicates() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let convKey = PeerID(str: "nostr_abcdef12")
let senderPubkey = "feedface00112233"
context.displayNamesByPubkey[senderPubkey] = "bob#5678"
let payloadData = PrivateMessagePacket(messageID: "geo-1", content: "hi there").encode()!
let payload = NoisePayload(type: .privateMessage, data: payloadData)
// Old timestamp: not "recent", so no unread marking (and no notification).
let oldTimestamp = Date().addingTimeInterval(-120)
coordinator.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: MockChatPrivateConversationContext.dummyIdentity,
messageTimestamp: oldTimestamp
)
#expect(context.geoDeliveryAcks.map(\.messageID) == ["geo-1"])
#expect(context.geoDeliveryAcks.first?.recipientHex == senderPubkey)
#expect(context.sentGeoDeliveryAcks == ["geo-1"])
#expect(context.privateChats[convKey]?.map(\.id) == ["geo-1"])
#expect(context.privateChats[convKey]?.first?.sender == "bob#5678")
#expect(context.unreadPrivateMessages.isEmpty)
#expect(context.notifyUIChangedCount == 1)
// Redelivery: ack is deduplicated and the message is not appended twice.
coordinator.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: MockChatPrivateConversationContext.dummyIdentity,
messageTimestamp: oldTimestamp
)
#expect(context.geoDeliveryAcks.count == 1)
#expect(context.privateChats[convKey]?.count == 1)
}
@Test @MainActor
func handleViewingThisChat_clearsUnreadAndSendsRoutedReadReceiptOnce() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let noiseKey = Data(repeating: 0xAB, count: 32)
let stablePeerID = PeerID(hexData: noiseKey)
let ephemeralPeerID = PeerID(str: "0102030405060708")
context.ephemeralPeerIDsByNoiseKey[noiseKey] = ephemeralPeerID
context.unreadPrivateMessages = [stablePeerID, ephemeralPeerID]
let message = makeIncomingMessage(id: "read-1", senderPeerID: stablePeerID)
coordinator.handleViewingThisChat(
message,
targetPeerID: stablePeerID,
key: noiseKey,
senderPubkey: "feedface00112233"
)
#expect(context.unreadPrivateMessages.isEmpty)
#expect(context.routedReadReceipts.map(\.messageID) == ["read-1"])
#expect(context.routedReadReceipts.first?.peerID == stablePeerID)
#expect(context.sentReadReceipts == ["read-1"])
// Already-acked message must not produce a second receipt.
coordinator.handleViewingThisChat(
message,
targetPeerID: stablePeerID,
key: noiseKey,
senderPubkey: "feedface00112233"
)
#expect(context.routedReadReceipts.count == 1)
// Without a Noise key, the receipt goes out via the geohash transport.
context.sentReadReceipts = []
coordinator.handleViewingThisChat(
message,
targetPeerID: stablePeerID,
key: nil,
senderPubkey: "feedface00112233"
)
#expect(context.geoReadReceipts.map(\.messageID) == ["read-1"])
#expect(context.sentReadReceipts == ["read-1"])
}
@Test @MainActor
func markAsUnread_tracksTargetAndEphemeralWithoutNotificationWhenStale() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let noiseKey = Data(repeating: 0xCD, count: 32)
let stablePeerID = PeerID(hexData: noiseKey)
let ephemeralPeerID = PeerID(str: "1112131415161718")
context.ephemeralPeerIDsByNoiseKey[noiseKey] = ephemeralPeerID
// isRecentMessage false keeps the flow off the NotificationService singleton.
coordinator.markAsUnreadIfNeeded(
shouldMarkAsUnread: true,
targetPeerID: stablePeerID,
key: noiseKey,
isRecentMessage: false,
senderNickname: "alice",
messageContent: "hello"
)
#expect(context.unreadPrivateMessages == [stablePeerID, ephemeralPeerID])
context.unreadPrivateMessages = []
coordinator.markAsUnreadIfNeeded(
shouldMarkAsUnread: false,
targetPeerID: stablePeerID,
key: noiseKey,
isRecentMessage: false,
senderNickname: "alice",
messageContent: "hello"
)
#expect(context.unreadPrivateMessages.isEmpty)
}
@Test @MainActor
func migratePrivateChats_movesMessagesOnFingerprintMatchAndClearsOldPeer() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let oldPeerID = PeerID(str: "aaaaaaaaaaaaaaaa")
let newPeerID = PeerID(str: "bbbbbbbbbbbbbbbb")
context.fingerprintsByPeerID[oldPeerID] = "fp-1"
context.fingerprintsByPeerID[newPeerID] = "fp-1"
let older = makeIncomingMessage(id: "old-1", timestamp: Date().addingTimeInterval(-60))
let newer = makeIncomingMessage(id: "old-2", timestamp: Date().addingTimeInterval(-30))
context.privateChats[oldPeerID] = [newer, older]
context.unreadPrivateMessages = [oldPeerID]
context.selectedPrivateChatPeer = oldPeerID
coordinator.migratePrivateChatsIfNeeded(for: newPeerID, senderNickname: "alice")
#expect(context.privateChats[oldPeerID] == nil)
#expect(context.privateChats[newPeerID]?.map(\.id) == ["old-1", "old-2"])
#expect(context.unreadPrivateMessages.isEmpty)
#expect(context.clearedFingerprints == [oldPeerID])
#expect(context.selectedPrivateChatPeer == newPeerID)
// The wholesale move went through the store's migrate intent.
#expect(context.migratedChats.map(\.from) == [oldPeerID])
#expect(context.migratedChats.map(\.to) == [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
}
}
}