mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:45:20 +00:00
nostrKeyMapping, sentGeoDeliveryAcks/sentReadReceipts dedupe, isBatchingPublic, geo subscription lifecycle, and private-chat selection hand-off now mutate through single intent operations on ChatViewModel, with backing storage locked down via private(set) so the single-writer property is compiler-enforced. Context protocols downgrade to read-only access where reads remain. 8 new contract tests. Known remaining writers outside the protocols: sentReadReceipts is passed inout to PrivateChatManager.syncReadReceiptsForSentMessages and un-marked by ChatTransportEventCoordinator on disconnect. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
406 lines
17 KiB
Swift
406 lines
17 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// System messages & chat hygiene
|
|
private(set) var systemMessages: [String] = []
|
|
private(set) var meshOnlySystemMessages: [String] = []
|
|
private(set) var sanitizedPeerIDs: [PeerID] = []
|
|
|
|
func addSystemMessage(_ content: String) {
|
|
systemMessages.append(content)
|
|
}
|
|
|
|
func addMeshOnlySystemMessage(_ content: String) {
|
|
meshOnlySystemMessages.append(content)
|
|
}
|
|
|
|
func sanitizeChat(for peerID: PeerID) {
|
|
sanitizedPeerIDs.append(peerID)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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.
|
|
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"])
|
|
#expect(context.sanitizedPeerIDs == [peerID, peerID, peerID])
|
|
|
|
#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)
|
|
#expect(context.sanitizedPeerIDs == [newPeerID])
|
|
#expect(context.notifyUIChangedCount == 1)
|
|
}
|
|
}
|