mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:05:19 +00:00
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>
284 lines
11 KiB
Swift
284 lines
11 KiB
Swift
//
|
|
// 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])
|
|
}
|
|
}
|