Migrate five more coordinators to narrow context protocols

ChatTransportEventCoordinator, ChatPeerIdentityCoordinator,
ChatMediaTransferCoordinator, ChatVerificationCoordinator, and
ChatLifecycleCoordinator drop their unowned ChatViewModel back-refs for
narrow @MainActor contexts (20-36 members each), reusing shared
witnesses across protocols. The two remaining raw writers of
sentReadReceipts now route through owner intent ops
(unmarkReadReceiptsSent, syncReadReceiptsForSentMessages), closing the
gaps noted in the previous commit. NoiseEncryptionService stays fully
out of the verification coordinator via installNoiseSessionCallbacks.
26 new mock-context tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-10 21:54:54 +02:00
co-authored by Claude Fable 5
parent 707b22878d
commit 82736c4991
11 changed files with 2376 additions and 350 deletions
@@ -0,0 +1,283 @@
//
// ChatLifecycleCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatLifecycleCoordinator` against a mock `ChatLifecycleContext`
// proving the coordinator works without a `ChatViewModel`, following the
// `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.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatLifecycleContext` proving that
/// `ChatLifecycleCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatLifecycleContext: ChatLifecycleContext {
// Chat & receipt state
var messages: [BitchatMessage] = []
var privateChats: [PeerID: [BitchatMessage]] = [:]
var unreadPrivateMessages: Set<PeerID> = []
var selectedPrivateChatPeer: PeerID?
var sentReadReceipts: Set<String> = []
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
var activeChannel: ChannelID = .mesh
var nostrKeyMapping: [PeerID: String] = [:]
private(set) var ownerLevelReadPasses: [PeerID] = []
private(set) var managerReadMarks: [PeerID] = []
private(set) var privateStoreSyncCount = 0
private(set) var systemMessages: [String] = []
@discardableResult
func markReadReceiptSent(_ messageID: String) -> Bool {
sentReadReceipts.insert(messageID).inserted
}
func markPrivateMessagesAsRead(from peerID: PeerID) {
ownerLevelReadPasses.append(peerID)
}
func markChatAsRead(from peerID: PeerID) {
managerReadMarks.append(peerID)
}
func synchronizePrivateConversationStore() { privateStoreSyncCount += 1 }
func addSystemMessage(_ content: String) { systemMessages.append(content) }
// Peers & sessions
var nicknamesByPeerID: [PeerID: String] = [:]
var peersByID: [PeerID: BitchatPeer] = [:]
var noiseSessionStates: [PeerID: LazyHandshakeState] = [:]
private(set) var stopMeshServicesCount = 0
private(set) var refreshBluetoothStateCount = 0
func peerNickname(for peerID: PeerID) -> String? { nicknamesByPeerID[peerID] }
func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { peersByID[peerID] }
func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState {
noiseSessionStates[peerID] ?? .none
}
func stopMeshServices() { stopMeshServicesCount += 1 }
func refreshBluetoothState() { refreshBluetoothStateCount += 1 }
// Routing & receipts
private(set) var routedPrivateMessages: [(content: String, peerID: PeerID, recipientNickname: String)] = []
private(set) var routedReadReceipts: [(messageID: String, peerID: PeerID)] = []
private(set) var meshBroadcasts: [String] = []
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
routedPrivateMessages.append((content, peerID, recipientNickname))
}
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
routedReadReceipts.append((receipt.originalMessageID, peerID))
}
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
meshBroadcasts.append(content)
}
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
geoReadReceipts.append((messageID, recipientHex))
}
// Nostr & geohash
var isTeleported = false
private(set) var recordedGeoParticipants: [String] = []
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { Self.dummyIdentity }
func recordGeoParticipant(pubkeyHex: String) { recordedGeoParticipants.append(pubkeyHex) }
// Identity persistence
private(set) var forceSaveIdentityCount = 0
private(set) var verifyIdentityKeyExistsCount = 0
func forceSaveIdentity() { forceSaveIdentityCount += 1 }
@discardableResult
func verifyIdentityKeyExists() -> Bool {
verifyIdentityKeyExistsCount += 1
return true
}
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 makePrivateMessage(
id: String,
sender: String = "alice",
timestamp: Date = Date(),
senderPeerID: PeerID? = nil,
isRelay: Bool = false,
deliveryStatus: DeliveryStatus? = nil
) -> BitchatMessage {
BitchatMessage(
id: id,
sender: sender,
content: "hello",
timestamp: timestamp,
isRelay: isRelay,
isPrivate: true,
recipientNickname: "me",
senderPeerID: senderPeerID,
deliveryStatus: deliveryStatus
)
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatLifecycleCoordinator` against `MockChatLifecycleContext`
/// with no `ChatViewModel`.
struct ChatLifecycleCoordinatorContextTests {
@Test @MainActor
func getPrivateChatMessages_mergesEphemeralAndStableKeepingBestStatus() async {
let context = MockChatLifecycleContext()
let coordinator = ChatLifecycleCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
let noiseKey = Data(repeating: 0xAB, count: 32)
let stablePeerID = PeerID(hexData: noiseKey)
context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")
let t1 = Date(timeIntervalSince1970: 1)
let t2 = Date(timeIntervalSince1970: 2)
// Same message under both keys: the read copy must win over sent.
context.privateChats[peerID] = [
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent),
makePrivateMessage(id: "m2", timestamp: t2),
]
context.privateChats[stablePeerID] = [
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2)),
]
let merged = coordinator.getPrivateChatMessages(for: peerID)
#expect(merged.map(\.id) == ["m1", "m2"])
if case .read? = merged.first?.deliveryStatus {
} else {
Issue.record("expected the .read copy of m1 to win the merge")
}
// getMessages(for: nil) falls back to the public timeline.
context.messages = [makePrivateMessage(id: "pub")]
#expect(coordinator.getMessages(for: nil).map(\.id) == ["pub"])
}
@Test @MainActor
func markPrivateMessagesAsRead_geoDM_sendsReadReceiptsOnce() async {
let context = MockChatLifecycleContext()
let coordinator = ChatLifecycleCoordinator(context: context)
let convKey = PeerID(nostr_: "feedface00112233")
let recipientHex = "feedface00112233"
context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy"))
context.nostrKeyMapping[convKey] = recipientHex
context.sentReadReceipts = ["already-acked"]
context.privateChats[convKey] = [
makePrivateMessage(id: "m1", senderPeerID: convKey),
makePrivateMessage(id: "already-acked", senderPeerID: convKey),
makePrivateMessage(id: "relay", senderPeerID: convKey, isRelay: true),
makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID),
]
coordinator.markPrivateMessagesAsRead(from: convKey)
#expect(context.managerReadMarks == [convKey])
#expect(context.privateStoreSyncCount == 1)
// Only the peer's own un-acked, non-relay message gets a READ.
#expect(context.geoReadReceipts.map(\.messageID) == ["m1"])
#expect(context.geoReadReceipts.first?.recipientHex == recipientHex)
#expect(context.sentReadReceipts.contains("m1"))
// Second pass: nothing new to send.
coordinator.markPrivateMessagesAsRead(from: convKey)
#expect(context.geoReadReceipts.count == 1)
}
@Test @MainActor
func handleScreenshotCaptured_privateChat_appendsNoticeAndRoutesWhenEstablished() async {
let context = MockChatLifecycleContext()
let coordinator = ChatLifecycleCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.nicknamesByPeerID[peerID] = "alice"
// No established session: local notice only, no network send.
coordinator.handleScreenshotCaptured()
#expect(context.routedPrivateMessages.isEmpty)
#expect(context.privateChats[peerID]?.map(\.content) == ["you took a screenshot"])
#expect(context.privateChats[peerID]?.first?.sender == "system")
// Established session: the peer is notified too.
context.noiseSessionStates[peerID] = .established
coordinator.handleScreenshotCaptured()
#expect(context.routedPrivateMessages.count == 1)
#expect(context.routedPrivateMessages.first?.content == "* me took a screenshot *")
#expect(context.routedPrivateMessages.first?.recipientNickname == "alice")
#expect(context.privateChats[peerID]?.count == 2)
// The public-channel system message is not used for private chats.
#expect(context.systemMessages.isEmpty)
#expect(context.meshBroadcasts.isEmpty)
}
@Test @MainActor
func handleScreenshotCaptured_meshChannel_broadcastsAndConfirmsLocally() async {
let context = MockChatLifecycleContext()
let coordinator = ChatLifecycleCoordinator(context: context)
coordinator.handleScreenshotCaptured()
#expect(context.meshBroadcasts == ["* me took a screenshot *"])
#expect(context.systemMessages == ["you took a screenshot"])
#expect(context.privateChats.isEmpty)
}
@Test @MainActor
func lifecycleEvents_persistIdentityAndScheduleReadPasses() async {
let context = MockChatLifecycleContext()
let coordinator = ChatLifecycleCoordinator(context: context)
coordinator.applicationWillTerminate()
#expect(context.stopMeshServicesCount == 1)
#expect(context.forceSaveIdentityCount == 1)
#expect(context.verifyIdentityKeyExistsCount == 1)
// Becoming active with no open chat only refreshes Bluetooth state.
coordinator.handleDidBecomeActive()
#expect(context.refreshBluetoothStateCount == 1)
#expect(context.managerReadMarks.isEmpty)
// With an open chat the read pass runs immediately (manager-level) and
// a delayed owner-level pass is scheduled.
let peerID = PeerID(nostr_: "feedface00112233")
context.selectedPrivateChatPeer = peerID
coordinator.handleDidBecomeActive()
#expect(context.refreshBluetoothStateCount == 2)
#expect(context.managerReadMarks == [peerID])
let deadline = Date().addingTimeInterval(2)
while context.ownerLevelReadPasses.isEmpty && Date() < deadline {
try? await Task.sleep(nanoseconds: 20_000_000)
}
#expect(context.ownerLevelReadPasses == [peerID])
}
}
@@ -0,0 +1,206 @@
//
// ChatMediaTransferCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatMediaTransferCoordinator` against a mock
// `ChatMediaTransferContext` proving the coordinator works without a
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Scope note: the async media-preparation pipelines (`ImageUtils`,
// `ChatMediaPreparation`) run real file/codec work and remain covered by
// `ChatMediaPreparationTests`; here we cover message enqueueing, transfer
// bookkeeping, and the blocked-context guards.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatMediaTransferContext` proving that
/// `ChatMediaTransferCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatMediaTransferContext: ChatMediaTransferContext {
// Composition state
var canSendMediaInCurrentContext = true
var selectedPrivateChatPeer: PeerID?
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
var activeChannel: ChannelID = .mesh
var nicknamesByPeerID: [PeerID: String] = [:]
func nicknameForPeer(_ peerID: PeerID) -> String {
nicknamesByPeerID[peerID] ?? "user"
}
func currentPublicSender() -> (name: String, peerID: PeerID) {
(nickname, myPeerID)
}
// Message state
var privateChats: [PeerID: [BitchatMessage]] = [:]
var meshTimeline: [BitchatMessage] = []
private(set) var refreshedChannels: [ChannelID?] = []
private(set) var trimCount = 0
private(set) var removedMessages: [(messageID: String, cleanupFile: Bool)] = []
private(set) var systemMessages: [String] = []
private(set) var notifyUIChangedCount = 0
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) {
meshTimeline.append(message)
}
func refreshVisibleMessages(from channel: ChannelID?) {
refreshedChannels.append(channel)
}
func trimMessagesIfNeeded() { trimCount += 1 }
func removeMessage(withID messageID: String, cleanupFile: Bool) {
removedMessages.append((messageID, cleanupFile))
}
func addSystemMessage(_ content: String) { systemMessages.append(content) }
func notifyUIChanged() { notifyUIChangedCount += 1 }
// Delivery status & dedup
private(set) var deliveryStatusUpdates: [(messageID: String, status: DeliveryStatus)] = []
private(set) var recordedContentKeys: [String] = []
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
deliveryStatusUpdates.append((messageID, status))
}
func normalizedContentKey(_ content: String) -> String { content.lowercased() }
func recordContentKey(_ key: String, timestamp: Date) {
recordedContentKeys.append(key)
}
// Mesh file transfer
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
private(set) var broadcastFileSends: [String] = []
private(set) var cancelledTransfers: [String] = []
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
privateFileSends.append((peerID, transferId))
}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
broadcastFileSends.append(transferId)
}
func cancelTransfer(_ transferId: String) {
cancelledTransfers.append(transferId)
}
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatMediaTransferCoordinator` against
/// `MockChatMediaTransferContext` with no `ChatViewModel`.
struct ChatMediaTransferCoordinatorContextTests {
@Test @MainActor
func enqueueMediaMessage_privateChatAppendsAndRecordsDedupKey() async {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.nicknamesByPeerID[peerID] = "alice"
let message = coordinator.enqueueMediaMessage(content: "[voice] note.m4a", targetPeer: peerID)
#expect(context.privateChats[peerID]?.map(\.id) == [message.id])
#expect(message.isPrivate)
#expect(message.recipientNickname == "alice")
#expect(message.senderPeerID == context.myPeerID)
#expect(message.deliveryStatus == .sending)
#expect(context.recordedContentKeys == ["[voice] note.m4a"])
#expect(context.trimCount == 1)
#expect(context.notifyUIChangedCount == 1)
#expect(context.meshTimeline.isEmpty)
}
@Test @MainActor
func enqueueMediaMessage_publicAppendsToTimelineAndRefreshes() async {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let message = coordinator.enqueueMediaMessage(content: "[image] pic.jpg", targetPeer: nil)
#expect(context.meshTimeline.map(\.id) == [message.id])
#expect(!message.isPrivate)
#expect(message.sender == "me")
#expect(context.refreshedChannels.count == 1)
#expect(context.privateChats.isEmpty)
#expect(context.notifyUIChangedCount == 1)
}
@Test @MainActor
func transferEvents_driveDeliveryStatusAndMappingCleanup() async {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
coordinator.registerTransfer(transferId: "t1", messageID: "m1")
coordinator.handleTransferEvent(.started(id: "t1", totalFragments: 10))
coordinator.handleTransferEvent(.updated(id: "t1", sentFragments: 4, totalFragments: 10))
coordinator.handleTransferEvent(.completed(id: "t1", totalFragments: 10))
// After completion the mapping is gone: further events are ignored.
coordinator.handleTransferEvent(.updated(id: "t1", sentFragments: 9, totalFragments: 10))
#expect(context.deliveryStatusUpdates.count == 3)
#expect(context.deliveryStatusUpdates[0].status == .partiallyDelivered(reached: 0, total: 10))
#expect(context.deliveryStatusUpdates[1].status == .partiallyDelivered(reached: 4, total: 10))
#expect(context.deliveryStatusUpdates[2].status == .sent)
#expect(coordinator.messageIDToTransferId.isEmpty)
// A cancelled transfer removes the message (with file cleanup).
coordinator.registerTransfer(transferId: "t2", messageID: "m2")
coordinator.handleTransferEvent(.cancelled(id: "t2", sentFragments: 1, totalFragments: 5))
#expect(context.removedMessages.count == 1)
#expect(context.removedMessages.first?.messageID == "m2")
#expect(context.removedMessages.first?.cleanupFile == true)
}
@Test @MainActor
func cancelMediaSend_cancelsOnlyActiveTransferAndRemovesMessage() async {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
// Two messages share a transfer queue; only the active head cancels
// the underlying transfer.
coordinator.registerTransfer(transferId: "t1", messageID: "m1")
coordinator.registerTransfer(transferId: "t1", messageID: "m2")
coordinator.cancelMediaSend(messageID: "m2")
#expect(context.cancelledTransfers.isEmpty)
#expect(context.removedMessages.map(\.messageID) == ["m2"])
coordinator.cancelMediaSend(messageID: "m1")
#expect(context.cancelledTransfers == ["t1"])
#expect(context.removedMessages.map(\.messageID) == ["m2", "m1"])
#expect(coordinator.transferIdToMessageIDs.isEmpty)
#expect(coordinator.messageIDToTransferId.isEmpty)
}
@Test @MainActor
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
context.canSendMediaInCurrentContext = false
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("voice-note-test-\(UUID().uuidString).m4a")
try Data([0x01, 0x02]).write(to: url)
coordinator.sendVoiceNote(at: url)
#expect(!FileManager.default.fileExists(atPath: url.path))
#expect(context.systemMessages == ["Voice notes are only available in mesh chats."])
#expect(context.privateChats.isEmpty)
#expect(context.meshTimeline.isEmpty)
#expect(coordinator.transferIdToMessageIDs.isEmpty)
}
}
@@ -0,0 +1,358 @@
//
// ChatPeerIdentityCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatPeerIdentityCoordinator` against a mock
// `ChatPeerIdentityContext` proving the coordinator works without a
// `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.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatPeerIdentityContext` proving that
/// `ChatPeerIdentityCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatPeerIdentityContext: ChatPeerIdentityContext {
// Conversation state
var privateChats: [PeerID: [BitchatMessage]] = [:]
var unreadPrivateMessages: Set<PeerID> = []
var selectedPrivateChatPeer: PeerID?
var selectedPrivateChatFingerprint: String?
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
var activeChannel: ChannelID = .mesh
private(set) var notifyUIChangedCount = 0
private(set) var systemMessages: [String] = []
func notifyUIChanged() { notifyUIChangedCount += 1 }
func addSystemMessage(_ content: String) { systemMessages.append(content) }
// Private chat session lifecycle
private(set) var consolidatedPeers: [(peerID: PeerID, peerNickname: String)] = []
private(set) var syncedReadReceiptPeers: [PeerID] = []
private(set) var begunChatSessions: [PeerID] = []
private(set) var privateStoreSyncCount = 0
private(set) var selectionStoreSyncCount = 0
private(set) var markedReadPeers: [PeerID] = []
@discardableResult
func consolidatePrivateMessages(for peerID: PeerID, peerNickname: String) -> Bool {
consolidatedPeers.append((peerID, peerNickname))
return false
}
func syncReadReceiptsForSentMessages(for peerID: PeerID) {
syncedReadReceiptPeers.append(peerID)
}
func beginPrivateChatSession(with peerID: PeerID) {
begunChatSessions.append(peerID)
}
func synchronizePrivateConversationStore() { privateStoreSyncCount += 1 }
func synchronizeConversationSelectionStore() { selectionStoreSyncCount += 1 }
func markPrivateMessagesAsRead(from peerID: PeerID) { markedReadPeers.append(peerID) }
// Unified peer service
var connectedPeers: Set<PeerID> = []
var peersByID: [PeerID: BitchatPeer] = [:]
var blockedPeers: Set<PeerID> = []
var fingerprintsByPeerID: [PeerID: String] = [:]
var peerIDsByNickname: [String: PeerID] = [:]
var ephemeralPeerIDsByNoiseKey: [Data: PeerID] = [:]
private(set) var toggledFavoritePeers: [PeerID] = []
func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { peersByID[peerID] }
func unifiedIsBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
func unifiedToggleFavorite(_ peerID: PeerID) { toggledFavoritePeers.append(peerID) }
func unifiedFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] }
func unifiedPeerID(forNickname nickname: String) -> PeerID? { peerIDsByNickname[nickname] }
func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? { ephemeralPeerIDsByNoiseKey[noiseKey] }
// Mesh & Noise sessions
var nicknamesByPeerID: [PeerID: String] = [:]
var noiseSessionStates: [PeerID: LazyHandshakeState] = [:]
var establishedNoiseSessions: Set<PeerID> = []
var activeNoiseSessions: Set<PeerID> = []
var myNoiseFingerprint = "my-fingerprint"
private(set) var triggeredHandshakes: [PeerID] = []
func peerNickname(for peerID: PeerID) -> String? { nicknamesByPeerID[peerID] }
func meshPeerNicknames() -> [PeerID: String] { nicknamesByPeerID }
func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState {
noiseSessionStates[peerID] ?? .none
}
func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) }
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool {
establishedNoiseSessions.contains(peerID)
}
func hasNoiseSession(with peerID: PeerID) -> Bool { activeNoiseSessions.contains(peerID) }
func noiseIdentityFingerprint() -> String { myNoiseFingerprint }
// Identity store (fingerprints & encryption status)
var verifiedFingerprintSet: Set<String> = []
var socialIdentitiesByFingerprint: [String: SocialIdentity] = [:]
private(set) var storedFingerprints: [(fingerprint: String, peerID: PeerID)] = []
private(set) var encryptionStatuses: [PeerID: EncryptionStatus?] = [:]
private(set) var cachedEncryptionStatuses: [PeerID: EncryptionStatus] = [:]
private(set) var invalidatedEncryptionCachePeers: [PeerID?] = []
func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) {
storedFingerprints.append((fingerprint, peerID))
fingerprintsByPeerID[peerID] = fingerprint
}
func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String? {
let fingerprint = fingerprintsByPeerID.removeValue(forKey: oldPeerID) ?? fallback
if let fingerprint {
fingerprintsByPeerID[newPeerID] = fingerprint
}
return fingerprint
}
func isVerifiedFingerprint(_ fingerprint: String) -> Bool {
verifiedFingerprintSet.contains(fingerprint)
}
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) {
encryptionStatuses[peerID] = status
}
func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
cachedEncryptionStatuses[peerID]
}
func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) {
cachedEncryptionStatuses[peerID] = status
}
func invalidateStoredEncryptionCache(for peerID: PeerID?) {
invalidatedEncryptionCachePeers.append(peerID)
if let peerID {
cachedEncryptionStatuses.removeValue(forKey: peerID)
} else {
cachedEncryptionStatuses.removeAll()
}
}
func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity? {
socialIdentitiesByFingerprint[fingerprint]
}
// Geohash & Nostr
var geoNicknames: [String: String] = [:]
var geohashPeople: [GeoPerson] = []
private(set) var registeredNostrKeyMappings: [(pubkey: String, peerID: PeerID)] = []
private(set) var nostrFavoriteNotifications: [(noisePublicKey: Data, isFavorite: Bool)] = []
var bridgedNostrKeysByNoiseKey: [Data: String] = [:]
func visibleGeohashPeople() -> [GeoPerson] { geohashPeople }
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) {
registeredNostrKeyMappings.append((pubkey, peerID))
}
func bridgedNostrPublicKey(for noiseKey: Data) -> String? {
bridgedNostrKeysByNoiseKey[noiseKey]
}
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
nostrFavoriteNotifications.append((noisePublicKey, isFavorite))
}
}
// MARK: - Helpers
@MainActor
private func makePrivateMessage(
id: String,
sender: String = "alice",
timestamp: Date = Date(),
senderPeerID: PeerID? = nil
) -> BitchatMessage {
BitchatMessage(
id: id,
sender: sender,
content: "hello",
timestamp: timestamp,
isRelay: false,
isPrivate: true,
recipientNickname: "me",
senderPeerID: senderPeerID
)
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatPeerIdentityCoordinator` against
/// `MockChatPeerIdentityContext` with no `ChatViewModel`.
struct ChatPeerIdentityCoordinatorContextTests {
@Test @MainActor
func startPrivateChat_runsFullSessionSetupSequence() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.nicknamesByPeerID[peerID] = "alice"
context.fingerprintsByPeerID[peerID] = "fp-alice"
// Chatting with ourselves is a no-op.
coordinator.startPrivateChat(with: context.myPeerID)
#expect(context.begunChatSessions.isEmpty)
coordinator.startPrivateChat(with: peerID)
#expect(context.consolidatedPeers.map(\.peerID) == [peerID])
#expect(context.consolidatedPeers.first?.peerNickname == "alice")
// No Noise session yet -> handshake triggered.
#expect(context.triggeredHandshakes == [peerID])
#expect(context.syncedReadReceiptPeers == [peerID])
#expect(context.storedFingerprints.map(\.fingerprint) == ["fp-alice"])
#expect(context.selectedPrivateChatFingerprint == "fp-alice")
#expect(context.begunChatSessions == [peerID])
#expect(context.privateStoreSyncCount == 1)
#expect(context.selectionStoreSyncCount == 1)
#expect(context.markedReadPeers == [peerID])
// Established session: no second handshake.
context.noiseSessionStates[peerID] = .established
coordinator.startPrivateChat(with: peerID)
#expect(context.triggeredHandshakes == [peerID])
}
@Test @MainActor
func startPrivateChat_blockedPeerOnlyGetsSystemMessage() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.blockedPeers = [peerID]
coordinator.startPrivateChat(with: peerID)
#expect(context.systemMessages.count == 1)
#expect(context.begunChatSessions.isEmpty)
#expect(context.consolidatedPeers.isEmpty)
#expect(context.markedReadPeers.isEmpty)
}
@Test @MainActor
func updatePrivateChatPeerIfNeeded_migratesChatStateByFingerprint() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let oldPeerID = PeerID(str: "1111111111111111")
let newPeerID = PeerID(str: "2222222222222222")
context.selectedPrivateChatFingerprint = "fp"
context.selectedPrivateChatPeer = oldPeerID
context.connectedPeers = [newPeerID]
context.fingerprintsByPeerID[newPeerID] = "fp"
let earlier = makePrivateMessage(id: "m1", timestamp: Date(timeIntervalSince1970: 1))
let later = makePrivateMessage(id: "m2", timestamp: Date(timeIntervalSince1970: 2))
context.privateChats[oldPeerID] = [later]
context.privateChats[newPeerID] = [earlier, later] // duplicate id "m2"
context.unreadPrivateMessages = [oldPeerID]
coordinator.updatePrivateChatPeerIfNeeded()
// Old chat is merged into the new peer's chat, deduplicated by id and
// sorted by timestamp; old keys are dropped.
#expect(context.privateChats[oldPeerID] == nil)
#expect(context.privateChats[newPeerID]?.map(\.id) == ["m1", "m2"])
#expect(context.selectedPrivateChatPeer == newPeerID)
// Unread moved to the new peer, then cleared for the now-open chat.
#expect(context.unreadPrivateMessages.isEmpty)
}
@Test @MainActor
func getEncryptionStatus_computesVerifiedStatusAndCachesIt() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
// Unknown peer with no fingerprint or session: no handshake yet.
#expect(coordinator.getEncryptionStatus(for: peerID) == .noHandshake)
#expect(context.cachedEncryptionStatuses[peerID] == .noHandshake)
// Cache hit short-circuits recomputation.
context.noiseSessionStates[peerID] = .established
#expect(coordinator.getEncryptionStatus(for: peerID) == .noHandshake)
// After invalidation, an established session with a verified
// fingerprint resolves (and re-caches) as verified.
coordinator.invalidateEncryptionCache(for: peerID)
context.fingerprintsByPeerID[peerID] = "fp"
context.verifiedFingerprintSet = ["fp"]
#expect(coordinator.getEncryptionStatus(for: peerID) == .noiseVerified)
#expect(context.cachedEncryptionStatuses[peerID] == .noiseVerified)
// updateEncryptionStatus publishes to the store and invalidates the cache.
context.establishedNoiseSessions = [peerID]
coordinator.updateEncryptionStatus(for: peerID)
#expect(context.encryptionStatuses[peerID] == .noiseVerified)
#expect(context.cachedEncryptionStatuses[peerID] == nil)
}
@Test @MainActor
func resolveNickname_walksMeshIdentityAndAnonFallbacks() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let meshPeer = PeerID(str: "aabbccddeeff0011")
let identityPeer = PeerID(str: "1234567890abcdef")
let unknownPeer = PeerID(str: "feedfacefeedface")
context.nicknamesByPeerID[meshPeer] = "alice"
#expect(coordinator.resolveNickname(for: meshPeer) == "alice")
context.fingerprintsByPeerID[identityPeer] = "fp"
context.socialIdentitiesByFingerprint["fp"] = SocialIdentity(
fingerprint: "fp",
localPetname: "bob!",
claimedNickname: "bob",
trustLevel: .casual,
isFavorite: false,
isBlocked: false,
notes: nil
)
#expect(coordinator.resolveNickname(for: identityPeer) == "bob!")
#expect(coordinator.resolveNickname(for: unknownPeer) == "anonfeed")
#expect(coordinator.getMyFingerprint() == "my-fingerprint")
}
@Test @MainActor
func getPeerIDForNickname_inGeohashChannel_registersNostrMapping() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let pubkey = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".lowercased()
context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy"))
context.geohashPeople = [GeoPerson(id: pubkey, displayName: "alice#6789", lastSeen: Date())]
context.geoNicknames[pubkey] = "alice"
// Suffixed display-name match.
let bySuffix = coordinator.getPeerIDForNickname("alice#6789")
#expect(bySuffix == PeerID(nostr_: pubkey))
// Base-nickname match via geoNicknames.
let byBase = coordinator.getPeerIDForNickname("ALICE")
#expect(byBase == PeerID(nostr_: pubkey))
#expect(context.registeredNostrKeyMappings.count == 2)
#expect(context.registeredNostrKeyMappings.allSatisfy { $0.pubkey == pubkey })
// Mesh channel falls through to the unified peer service.
context.activeChannel = .mesh
let meshPeer = PeerID(str: "1122334455667788")
context.peerIDsByNickname["carol"] = meshPeer
#expect(coordinator.getPeerIDForNickname("carol") == meshPeer)
}
}
@@ -0,0 +1,331 @@
//
// ChatTransportEventCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatTransportEventCoordinator` against a mock
// `ChatTransportEventContext` proving the coordinator works without a
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Scope note: the coordinator hops every event onto the main actor via an
// internal `Task`; tests drain those tasks with `Task.yield()`. All flows are
// mockable no singletons are involved at this layer.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatTransportEventContext` proving that
/// `ChatTransportEventCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatTransportEventContext: ChatTransportEventContext {
// Connection & chat state
var isConnected = false
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
var privateChats: [PeerID: [BitchatMessage]] = [:]
var unreadPrivateMessages: Set<PeerID> = []
var selectedPrivateChatPeer: PeerID?
private(set) var unmarkedReadReceiptBatches: [[String]] = []
private(set) var notifyUIChangedCount = 0
func unmarkReadReceiptsSent(_ ids: [String]) {
unmarkedReadReceiptBatches.append(ids)
}
func notifyUIChanged() {
notifyUIChangedCount += 1
}
// Inbound message handling
var blockedMessageIDs: Set<String> = []
private(set) var handledPrivateMessages: [BitchatMessage] = []
private(set) var handledPublicMessages: [BitchatMessage] = []
private(set) var mentionCheckedMessageIDs: [String] = []
private(set) var hapticMessageIDs: [String] = []
func isMessageBlocked(_ message: BitchatMessage) -> Bool {
blockedMessageIDs.contains(message.id)
}
func handlePrivateMessage(_ message: BitchatMessage) {
handledPrivateMessages.append(message)
}
func handlePublicMessage(_ message: BitchatMessage) {
handledPublicMessages.append(message)
}
func checkForMentions(_ message: BitchatMessage) {
mentionCheckedMessageIDs.append(message.id)
}
func sendHapticFeedback(for message: BitchatMessage) {
hapticMessageIDs.append(message.id)
}
func parseMentions(from content: String) -> [String] {
content.contains("@me") ? ["me"] : []
}
// Peer identity & sessions
var blockedPeers: Set<PeerID> = []
var peersByID: [PeerID: BitchatPeer] = [:]
var noiseSessionKeysByPeerID: [PeerID: Data] = [:]
private(set) var stablePeerIDCache: [PeerID: PeerID] = [:]
private(set) var registeredEphemeralSessions: [PeerID] = []
private(set) var removedEphemeralSessions: [PeerID] = []
func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { peersByID[peerID] }
func resolveNickname(for peerID: PeerID) -> String { "anon\(peerID.id.prefix(4))" }
func registerEphemeralSession(peerID: PeerID) { registeredEphemeralSessions.append(peerID) }
func removeEphemeralSession(peerID: PeerID) { removedEphemeralSessions.append(peerID) }
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { noiseSessionKeysByPeerID[peerID] }
func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID) {
stablePeerIDCache[shortPeerID] = stablePeerID
}
func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? { stablePeerIDCache[shortPeerID] }
// Routing & acknowledgements
private(set) var flushedOutboxPeerIDs: [PeerID] = []
private(set) var meshDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
func flushRouterOutbox(for peerID: PeerID) { flushedOutboxPeerIDs.append(peerID) }
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) {
meshDeliveryAcks.append((messageID, peerID))
}
// Delivery status
var applyMessageDeliveryStatusResult = true
var deliveryStatusesByMessageID: [String: DeliveryStatus] = [:]
private(set) var appliedDeliveryStatuses: [(messageID: String, status: DeliveryStatus)] = []
@discardableResult
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
appliedDeliveryStatuses.append((messageID, status))
return applyMessageDeliveryStatusResult
}
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
deliveryStatusesByMessageID[messageID]
}
// Verification payloads
private(set) var verifyChallengePayloads: [(peerID: PeerID, payload: Data)] = []
private(set) var verifyResponsePayloads: [(peerID: PeerID, payload: Data)] = []
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) {
verifyChallengePayloads.append((peerID, payload))
}
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) {
verifyResponsePayloads.append((peerID, payload))
}
}
// MARK: - Helpers
/// Lets the coordinator's internal `Task { @MainActor }` hops run.
@MainActor
private func drainMainActorTasks() async {
for _ in 0..<10 { await Task.yield() }
}
private func makeMessage(
id: String,
sender: String = "alice",
content: String = "hello",
isPrivate: Bool = false,
senderPeerID: PeerID? = nil
) -> BitchatMessage {
BitchatMessage(
id: id,
sender: sender,
content: content,
timestamp: Date(),
isRelay: false,
isPrivate: isPrivate,
recipientNickname: isPrivate ? "me" : nil,
senderPeerID: senderPeerID
)
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatTransportEventCoordinator` against
/// `MockChatTransportEventContext` with no `ChatViewModel`.
struct ChatTransportEventCoordinatorContextTests {
@Test @MainActor
func didReceiveMessage_routesPrivateAndPublic_skipsBlockedAndEmpty() async {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
// Blocked messages are dropped before any handling.
context.blockedMessageIDs = ["blocked"]
coordinator.didReceiveMessage(makeMessage(id: "blocked"))
// Empty public content is dropped too.
coordinator.didReceiveMessage(makeMessage(id: "empty", content: " "))
await drainMainActorTasks()
#expect(context.handledPublicMessages.isEmpty)
#expect(context.handledPrivateMessages.isEmpty)
#expect(context.mentionCheckedMessageIDs.isEmpty)
// Private goes to the private handler, public to the public handler;
// both get mention checks and haptics.
coordinator.didReceiveMessage(makeMessage(id: "pm", isPrivate: true))
coordinator.didReceiveMessage(makeMessage(id: "pub"))
await drainMainActorTasks()
#expect(context.handledPrivateMessages.map(\.id) == ["pm"])
#expect(context.handledPublicMessages.map(\.id) == ["pub"])
#expect(context.mentionCheckedMessageIDs == ["pm", "pub"])
#expect(context.hapticMessageIDs == ["pm", "pub"])
}
@Test @MainActor
func didReceivePublicMessage_trimsContentAndParsesMentions() async {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "aabbccdd00112233")
coordinator.didReceivePublicMessage(
from: peerID,
nickname: "alice",
content: " hi @me ",
timestamp: Date(),
messageID: "m1"
)
await drainMainActorTasks()
#expect(context.handledPublicMessages.count == 1)
let message = context.handledPublicMessages[0]
#expect(message.content == "hi @me")
#expect(message.mentions == ["me"])
#expect(message.senderPeerID == peerID)
#expect(context.hapticMessageIDs == ["m1"])
}
@Test @MainActor
func didConnectAndDisconnect_manageSessionsStableIDsAndReadReceipts() async {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
let noiseKey = Data(repeating: 0xAB, count: 32)
context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")
coordinator.didConnectToPeer(peerID)
await drainMainActorTasks()
#expect(context.isConnected)
#expect(context.registeredEphemeralSessions == [peerID])
#expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey))
#expect(context.flushedOutboxPeerIDs == [peerID])
#expect(context.notifyUIChangedCount == 1)
// Their messages' read receipts are un-marked on disconnect so READ
// acks can be re-sent after reconnect; our own messages are not.
context.privateChats[peerID] = [
makeMessage(id: "theirs-1", isPrivate: true, senderPeerID: peerID),
makeMessage(id: "mine-1", sender: "me", isPrivate: true, senderPeerID: context.myPeerID),
makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID),
]
coordinator.didDisconnectFromPeer(peerID)
await drainMainActorTasks()
#expect(context.removedEphemeralSessions == [peerID])
#expect(context.unmarkedReadReceiptBatches == [["theirs-1", "theirs-2"]])
#expect(context.notifyUIChangedCount == 2)
}
@Test @MainActor
func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
let noiseKey = Data(repeating: 0xCD, count: 32)
let stablePeerID = PeerID(hexData: noiseKey)
// No cached stable ID: it must be derived from the Noise session key.
context.noiseSessionKeysByPeerID[peerID] = noiseKey
context.selectedPrivateChatPeer = peerID
context.unreadPrivateMessages = [peerID]
context.privateChats[peerID] = [
makeMessage(id: "m1", isPrivate: true, senderPeerID: peerID),
makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID),
]
coordinator.didDisconnectFromPeer(peerID)
await drainMainActorTasks()
#expect(context.privateChats[peerID] == nil)
#expect(context.privateChats[stablePeerID]?.map(\.id) == ["m1", "mine"])
// Sender IDs migrate to the stable peer ID, except our own.
#expect(context.privateChats[stablePeerID]?.first?.senderPeerID == stablePeerID)
#expect(context.privateChats[stablePeerID]?.last?.senderPeerID == context.myPeerID)
#expect(context.selectedPrivateChatPeer == stablePeerID)
#expect(context.unreadPrivateMessages == [stablePeerID])
#expect(context.stablePeerIDCache[peerID] == stablePeerID)
}
@Test @MainActor
func noisePayloads_driveDeliveryStatusAcksAndVerification() async {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "99aabbccddeeff00")
let noiseKey = Data(repeating: 0x44, count: 32)
context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")
// Inbound private message: decoded, handled, and delivery-acked.
let packet = PrivateMessagePacket(messageID: "pm-1", content: "hi there")
coordinator.didReceiveNoisePayload(
from: peerID,
type: .privateMessage,
payload: packet.encode() ?? Data(),
timestamp: Date()
)
await drainMainActorTasks()
#expect(context.handledPrivateMessages.map(\.id) == ["pm-1"])
#expect(context.handledPrivateMessages.first?.sender == "alice")
#expect(context.meshDeliveryAcks.count == 1)
#expect(context.meshDeliveryAcks.first?.messageID == "pm-1")
// Delivered / read acks resolve the display name from the unified peer.
coordinator.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data("m-1".utf8), timestamp: Date())
coordinator.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data("m-2".utf8), timestamp: Date())
await drainMainActorTasks()
#expect(context.appliedDeliveryStatuses.count == 2)
#expect(context.appliedDeliveryStatuses[0].messageID == "m-1")
if case .delivered(let to, _) = context.appliedDeliveryStatuses[0].status {
#expect(to == "alice")
} else {
Issue.record("expected .delivered status")
}
if case .read(let by, _) = context.appliedDeliveryStatuses[1].status {
#expect(by == "alice")
} else {
Issue.record("expected .read status")
}
// Verification payloads are forwarded untouched.
coordinator.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data([0x01]), timestamp: Date())
coordinator.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data([0x02]), timestamp: Date())
await drainMainActorTasks()
#expect(context.verifyChallengePayloads.count == 1)
#expect(context.verifyResponsePayloads.count == 1)
// Blocked peers' private messages are dropped (no handling, no ack).
context.blockedPeers = [peerID]
coordinator.didReceiveNoisePayload(
from: peerID,
type: .privateMessage,
payload: packet.encode() ?? Data(),
timestamp: Date()
)
await drainMainActorTasks()
#expect(context.handledPrivateMessages.count == 1)
#expect(context.meshDeliveryAcks.count == 1)
}
}
@@ -0,0 +1,284 @@
//
// ChatVerificationCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatVerificationCoordinator` against a mock
// `ChatVerificationContext` proving the coordinator works without a
// `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).
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatVerificationContext` proving that
/// `ChatVerificationCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatVerificationContext: ChatVerificationContext {
// Fingerprints & verification state
var fingerprintsByPeerID: [PeerID: String] = [:]
var verifiedFingerprints: Set<String> = []
var persistedFingerprints: Set<String> = []
private(set) var identityVerifiedCalls: [(fingerprint: String, verified: Bool)] = []
private(set) var storedVerifiedCalls: [(fingerprint: String, verified: Bool)] = []
private(set) var saveIdentityStateCount = 0
func getFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] }
func persistedVerifiedFingerprints() -> Set<String> { persistedFingerprints }
func setIdentityVerified(fingerprint: String, verified: Bool) {
identityVerifiedCalls.append((fingerprint, verified))
}
func setStoredVerified(_ fingerprint: String, verified: Bool) {
storedVerifiedCalls.append((fingerprint, verified))
}
func isVerifiedFingerprint(_ fingerprint: String) -> Bool {
verifiedFingerprints.contains(fingerprint)
}
func saveIdentityState() { saveIdentityStateCount += 1 }
// Encryption status
private(set) var encryptionStatuses: [PeerID: EncryptionStatus?] = [:]
private(set) var updatedEncryptionStatusPeers: [PeerID] = []
private(set) var invalidatedEncryptionCachePeers: [PeerID?] = []
private(set) var notifyUIChangedCount = 0
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) {
encryptionStatuses[peerID] = status
}
func updateEncryptionStatus(for peerID: PeerID) {
updatedEncryptionStatusPeers.append(peerID)
}
func invalidateEncryptionCache(for peerID: PeerID?) {
invalidatedEncryptionCachePeers.append(peerID)
}
func notifyUIChanged() { notifyUIChangedCount += 1 }
// Peers
var unifiedPeers: [BitchatPeer] = []
var unifiedFavorites: [BitchatPeer] = []
private(set) var stablePeerIDCache: [PeerID: PeerID] = [:]
func unifiedPeer(for peerID: PeerID) -> BitchatPeer? {
unifiedPeers.first { $0.peerID == peerID }
}
func unifiedFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] }
func resolveNickname(for peerID: PeerID) -> String { "anon\(peerID.id.prefix(4))" }
func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? { stablePeerIDCache[shortPeerID] }
func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID) {
stablePeerIDCache[shortPeerID] = stablePeerID
}
// Noise sessions & verification transport
var myNoiseStaticKey = Data(repeating: 0x42, count: 32)
var establishedNoiseSessions: Set<PeerID> = []
var noiseSessionKeysByPeerID: [PeerID: Data] = [:]
private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)?
private(set) var triggeredHandshakes: [PeerID] = []
private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
func installNoiseSessionCallbacks(
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
onHandshakeRequired: @escaping (PeerID) -> Void
) {
installedCallbacks = (onPeerAuthenticated, onHandshakeRequired)
}
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { noiseSessionKeysByPeerID[peerID] }
func noiseStaticPublicKeyData() -> Data { myNoiseStaticKey }
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool {
establishedNoiseSessions.contains(peerID)
}
func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) }
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
sentChallenges.append((peerID, noiseKeyHex, nonceA))
}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
sentResponses.append((peerID, noiseKeyHex, nonceA))
}
}
// MARK: - Helpers
/// Builds the raw verify-challenge TLV as it arrives at the coordinator
/// (i.e. with the `NoisePayload` type byte already stripped).
private func makeVerifyChallengeTLV(noiseKeyHex: String, nonceA: Data) -> Data {
var tlv = Data()
tlv.append(0x01)
tlv.append(UInt8(noiseKeyHex.count))
tlv.append(Data(noiseKeyHex.utf8))
tlv.append(0x02)
tlv.append(UInt8(nonceA.count))
tlv.append(nonceA)
return tlv
}
private func makeVerificationQR(noiseKeyHex: String) -> VerificationService.VerificationQR {
VerificationService.VerificationQR(
v: 1,
noiseKeyHex: noiseKeyHex,
signKeyHex: "00" + String(repeating: "ab", count: 31),
npub: nil,
nickname: "alice",
ts: 0,
nonceB64: "",
sigHex: ""
)
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatVerificationCoordinator` against
/// `MockChatVerificationContext` with no `ChatViewModel`.
struct ChatVerificationCoordinatorContextTests {
@Test @MainActor
func verifyAndUnverifyFingerprint_updateBothStoresAndStatus() async {
let context = MockChatVerificationContext()
let coordinator = ChatVerificationCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
// Unknown fingerprint: nothing happens.
coordinator.verifyFingerprint(for: peerID)
#expect(context.identityVerifiedCalls.isEmpty)
context.fingerprintsByPeerID[peerID] = "fp"
coordinator.verifyFingerprint(for: peerID)
coordinator.unverifyFingerprint(for: peerID)
#expect(context.identityVerifiedCalls.map(\.fingerprint) == ["fp", "fp"])
#expect(context.identityVerifiedCalls.map(\.verified) == [true, false])
#expect(context.storedVerifiedCalls.map(\.verified) == [true, false])
#expect(context.saveIdentityStateCount == 2)
#expect(context.updatedEncryptionStatusPeers == [peerID, peerID])
}
@Test @MainActor
func beginQRVerification_sendsChallengeOrTriggersHandshake() async {
let context = MockChatVerificationContext()
let coordinator = ChatVerificationCoordinator(context: context)
let noiseKey = Data(repeating: 0xCD, count: 32)
let peerID = PeerID(str: "1122334455667788")
let qr = makeVerificationQR(noiseKeyHex: noiseKey.hexEncodedString())
// No matching peer -> not started.
#expect(!coordinator.beginQRVerification(with: qr))
// Matching peer without an established session -> handshake first.
context.unifiedPeers = [BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")]
#expect(coordinator.beginQRVerification(with: qr))
#expect(context.triggeredHandshakes == [peerID])
#expect(context.sentChallenges.isEmpty)
// Already pending -> short-circuits without re-triggering.
#expect(coordinator.beginQRVerification(with: qr))
#expect(context.triggeredHandshakes == [peerID])
// Fresh coordinator with an established session -> immediate challenge.
let context2 = MockChatVerificationContext()
context2.unifiedPeers = [BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")]
context2.establishedNoiseSessions = [peerID]
let coordinator2 = ChatVerificationCoordinator(context: context2)
#expect(coordinator2.beginQRVerification(with: qr))
#expect(context2.sentChallenges.count == 1)
#expect(context2.sentChallenges.first?.noiseKeyHex == qr.noiseKeyHex)
#expect(context2.triggeredHandshakes.isEmpty)
}
@Test @MainActor
func handleVerifyChallengePayload_respondsOncePerNonceForOurKeyOnly() async {
let context = MockChatVerificationContext()
let coordinator = ChatVerificationCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
let myHex = context.myNoiseStaticKey.hexEncodedString()
let nonce = Data(repeating: 0x07, count: 16)
let payload = makeVerifyChallengeTLV(noiseKeyHex: myHex, nonceA: nonce)
coordinator.handleVerifyChallengePayload(from: peerID, payload: payload)
#expect(context.sentResponses.count == 1)
#expect(context.sentResponses.first?.noiseKeyHex.lowercased() == myHex)
#expect(context.sentResponses.first?.nonceA == nonce)
// Same nonce again: deduplicated, no second response.
coordinator.handleVerifyChallengePayload(from: peerID, payload: payload)
#expect(context.sentResponses.count == 1)
// A challenge for someone else's key is ignored.
let otherHex = Data(repeating: 0x99, count: 32).hexEncodedString()
let otherPayload = makeVerifyChallengeTLV(
noiseKeyHex: otherHex,
nonceA: Data(repeating: 0x08, count: 16)
)
coordinator.handleVerifyChallengePayload(from: peerID, payload: otherPayload)
#expect(context.sentResponses.count == 1)
}
@Test @MainActor
func loadVerifiedFingerprints_syncsPersistedSetAndRefreshesUI() async {
let context = MockChatVerificationContext()
let coordinator = ChatVerificationCoordinator(context: context)
context.persistedFingerprints = ["fp1", "fp2"]
coordinator.loadVerifiedFingerprints()
#expect(context.verifiedFingerprints == ["fp1", "fp2"])
#expect(context.invalidatedEncryptionCachePeers == [nil])
#expect(context.notifyUIChangedCount == 1)
}
@Test @MainActor
func installedNoiseCallbacks_publishStatusAndStableIDs() async {
let context = MockChatVerificationContext()
let coordinator = ChatVerificationCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
let noiseKey = Data(repeating: 0x33, count: 32)
context.noiseSessionKeysByPeerID[peerID] = noiseKey
context.verifiedFingerprints = ["fp-verified"]
coordinator.setupNoiseCallbacks()
let callbacks = try? #require(context.installedCallbacks)
// Authenticated with a verified fingerprint -> verified status and a
// cached stable peer ID derived from the session key.
callbacks?.onPeerAuthenticated(peerID, "fp-verified")
await waitForMainQueue()
#expect(context.encryptionStatuses[peerID] == .noiseVerified)
#expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey))
#expect(context.invalidatedEncryptionCachePeers.contains(peerID))
// Handshake required -> handshaking status.
callbacks?.onHandshakeRequired(peerID)
await waitForMainQueue()
#expect(context.encryptionStatuses[peerID] == .noiseHandshaking)
}
}
/// The installed callbacks hop through `DispatchQueue.main.async`; tests must
/// let that queue drain before asserting.
@MainActor
private func waitForMainQueue() async {
await withCheckedContinuation { continuation in
DispatchQueue.main.async { continuation.resume() }
}
}