Files
bitchat/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift
T
jackandClaude Fable 5 3a995e20b6 Extract BLE packet handlers and migrate coordinators to narrow contexts
BLEService's per-packet-type orchestration moves into owned, tested
components (BLEAnnounceHandler, BLEPublicMessageHandler,
BLENoisePacketHandler, BLEFileTransferHandler, BLEFragmentHandler),
each taking an environment struct of closures so every queue hop stays
in BLEService and the handlers are synchronously testable. Behavior is
preserved verbatim, including Noise session recovery on decrypt failure
and single-block UI event ordering. handleLeave/handleRequestSync stay
in place as already-thin delegations. BLEService drops to 3393 lines.

Four coordinators (delivery, private conversation, Nostr, public
conversation) drop their unowned/weak ChatViewModel back-references for
narrow @MainActor context protocols, with ChatViewModel conformances as
single shared witnesses for overlapping members. Their true coupling is
now an explicit, reviewable surface, and each gains a mock-context test
suite covering flows previously testable only through the full view
model. Delivery/read acks now also clear the router's retained-send
outbox via the delivery context.

New LargeTopologyTests exercise production-shaped meshes with the
in-memory harness: an 8-peer relay chain with per-hop TTL decay, a
14-peer cyclic mesh with exactly-once delivery, partition/heal, and
topology churn.

App-layer runtime/model files updated alongside.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:22:52 +01:00

391 lines
16 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
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)
}
}