mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:05:20 +00:00
Merge pull request #896 from permissionlesstech/refactor/chatviewmodel-testability
Add ChatViewModel testability infrastructure and unit tests
This commit is contained in:
@@ -59,11 +59,28 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
private var resolvingNames: Set<String> = []
|
private var resolvingNames: Set<String> = []
|
||||||
private let storage: UserDefaults
|
private let storage: UserDefaults
|
||||||
|
|
||||||
|
/// Returns true if running in test environment
|
||||||
|
private static var isRunningTests: Bool {
|
||||||
|
let env = ProcessInfo.processInfo.environment
|
||||||
|
return NSClassFromString("XCTestCase") != nil ||
|
||||||
|
env["XCTestConfigurationFilePath"] != nil ||
|
||||||
|
env["XCTestBundlePath"] != nil ||
|
||||||
|
env["GITHUB_ACTIONS"] != nil ||
|
||||||
|
env["CI"] != nil
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
private override init() {
|
private override init() {
|
||||||
self.storage = .standard
|
self.storage = .standard
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
|
// Skip CoreLocation setup in test environments
|
||||||
|
guard !Self.isRunningTests else {
|
||||||
|
loadPersistedState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
cl.delegate = self
|
cl.delegate = self
|
||||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
||||||
|
|||||||
@@ -17,9 +17,20 @@ import AppKit
|
|||||||
final class NotificationService {
|
final class NotificationService {
|
||||||
static let shared = NotificationService()
|
static let shared = NotificationService()
|
||||||
|
|
||||||
|
/// Returns true if running in test environment (XCTest, Swift Testing, or CI)
|
||||||
|
private var isRunningTests: Bool {
|
||||||
|
let env = ProcessInfo.processInfo.environment
|
||||||
|
return NSClassFromString("XCTestCase") != nil ||
|
||||||
|
env["XCTestConfigurationFilePath"] != nil ||
|
||||||
|
env["XCTestBundlePath"] != nil ||
|
||||||
|
env["GITHUB_ACTIONS"] != nil ||
|
||||||
|
env["CI"] != nil
|
||||||
|
}
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
func requestAuthorization() {
|
func requestAuthorization() {
|
||||||
|
guard !isRunningTests else { return }
|
||||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||||
if granted {
|
if granted {
|
||||||
// Permission granted
|
// Permission granted
|
||||||
@@ -36,6 +47,7 @@ final class NotificationService {
|
|||||||
userInfo: [String: Any]? = nil,
|
userInfo: [String: Any]? = nil,
|
||||||
interruptionLevel: UNNotificationInterruptionLevel = .active
|
interruptionLevel: UNNotificationInterruptionLevel = .active
|
||||||
) {
|
) {
|
||||||
|
guard !isRunningTests else { return }
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = title
|
content.title = title
|
||||||
content.body = body
|
content.body = body
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
private let privateChatManager: PrivateChatManager
|
private let privateChatManager: PrivateChatManager
|
||||||
private let unifiedPeerService: UnifiedPeerService
|
private let unifiedPeerService: UnifiedPeerService
|
||||||
private let autocompleteService: AutocompleteService
|
private let autocompleteService: AutocompleteService
|
||||||
private let deduplicationService: MessageDeduplicationService
|
let deduplicationService: MessageDeduplicationService // internal for test access
|
||||||
|
|
||||||
// Computed properties for compatibility
|
// Computed properties for compatibility
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -419,15 +419,32 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
init(
|
convenience init(
|
||||||
keychain: KeychainManagerProtocol,
|
keychain: KeychainManagerProtocol,
|
||||||
idBridge: NostrIdentityBridge,
|
idBridge: NostrIdentityBridge,
|
||||||
identityManager: SecureIdentityStateManagerProtocol
|
identityManager: SecureIdentityStateManagerProtocol
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: identityManager,
|
||||||
|
transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Testable initializer that accepts a Transport dependency.
|
||||||
|
/// Use this initializer for unit testing with MockTransport.
|
||||||
|
@MainActor
|
||||||
|
init(
|
||||||
|
keychain: KeychainManagerProtocol,
|
||||||
|
idBridge: NostrIdentityBridge,
|
||||||
|
identityManager: SecureIdentityStateManagerProtocol,
|
||||||
|
transport: Transport
|
||||||
) {
|
) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
self.meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
|
self.meshService = transport
|
||||||
self.publicMessagePipeline = PublicMessagePipeline()
|
self.publicMessagePipeline = PublicMessagePipeline()
|
||||||
|
|
||||||
// Load persisted read receipts
|
// Load persisted read receipts
|
||||||
@@ -616,7 +633,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
// Request notification permission
|
// Request notification permission (guards test environment internally)
|
||||||
NotificationService.shared.requestAuthorization()
|
NotificationService.shared.requestAuthorization()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
//
|
||||||
|
// ChatViewModelTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// Tests for ChatViewModel using MockTransport for isolation.
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
// MARK: - Test Helpers
|
||||||
|
|
||||||
|
/// Creates a ChatViewModel with mock dependencies for testing
|
||||||
|
@MainActor
|
||||||
|
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let keychainHelper = MockKeychainHelper()
|
||||||
|
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||||
|
let identityManager = MockIdentityManager(keychain)
|
||||||
|
let transport = MockTransport()
|
||||||
|
|
||||||
|
let viewModel = ChatViewModel(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: identityManager,
|
||||||
|
transport: transport
|
||||||
|
)
|
||||||
|
|
||||||
|
return (viewModel, transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Initialization Tests
|
||||||
|
|
||||||
|
struct ChatViewModelInitializationTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func initialization_setsDelegate() async {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
|
||||||
|
// The viewModel should set itself as the transport delegate
|
||||||
|
#expect(transport.delegate === viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func initialization_startsServices() async {
|
||||||
|
let (_, transport) = makeTestableViewModel()
|
||||||
|
|
||||||
|
// Services should be started during init
|
||||||
|
#expect(transport.startServicesCallCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func initialization_hasEmptyMessageList() async {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
|
||||||
|
// Initial messages may include system messages, but should be limited
|
||||||
|
#expect(viewModel.messages.count < 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func initialization_setsNickname() async {
|
||||||
|
let (_, transport) = makeTestableViewModel()
|
||||||
|
|
||||||
|
// Nickname should be set during init
|
||||||
|
#expect(!transport.myNickname.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Message Sending Tests
|
||||||
|
|
||||||
|
struct ChatViewModelSendingTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func sendMessage_delegatesToTransport() async {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
|
||||||
|
viewModel.sendMessage("Hello World")
|
||||||
|
|
||||||
|
#expect(transport.sentMessages.count == 1)
|
||||||
|
#expect(transport.sentMessages.first?.content == "Hello World")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func sendMessage_emptyContent_ignored() async {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
|
||||||
|
viewModel.sendMessage("")
|
||||||
|
viewModel.sendMessage(" ")
|
||||||
|
viewModel.sendMessage("\n\t")
|
||||||
|
|
||||||
|
#expect(transport.sentMessages.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func sendMessage_withMentions_sendsContent() async {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
|
||||||
|
viewModel.sendMessage("Hello @alice")
|
||||||
|
|
||||||
|
#expect(transport.sentMessages.count == 1)
|
||||||
|
#expect(transport.sentMessages.first?.content == "Hello @alice")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func sendMessage_command_notSentToTransport() async {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
|
||||||
|
viewModel.sendMessage("/help")
|
||||||
|
|
||||||
|
// Commands are processed locally, not sent to transport
|
||||||
|
#expect(transport.sentMessages.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Message Receiving Tests
|
||||||
|
|
||||||
|
struct ChatViewModelReceivingTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func didReceiveMessage_callsDelegate() async {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: "msg-001",
|
||||||
|
sender: "Alice",
|
||||||
|
content: "Hello from Alice",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: false,
|
||||||
|
recipientNickname: nil,
|
||||||
|
senderPeerID: PeerID(str: "PEER001"),
|
||||||
|
mentions: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
transport.simulateIncomingMessage(message)
|
||||||
|
|
||||||
|
// Give time for Task and pipeline processing
|
||||||
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
|
|
||||||
|
// Message may or may not appear due to rate limiting/pipeline batching
|
||||||
|
// The important thing is no crash and delegate was called
|
||||||
|
#expect(transport.delegate != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func didReceivePublicMessage_addsToTimeline() async {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
|
||||||
|
transport.simulateIncomingPublicMessage(
|
||||||
|
from: PeerID(str: "PEER002"),
|
||||||
|
nickname: "Bob",
|
||||||
|
content: "Public hello from Bob",
|
||||||
|
timestamp: Date(),
|
||||||
|
messageID: "pub-001"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Give time for async Task and pipeline processing
|
||||||
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
|
|
||||||
|
#expect(viewModel.messages.contains { $0.content == "Public hello from Bob" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Peer Connection Tests
|
||||||
|
|
||||||
|
struct ChatViewModelPeerTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func didConnectToPeer_notifiesDelegate() async {
|
||||||
|
let (_, transport) = makeTestableViewModel()
|
||||||
|
let peerID = PeerID(str: "NEWPEER")
|
||||||
|
|
||||||
|
transport.simulateConnect(peerID, nickname: "NewUser")
|
||||||
|
|
||||||
|
#expect(transport.connectedPeers.contains(peerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func didDisconnectFromPeer_notifiesDelegate() async {
|
||||||
|
let (_, transport) = makeTestableViewModel()
|
||||||
|
let peerID = PeerID(str: "OLDPEER")
|
||||||
|
|
||||||
|
transport.simulateConnect(peerID, nickname: "OldUser")
|
||||||
|
transport.simulateDisconnect(peerID)
|
||||||
|
|
||||||
|
#expect(!transport.connectedPeers.contains(peerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func isPeerConnected_delegatesToTransport() async {
|
||||||
|
let (_, transport) = makeTestableViewModel()
|
||||||
|
let peerID = PeerID(str: "TESTPEER")
|
||||||
|
|
||||||
|
// Not connected initially
|
||||||
|
#expect(!transport.isPeerConnected(peerID))
|
||||||
|
|
||||||
|
transport.connectedPeers.insert(peerID)
|
||||||
|
|
||||||
|
#expect(transport.isPeerConnected(peerID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Deduplication Integration Tests
|
||||||
|
//
|
||||||
|
// Note: Detailed deduplication logic is tested in MessageDeduplicationServiceTests.
|
||||||
|
// These tests verify that ChatViewModel has a deduplication service configured.
|
||||||
|
|
||||||
|
struct ChatViewModelDeduplicationTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deduplicationService_isConfigured() async {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
|
||||||
|
// Verify the deduplication service is available and functional
|
||||||
|
// by checking that we can record and query content
|
||||||
|
let testContent = "Test dedup content \(UUID().uuidString)"
|
||||||
|
let testDate = Date()
|
||||||
|
|
||||||
|
viewModel.deduplicationService.recordContent(testContent, timestamp: testDate)
|
||||||
|
|
||||||
|
let retrieved = viewModel.deduplicationService.contentTimestamp(for: testContent)
|
||||||
|
#expect(retrieved == testDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deduplicationService_normalizedKey_consistent() async {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
|
||||||
|
let content = "Hello World"
|
||||||
|
let key1 = viewModel.deduplicationService.normalizedContentKey(content)
|
||||||
|
let key2 = viewModel.deduplicationService.normalizedContentKey(content)
|
||||||
|
|
||||||
|
#expect(key1 == key2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Chat Tests
|
||||||
|
|
||||||
|
struct ChatViewModelPrivateChatTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func sendPrivateMessage_delegatesToTransport() async {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let recipientID = PeerID(str: "RECIPIENT")
|
||||||
|
|
||||||
|
// Set up connected peer for routing
|
||||||
|
transport.connectedPeers.insert(recipientID)
|
||||||
|
transport.peerNicknames[recipientID] = "Recipient"
|
||||||
|
|
||||||
|
viewModel.sendPrivateMessage("Secret message", to: recipientID)
|
||||||
|
|
||||||
|
// The message routing depends on connection state and other factors
|
||||||
|
// At minimum, it should not crash
|
||||||
|
#expect(true) // If we get here without crash, the test passes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bluetooth State Tests
|
||||||
|
|
||||||
|
struct ChatViewModelBluetoothTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func didUpdateBluetoothState_poweredOn_noAlert() async {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
|
||||||
|
transport.simulateBluetoothStateChange(.poweredOn)
|
||||||
|
|
||||||
|
// Give time for async processing
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
|
||||||
|
#expect(!viewModel.showBluetoothAlert)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func didUpdateBluetoothState_poweredOff_showsAlert() async {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
|
||||||
|
transport.simulateBluetoothStateChange(.poweredOff)
|
||||||
|
|
||||||
|
// Give time for async processing
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
|
||||||
|
#expect(viewModel.showBluetoothAlert)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func didUpdateBluetoothState_unauthorized_showsAlert() async {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
|
||||||
|
transport.simulateBluetoothStateChange(.unauthorized)
|
||||||
|
|
||||||
|
// Give time for async processing
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
|
||||||
|
#expect(viewModel.showBluetoothAlert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Panic Clear Tests
|
||||||
|
|
||||||
|
struct ChatViewModelPanicTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicClearAllData_delegatesToTransport() async {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
|
||||||
|
// Set up some state
|
||||||
|
transport.connectedPeers.insert(PeerID(str: "PEER1"))
|
||||||
|
|
||||||
|
viewModel.panicClearAllData()
|
||||||
|
|
||||||
|
// After panic, emergency disconnect should be called
|
||||||
|
#expect(transport.emergencyDisconnectCallCount == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Service Lifecycle Tests
|
||||||
|
|
||||||
|
struct ChatViewModelLifecycleTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func startServices_calledOnInit() async {
|
||||||
|
let (_, transport) = makeTestableViewModel()
|
||||||
|
|
||||||
|
#expect(transport.startServicesCallCount == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
//
|
||||||
|
// MockTransport.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// Mock Transport implementation for unit testing ChatViewModel.
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import CoreBluetooth
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
/// Mock Transport implementation for testing ChatViewModel in isolation.
|
||||||
|
/// Records all method calls and allows test code to verify interactions.
|
||||||
|
final class MockTransport: Transport {
|
||||||
|
|
||||||
|
// MARK: - Protocol Properties
|
||||||
|
|
||||||
|
weak var delegate: BitchatDelegate?
|
||||||
|
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||||
|
|
||||||
|
var myPeerID: PeerID = PeerID(str: "TESTPEER")
|
||||||
|
var myNickname: String = "TestUser"
|
||||||
|
|
||||||
|
private let peerSnapshotSubject = CurrentValueSubject<[TransportPeerSnapshot], Never>([])
|
||||||
|
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||||
|
peerSnapshotSubject.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recording Properties (for test assertions)
|
||||||
|
|
||||||
|
private(set) var sentMessages: [(content: String, mentions: [String], messageID: String?, timestamp: Date?)] = []
|
||||||
|
private(set) var sentPrivateMessages: [(content: String, peerID: PeerID, recipientNickname: String, messageID: String)] = []
|
||||||
|
private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = []
|
||||||
|
private(set) var sentDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
||||||
|
private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
|
||||||
|
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
|
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
|
private(set) var startServicesCallCount = 0
|
||||||
|
private(set) var stopServicesCallCount = 0
|
||||||
|
private(set) var emergencyDisconnectCallCount = 0
|
||||||
|
private(set) var broadcastAnnounceCallCount = 0
|
||||||
|
private(set) var triggeredHandshakes: [PeerID] = []
|
||||||
|
|
||||||
|
// MARK: - Configurable Mock State
|
||||||
|
|
||||||
|
var connectedPeers: Set<PeerID> = []
|
||||||
|
var reachablePeers: Set<PeerID> = []
|
||||||
|
var peerNicknames: [PeerID: String] = [:]
|
||||||
|
var peerFingerprints: [PeerID: String] = [:]
|
||||||
|
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
|
||||||
|
private let mockKeychain = MockKeychain()
|
||||||
|
|
||||||
|
// MARK: - Transport Protocol Implementation
|
||||||
|
|
||||||
|
func currentPeerSnapshots() -> [TransportPeerSnapshot] {
|
||||||
|
peerSnapshotSubject.value
|
||||||
|
}
|
||||||
|
|
||||||
|
func setNickname(_ nickname: String) {
|
||||||
|
myNickname = nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
func startServices() {
|
||||||
|
startServicesCallCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopServices() {
|
||||||
|
stopServicesCallCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func emergencyDisconnectAll() {
|
||||||
|
emergencyDisconnectCallCount += 1
|
||||||
|
connectedPeers.removeAll()
|
||||||
|
reachablePeers.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPeerConnected(_ peerID: PeerID) -> Bool {
|
||||||
|
connectedPeers.contains(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
||||||
|
reachablePeers.contains(peerID) || connectedPeers.contains(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func peerNickname(peerID: PeerID) -> String? {
|
||||||
|
peerNicknames[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPeerNicknames() -> [PeerID: String] {
|
||||||
|
peerNicknames
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFingerprint(for peerID: PeerID) -> String? {
|
||||||
|
peerFingerprints[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState {
|
||||||
|
peerNoiseStates[peerID] ?? .none
|
||||||
|
}
|
||||||
|
|
||||||
|
func triggerHandshake(with peerID: PeerID) {
|
||||||
|
triggeredHandshakes.append(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNoiseService() -> NoiseEncryptionService {
|
||||||
|
NoiseEncryptionService(keychain: mockKeychain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Messaging
|
||||||
|
|
||||||
|
func sendMessage(_ content: String, mentions: [String]) {
|
||||||
|
sentMessages.append((content, mentions, nil, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||||
|
sentMessages.append((content, mentions, messageID, timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
|
sentPrivateMessages.append((content, peerID, recipientNickname, messageID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
|
sentReadReceipts.append((receipt, peerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
|
sentFavoriteNotifications.append((peerID, isFavorite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendBroadcastAnnounce() {
|
||||||
|
broadcastAnnounceCallCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||||
|
sentDeliveryAcks.append((messageID, peerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
||||||
|
// Not tracked for current tests
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||||
|
// Not tracked for current tests
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelTransfer(_ transferId: String) {
|
||||||
|
// Not tracked for current tests
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||||
|
sentVerifyChallenges.append((peerID, noiseKeyHex, nonceA))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||||
|
sentVerifyResponses.append((peerID, noiseKeyHex, nonceA))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test Helpers
|
||||||
|
|
||||||
|
/// Clears all recorded method calls for fresh assertions
|
||||||
|
func resetRecordings() {
|
||||||
|
sentMessages.removeAll()
|
||||||
|
sentPrivateMessages.removeAll()
|
||||||
|
sentReadReceipts.removeAll()
|
||||||
|
sentDeliveryAcks.removeAll()
|
||||||
|
sentFavoriteNotifications.removeAll()
|
||||||
|
sentVerifyChallenges.removeAll()
|
||||||
|
sentVerifyResponses.removeAll()
|
||||||
|
startServicesCallCount = 0
|
||||||
|
stopServicesCallCount = 0
|
||||||
|
emergencyDisconnectCallCount = 0
|
||||||
|
broadcastAnnounceCallCount = 0
|
||||||
|
triggeredHandshakes.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simulates a peer connecting
|
||||||
|
func simulateConnect(_ peerID: PeerID, nickname: String? = nil) {
|
||||||
|
connectedPeers.insert(peerID)
|
||||||
|
if let nickname = nickname {
|
||||||
|
peerNicknames[peerID] = nickname
|
||||||
|
}
|
||||||
|
delegate?.didConnectToPeer(peerID)
|
||||||
|
delegate?.didUpdatePeerList(Array(connectedPeers))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simulates a peer disconnecting
|
||||||
|
func simulateDisconnect(_ peerID: PeerID) {
|
||||||
|
connectedPeers.remove(peerID)
|
||||||
|
peerNicknames.removeValue(forKey: peerID)
|
||||||
|
delegate?.didDisconnectFromPeer(peerID)
|
||||||
|
delegate?.didUpdatePeerList(Array(connectedPeers))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simulates receiving a message
|
||||||
|
func simulateIncomingMessage(_ message: BitchatMessage) {
|
||||||
|
delegate?.didReceiveMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simulates receiving a public message
|
||||||
|
func simulateIncomingPublicMessage(
|
||||||
|
from peerID: PeerID,
|
||||||
|
nickname: String,
|
||||||
|
content: String,
|
||||||
|
timestamp: Date = Date(),
|
||||||
|
messageID: String? = nil
|
||||||
|
) {
|
||||||
|
delegate?.didReceivePublicMessage(
|
||||||
|
from: peerID,
|
||||||
|
nickname: nickname,
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simulates Bluetooth state change
|
||||||
|
func simulateBluetoothStateChange(_ state: CBManagerState) {
|
||||||
|
delegate?.didUpdateBluetoothState(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the peer snapshot publisher
|
||||||
|
func updatePeerSnapshots(_ snapshots: [TransportPeerSnapshot]) {
|
||||||
|
peerSnapshotSubject.send(snapshots)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user