mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
Add comprehensive test coverage for ChatViewModel and BLEService (#962)
* Add comprehensive test coverage for ChatViewModel and BLEService This commit adds 14 new tests to improve the safety net for future refactoring of ChatViewModel and BLEService: New test files: - ChatViewModelTorTests: Tor lifecycle notification handlers (8 tests) - ChatViewModelDeliveryStatusTests: Delivery status state machine (6 tests) - ChatViewModelRefactoringTests: Command routing and message handling (4 tests) - BLEServiceCoreTests: Packet deduplication and stale broadcast filtering (2 tests) - PublicMessagePipelineTests: Message ordering and deduplication (4 tests) - MessageRouterTests: Transport selection and outbox behavior (4 tests) - PrivateChatManagerTests: Chat selection and read receipts (2 tests) - UnifiedPeerServiceTests: Fingerprint resolution and blocking (2 tests) - RelayControllerTests: TTL, handshake, and fragment relay logic (4 tests) Modified files: - MockTransport: Added peer snapshot publishing on connect/disconnect - TestHelpers: Added waitUntil polling helper for async tests - MockIdentityManager: Extended for new test scenarios Total: 454 tests across 64 suites (was 440) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix flaky test by using waitUntil instead of fixed sleep Replace fixed 200ms sleep with waitUntil helper for more reliable async assertion in routing tests. This prevents timing issues on CI. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
jack
Claude Opus 4.5
parent
194dedac43
commit
806c420313
@@ -0,0 +1,106 @@
|
||||
//
|
||||
// BLEServiceCoreTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Focused BLEService tests for packet handling behavior.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEServiceCoreTests {
|
||||
|
||||
@Test
|
||||
func duplicatePacket_isDeduped() async {
|
||||
let ble = makeService()
|
||||
let delegate = PublicCaptureDelegate()
|
||||
ble.delegate = delegate
|
||||
|
||||
let sender = PeerID(str: "1122334455667788")
|
||||
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
|
||||
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
|
||||
_ = await TestHelpers.waitUntil({ delegate.publicMessagesSnapshot().count == 1 },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
|
||||
let messages = delegate.publicMessagesSnapshot()
|
||||
#expect(messages.count == 1)
|
||||
#expect(messages.first?.content == "Hello")
|
||||
}
|
||||
|
||||
@Test
|
||||
func staleBroadcast_isIgnored() async {
|
||||
let ble = makeService()
|
||||
let delegate = PublicCaptureDelegate()
|
||||
ble.delegate = delegate
|
||||
|
||||
let sender = PeerID(str: "A1B2C3D4E5F60708")
|
||||
let oldTimestamp = UInt64(Date().addingTimeInterval(-901).timeIntervalSince1970 * 1000)
|
||||
let packet = makePublicPacket(content: "Old", sender: sender, timestamp: oldTimestamp)
|
||||
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
|
||||
let didReceive = await TestHelpers.waitUntil({ !delegate.publicMessagesSnapshot().isEmpty }, timeout: 0.3)
|
||||
#expect(!didReceive)
|
||||
#expect(delegate.publicMessagesSnapshot().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeService() -> BLEService {
|
||||
let keychain = MockKeychain()
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
return BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
|
||||
}
|
||||
|
||||
private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data(hexString: sender.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: Data(content.utf8),
|
||||
signature: nil,
|
||||
ttl: 3
|
||||
)
|
||||
}
|
||||
|
||||
private final class PublicCaptureDelegate: BitchatDelegate {
|
||||
private let lock = NSLock()
|
||||
private(set) var publicMessages: [BitchatMessage] = []
|
||||
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: nickname,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
mentions: nil
|
||||
)
|
||||
lock.lock()
|
||||
publicMessages.append(message)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func didReceiveMessage(_ message: BitchatMessage) {}
|
||||
func didConnectToPeer(_ peerID: PeerID) {}
|
||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||
|
||||
func publicMessagesSnapshot() -> [BitchatMessage] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return publicMessages
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//
|
||||
// ChatViewModelDeliveryStatusTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for ChatViewModel delivery status state machine.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
@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: - Delivery Status Tests
|
||||
|
||||
struct ChatViewModelDeliveryStatusTests {
|
||||
|
||||
// MARK: - Status Transition Tests
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_noDowngrade_readToDelivered() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "test-msg-1"
|
||||
|
||||
// Setup: create a message with .read status
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .read(by: "Peer", at: Date())
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
|
||||
// Action: try to downgrade to .delivered
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
||||
|
||||
// Assert: status should remain .read (no downgrade)
|
||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
||||
#expect({
|
||||
if case .read = currentStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_upgrade_sentToDelivered() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "test-msg-2"
|
||||
|
||||
// Setup: create a message with .sent status
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
|
||||
// Action: upgrade to .delivered
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
||||
|
||||
// Assert: status should be .delivered
|
||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
||||
#expect({
|
||||
if case .delivered = currentStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_upgrade_deliveredToRead() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "test-msg-3"
|
||||
|
||||
// Setup: create a message with .delivered status
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .delivered(to: "Peer", at: Date().addingTimeInterval(-60))
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
|
||||
// Action: upgrade to .read
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .read(by: "Peer", at: Date()))
|
||||
|
||||
// Assert: status should be .read
|
||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
||||
#expect({
|
||||
if case .read = currentStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
// MARK: - Read Receipt Handling
|
||||
|
||||
@Test @MainActor
|
||||
func didReceiveReadReceipt_updatesMessageStatus() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "test-msg-4"
|
||||
|
||||
// Setup: create a message with .sent status
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
|
||||
// Action: receive read receipt
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: messageID,
|
||||
readerID: peerID,
|
||||
readerNickname: "Peer"
|
||||
)
|
||||
viewModel.didReceiveReadReceipt(receipt)
|
||||
|
||||
// Assert: status should be .read
|
||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
||||
#expect({
|
||||
if case .read = currentStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
// MARK: - Public Timeline Status Tests
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_publicTimeline_updatesCorrectly() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let messageID = "public-msg-1"
|
||||
|
||||
// Setup: add a message to public timeline with .sending status
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Public message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: false,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
viewModel.messages.append(message)
|
||||
|
||||
// Action: update to .sent
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||
|
||||
// Assert
|
||||
let updatedMessage = viewModel.messages.first(where: { $0.id == messageID })
|
||||
#expect({
|
||||
if case .sent = updatedMessage?.deliveryStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
// MARK: - Status Rank Tests (for deduplication)
|
||||
|
||||
@Test @MainActor
|
||||
func statusRank_orderingIsCorrect() async {
|
||||
// This tests the implicit ordering used in refreshVisibleMessages
|
||||
// failed < sending < sent < partiallyDelivered < delivered < read
|
||||
|
||||
let statuses: [DeliveryStatus] = [
|
||||
.failed(reason: "test"),
|
||||
.sending,
|
||||
.sent,
|
||||
.partiallyDelivered(reached: 1, total: 3),
|
||||
.delivered(to: "B", at: Date()),
|
||||
.read(by: "C", at: Date())
|
||||
]
|
||||
|
||||
// Verify each status has a logical progression
|
||||
// This is more of a documentation test to ensure the ranking logic is understood
|
||||
for (index, status) in statuses.enumerated() {
|
||||
switch status {
|
||||
case .failed: #expect(index == 0)
|
||||
case .sending: #expect(index == 1)
|
||||
case .sent: #expect(index == 2)
|
||||
case .partiallyDelivered: #expect(index == 3)
|
||||
case .delivered: #expect(index == 4)
|
||||
case .read: #expect(index == 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,22 @@ struct ChatViewModelPrivateChatExtensionTests {
|
||||
// Check MockTransport implementation... it might need update or verification
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivateMessage_unreachable_setsFailedStatus() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10"
|
||||
let peerID = PeerID(str: validHex)
|
||||
|
||||
viewModel.sendPrivateMessage("Hello", to: peerID)
|
||||
|
||||
#expect(viewModel.privateChats[peerID]?.count == 1)
|
||||
let status = viewModel.privateChats[peerID]?.last?.deliveryStatus
|
||||
#expect({
|
||||
if case .failed = status { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handlePrivateMessage_storesMessage() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
@@ -250,10 +266,17 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
LocationChannelManager.shared.select(channel)
|
||||
defer { LocationChannelManager.shared.select(.mesh) }
|
||||
|
||||
_ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel })
|
||||
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel })
|
||||
|
||||
var event = NostrEvent(
|
||||
pubkey: "pub1",
|
||||
@@ -267,19 +290,119 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
|
||||
// Allow async processing
|
||||
let didAppend = await TestHelpers.waitUntil({
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
return viewModel.messages.contains { $0.content == "Hello Geo" }
|
||||
})
|
||||
#expect(didAppend)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleNostrEvent_ignoresRecentSelfEcho() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
|
||||
|
||||
var event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Self echo"
|
||||
)
|
||||
event.id = "evt-self"
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
|
||||
// Check timeline
|
||||
// This depends on `handlePublicMessage` being called and updating `messages`
|
||||
// Since `handlePublicMessage` delegates to `timelineStore` and updates `messages`...
|
||||
// And we are in the correct channel...
|
||||
#expect(!viewModel.messages.contains { $0.content == "Self echo" })
|
||||
}
|
||||
|
||||
// However, `handleNostrEvent` in the extension now calls `handlePublicMessage`.
|
||||
// Let's verify if the message appears.
|
||||
// Note: `handleNostrEvent` logic was refactored.
|
||||
// The new logic in `ChatViewModel+Nostr.swift` calls `handlePublicMessage`.
|
||||
@Test @MainActor
|
||||
func handleNostrEvent_skipsBlockedSender() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let blockedPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
|
||||
// We need to ensure `deduplicationService` doesn't block it (new instance, so empty).
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
viewModel.identityManager.setNostrBlocked(blockedPubkey, isBlocked: true)
|
||||
|
||||
var event = NostrEvent(
|
||||
pubkey: blockedPubkey,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Blocked"
|
||||
)
|
||||
event.id = "evt-blocked"
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
|
||||
#expect(!viewModel.messages.contains { $0.content == "Blocked" })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func switchLocationChannel_clearsNostrDedupCache() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.deduplicationService.recordNostrEvent("evt-cache")
|
||||
#expect(viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
#expect(!viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash Queue Tests
|
||||
|
||||
struct ChatViewModelGeohashQueueTests {
|
||||
|
||||
@Test @MainActor
|
||||
func addGeohashOnlySystemMessage_queuesUntilLocationChannel() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.addGeohashOnlySystemMessage("Queued system")
|
||||
#expect(!viewModel.messages.contains { $0.content == "Queued system" })
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
#expect(viewModel.messages.contains { $0.content == "Queued system" })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - GeoDM Tests
|
||||
|
||||
struct ChatViewModelGeoDMTests {
|
||||
|
||||
@Test @MainActor
|
||||
func handlePrivateMessage_geohash_dedupsAndTracksAck() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let senderPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
let messageID = "pm-1"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
|
||||
|
||||
let convKey = PeerID(nostr_: senderPubkey)
|
||||
let packet = PrivateMessagePacket(messageID: messageID, content: "Hello")
|
||||
let payloadData = try #require(packet.encode(), "Failed to encode private message")
|
||||
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
||||
|
||||
viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())
|
||||
viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())
|
||||
|
||||
#expect(viewModel.privateChats[convKey]?.count == 1)
|
||||
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// ChatViewModelRefactoringTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Pinning tests to characterize ChatViewModel behavior before refactoring.
|
||||
// These tests act as a safety net to ensure we don't break existing functionality.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct ChatViewModelRefactoringTests {
|
||||
|
||||
// Helper to setup the environment
|
||||
@MainActor
|
||||
private func makePinnedViewModel() -> (viewModel: ChatViewModel, transport: MockTransport, identity: MockIdentityManager) {
|
||||
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, identityManager)
|
||||
}
|
||||
|
||||
// MARK: - Command Processor Integration "Pinning"
|
||||
|
||||
@Test @MainActor
|
||||
func command_msg_routesToTransport() async throws {
|
||||
let (viewModel, transport, _) = makePinnedViewModel()
|
||||
|
||||
// Setup: Use simulateConnect so ChatViewModel and UnifiedPeerService are notified
|
||||
let peerID = PeerID(str: "0000000000000001")
|
||||
transport.simulateConnect(peerID, nickname: "alice")
|
||||
|
||||
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("alice") != nil },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
#expect(didResolve)
|
||||
|
||||
// Action: User types /msg command
|
||||
viewModel.sendMessage("/msg @alice Hello Private World")
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
#expect(didSend)
|
||||
|
||||
// Assert:
|
||||
// 1. Should NOT go to public transport
|
||||
#expect(transport.sentMessages.isEmpty, "Command should not be sent as public message")
|
||||
|
||||
// 2. Should go to private transport logic
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
#expect(transport.sentPrivateMessages.first?.content == "Hello Private World")
|
||||
#expect(transport.sentPrivateMessages.first?.peerID == peerID)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func command_block_updatesIdentity() async throws {
|
||||
let (viewModel, transport, identity) = makePinnedViewModel()
|
||||
|
||||
// Setup: Use simulateConnect
|
||||
let peerID = PeerID(str: "0000000000000002")
|
||||
// Mock the fingerprint so the block command finds it
|
||||
transport.peerFingerprints[peerID] = "fingerprint_123"
|
||||
transport.simulateConnect(peerID, nickname: "troll")
|
||||
|
||||
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("troll") != nil },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
#expect(didResolve)
|
||||
|
||||
// Action
|
||||
viewModel.sendMessage("/block @troll")
|
||||
|
||||
// Assert
|
||||
// Verify identity manager was called to block "fingerprint_123"
|
||||
let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: "fingerprint_123") },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
#expect(didBlock)
|
||||
}
|
||||
|
||||
// MARK: - Message Routing Logic
|
||||
|
||||
@Test @MainActor
|
||||
func routing_incomingPrivateMessage_addsToPrivateChats() async {
|
||||
let (viewModel, _, _) = makePinnedViewModel()
|
||||
let senderID = PeerID(str: "sender_1")
|
||||
|
||||
// Setup
|
||||
let message = BitchatMessage(
|
||||
id: "msg_1",
|
||||
sender: "bob",
|
||||
content: "Secret",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: "me",
|
||||
senderPeerID: senderID,
|
||||
mentions: nil
|
||||
)
|
||||
|
||||
// Action: Simulate incoming private message
|
||||
viewModel.didReceiveMessage(message)
|
||||
|
||||
// Wait for async processing with proper timeout
|
||||
let found = await TestHelpers.waitUntil(
|
||||
{ viewModel.privateChats[senderID]?.first?.content == "Secret" },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(found)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func routing_incomingPublicMessage_addsToPublicTimeline() async {
|
||||
let (viewModel, _, _) = makePinnedViewModel()
|
||||
let senderID = PeerID(str: "sender_2")
|
||||
|
||||
// Setup
|
||||
let message = BitchatMessage(
|
||||
id: "msg_2",
|
||||
sender: "charlie",
|
||||
content: "Public Hi",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: senderID,
|
||||
mentions: nil
|
||||
)
|
||||
|
||||
// Action
|
||||
viewModel.didReceiveMessage(message)
|
||||
|
||||
// Wait for async processing with proper timeout
|
||||
let found = await TestHelpers.waitUntil(
|
||||
{ viewModel.messages.contains(where: { $0.content == "Public Hi" }) },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(found)
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,44 @@ struct ChatViewModelSendingTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Command Handling Tests
|
||||
|
||||
struct ChatViewModelCommandTests {
|
||||
|
||||
@Test @MainActor
|
||||
func sendMessage_commandsNotSentToTransport() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let commands = ["/nick bob", "/who", "/help", "/clear"]
|
||||
|
||||
for command in commands {
|
||||
transport.resetRecordings()
|
||||
viewModel.sendMessage(command)
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
#expect(transport.sentMessages.isEmpty)
|
||||
#expect(transport.sentPrivateMessages.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline Cap Tests
|
||||
|
||||
struct ChatViewModelTimelineCapTests {
|
||||
|
||||
@Test @MainActor
|
||||
func sendMessage_trimsTimelineToCap() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let total = TransportConfig.meshTimelineCap + 5
|
||||
|
||||
for i in 0..<total {
|
||||
viewModel.sendMessage("cap-msg-\(i)")
|
||||
}
|
||||
|
||||
#expect(viewModel.messages.count == TransportConfig.meshTimelineCap)
|
||||
#expect(viewModel.messages.last?.content == "cap-msg-\(total - 1)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Receiving Tests
|
||||
|
||||
struct ChatViewModelReceivingTests {
|
||||
@@ -164,6 +202,40 @@ struct ChatViewModelReceivingTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rate Limiting Tests
|
||||
|
||||
struct ChatViewModelRateLimitingTests {
|
||||
|
||||
@Test @MainActor
|
||||
func handlePublicMessage_rateLimitsBurstBySender() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let senderID = PeerID(str: "1122334455667788")
|
||||
let now = Date()
|
||||
|
||||
for i in 0..<6 {
|
||||
let message = BitchatMessage(
|
||||
id: "rate-\(i)",
|
||||
sender: "Spammer",
|
||||
content: "rate-msg-\(i)",
|
||||
timestamp: now,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: senderID,
|
||||
mentions: nil
|
||||
)
|
||||
viewModel.handlePublicMessage(message)
|
||||
}
|
||||
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
|
||||
let burstMessages = viewModel.messages.filter { $0.content.hasPrefix("rate-msg-") }
|
||||
#expect(burstMessages.count == 5)
|
||||
#expect(!burstMessages.contains { $0.content == "rate-msg-5" })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Peer Connection Tests
|
||||
|
||||
struct ChatViewModelPeerTests {
|
||||
@@ -258,6 +330,94 @@ struct ChatViewModelPrivateChatTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Chat Selection Tests
|
||||
|
||||
struct ChatViewModelPrivateChatSelectionTests {
|
||||
|
||||
@Test @MainActor
|
||||
func openMostRelevantPrivateChat_prefersUnreadMostRecent() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let peerA = PeerID(str: "PEER_A")
|
||||
let peerB = PeerID(str: "PEER_B")
|
||||
|
||||
let older = Date().addingTimeInterval(-120)
|
||||
let newer = Date().addingTimeInterval(-30)
|
||||
|
||||
viewModel.privateChats = [
|
||||
peerA: [
|
||||
BitchatMessage(
|
||||
id: "a-1",
|
||||
sender: "A",
|
||||
content: "Old",
|
||||
timestamp: older,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerA
|
||||
)
|
||||
],
|
||||
peerB: [
|
||||
BitchatMessage(
|
||||
id: "b-1",
|
||||
sender: "B",
|
||||
content: "New",
|
||||
timestamp: newer,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerB
|
||||
)
|
||||
]
|
||||
]
|
||||
viewModel.unreadPrivateMessages = [peerA, peerB]
|
||||
|
||||
viewModel.openMostRelevantPrivateChat()
|
||||
|
||||
#expect(viewModel.selectedPrivateChatPeer == peerB)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func openMostRelevantPrivateChat_fallsBackToMostRecentChat() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let peerA = PeerID(str: "PEER_A")
|
||||
let peerB = PeerID(str: "PEER_B")
|
||||
|
||||
let older = Date().addingTimeInterval(-200)
|
||||
let newer = Date().addingTimeInterval(-20)
|
||||
|
||||
viewModel.privateChats = [
|
||||
peerA: [
|
||||
BitchatMessage(
|
||||
id: "a-1",
|
||||
sender: "A",
|
||||
content: "Old",
|
||||
timestamp: older,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerA
|
||||
)
|
||||
],
|
||||
peerB: [
|
||||
BitchatMessage(
|
||||
id: "b-1",
|
||||
sender: "B",
|
||||
content: "New",
|
||||
timestamp: newer,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerB
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
viewModel.openMostRelevantPrivateChat()
|
||||
|
||||
#expect(viewModel.selectedPrivateChatPeer == peerB)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bluetooth State Tests
|
||||
|
||||
struct ChatViewModelBluetoothTests {
|
||||
@@ -309,11 +469,37 @@ struct ChatViewModelPanicTests {
|
||||
|
||||
// Set up some state
|
||||
transport.connectedPeers.insert(PeerID(str: "PEER1"))
|
||||
viewModel.messages = [
|
||||
BitchatMessage(
|
||||
id: "panic-1",
|
||||
sender: "Tester",
|
||||
content: "Before",
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
)
|
||||
]
|
||||
viewModel.privateChats[PeerID(str: "PEER1")] = [
|
||||
BitchatMessage(
|
||||
id: "pm-1",
|
||||
sender: "Peer",
|
||||
content: "Secret",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: PeerID(str: "PEER1")
|
||||
)
|
||||
]
|
||||
viewModel.unreadPrivateMessages.insert(PeerID(str: "PEER1"))
|
||||
|
||||
viewModel.panicClearAllData()
|
||||
|
||||
// After panic, emergency disconnect should be called
|
||||
#expect(transport.emergencyDisconnectCallCount == 1)
|
||||
#expect(viewModel.messages.isEmpty)
|
||||
#expect(viewModel.privateChats.isEmpty)
|
||||
#expect(viewModel.unreadPrivateMessages.isEmpty)
|
||||
#expect(viewModel.selectedPrivateChatPeer == nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//
|
||||
// ChatViewModelTorTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for ChatViewModel+Tor.swift Tor lifecycle notification handlers.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
@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: - Tor Notification Handler Tests
|
||||
|
||||
struct ChatViewModelTorTests {
|
||||
|
||||
// MARK: - handleTorWillStart Tests
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorWillStart_whenEnforced_setsAnnouncedFlag() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Precondition: flag should start false
|
||||
#expect(!viewModel.torStatusAnnounced)
|
||||
|
||||
// Action: simulate Tor starting notification
|
||||
viewModel.handleTorWillStart()
|
||||
|
||||
// Wait for Task to complete
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: flag should be set (torEnforced is true in tests)
|
||||
#expect(viewModel.torStatusAnnounced)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorWillStart_whenAlreadyAnnounced_doesNotDuplicate() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: pre-set the flag
|
||||
viewModel.torStatusAnnounced = true
|
||||
|
||||
// Switch to a geohash channel so messages would be visible
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
let initialMessageCount = viewModel.messages.count
|
||||
|
||||
// Action: call handler again
|
||||
viewModel.handleTorWillStart()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: no new message added (flag was already true)
|
||||
#expect(viewModel.messages.count == initialMessageCount)
|
||||
}
|
||||
|
||||
// MARK: - handleTorWillRestart Tests
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorWillRestart_setsPendingFlag() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Precondition
|
||||
#expect(!viewModel.torRestartPending)
|
||||
|
||||
// Action
|
||||
viewModel.handleTorWillRestart()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert
|
||||
#expect(viewModel.torRestartPending)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorWillRestart_setsFlag_regardlessOfChannel() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Action: call handler (works regardless of channel)
|
||||
viewModel.handleTorWillRestart()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: flag should be set
|
||||
#expect(viewModel.torRestartPending)
|
||||
}
|
||||
|
||||
// MARK: - handleTorDidBecomeReady Tests
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorDidBecomeReady_afterRestart_clearsPendingFlag() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: simulate restart pending state
|
||||
viewModel.torRestartPending = true
|
||||
|
||||
// Action
|
||||
viewModel.handleTorDidBecomeReady()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: should clear pending flag
|
||||
#expect(!viewModel.torRestartPending)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorDidBecomeReady_initialStart_setsAnnouncedFlag() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: not restarting, but initial ready not announced yet
|
||||
viewModel.torRestartPending = false
|
||||
viewModel.torInitialReadyAnnounced = false
|
||||
|
||||
// Action
|
||||
viewModel.handleTorDidBecomeReady()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: should set flag (torEnforced is true in tests)
|
||||
#expect(viewModel.torInitialReadyAnnounced)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorDidBecomeReady_alreadyAnnounced_noDuplicate() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: already announced initial ready
|
||||
viewModel.torRestartPending = false
|
||||
viewModel.torInitialReadyAnnounced = true
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
let initialMessageCount = viewModel.messages.count
|
||||
|
||||
// Action
|
||||
viewModel.handleTorDidBecomeReady()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: no new message
|
||||
#expect(viewModel.messages.count == initialMessageCount)
|
||||
}
|
||||
|
||||
// MARK: - handleTorPreferenceChanged Tests
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorPreferenceChanged_resetsAllFlags() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: set all flags
|
||||
viewModel.torStatusAnnounced = true
|
||||
viewModel.torInitialReadyAnnounced = true
|
||||
viewModel.torRestartPending = true
|
||||
|
||||
// Action
|
||||
viewModel.handleTorPreferenceChanged(Notification(name: .init("test")))
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: all flags reset
|
||||
#expect(!viewModel.torStatusAnnounced)
|
||||
#expect(!viewModel.torInitialReadyAnnounced)
|
||||
#expect(!viewModel.torRestartPending)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private var blockedFingerprints: Set<String> = []
|
||||
private var blockedNostrPubkeys: Set<String> = []
|
||||
private var socialIdentities: [String: SocialIdentity] = [:]
|
||||
|
||||
init(_ keychain: KeychainManagerProtocol) {
|
||||
self.keychain = keychain
|
||||
@@ -25,7 +26,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
func forceSave() {}
|
||||
|
||||
func getSocialIdentity(for fingerprint: String) -> SocialIdentity? {
|
||||
nil
|
||||
socialIdentities[fingerprint]
|
||||
}
|
||||
|
||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) {}
|
||||
@@ -34,7 +35,14 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
[]
|
||||
}
|
||||
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {}
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||
socialIdentities[identity.fingerprint] = identity
|
||||
if identity.isBlocked {
|
||||
blockedFingerprints.insert(identity.fingerprint)
|
||||
} else {
|
||||
blockedFingerprints.remove(identity.fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func getFavorites() -> Set<String> {
|
||||
Set()
|
||||
@@ -47,10 +55,25 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
func isBlocked(fingerprint: String) -> Bool {
|
||||
blockedFingerprints.contains(fingerprint)
|
||||
blockedFingerprints.contains(fingerprint) || socialIdentities[fingerprint]?.isBlocked == true
|
||||
}
|
||||
|
||||
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
||||
if var identity = socialIdentities[fingerprint] {
|
||||
identity.isBlocked = isBlocked
|
||||
socialIdentities[fingerprint] = identity
|
||||
} else {
|
||||
let identity = SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: "",
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: isBlocked,
|
||||
notes: nil
|
||||
)
|
||||
socialIdentities[fingerprint] = identity
|
||||
}
|
||||
if isBlocked {
|
||||
blockedFingerprints.insert(fingerprint)
|
||||
} else {
|
||||
|
||||
@@ -184,6 +184,7 @@ final class MockTransport: Transport {
|
||||
}
|
||||
delegate?.didConnectToPeer(peerID)
|
||||
delegate?.didUpdatePeerList(Array(connectedPeers))
|
||||
publishPeerSnapshots()
|
||||
}
|
||||
|
||||
/// Simulates a peer disconnecting
|
||||
@@ -192,6 +193,7 @@ final class MockTransport: Transport {
|
||||
peerNicknames.removeValue(forKey: peerID)
|
||||
delegate?.didDisconnectFromPeer(peerID)
|
||||
delegate?.didUpdatePeerList(Array(connectedPeers))
|
||||
publishPeerSnapshots()
|
||||
}
|
||||
|
||||
/// Simulates receiving a message
|
||||
@@ -224,5 +226,22 @@ final class MockTransport: Transport {
|
||||
/// Updates the peer snapshot publisher
|
||||
func updatePeerSnapshots(_ snapshots: [TransportPeerSnapshot]) {
|
||||
peerSnapshotSubject.send(snapshots)
|
||||
Task { @MainActor [weak self] in
|
||||
self?.peerEventsDelegate?.didUpdatePeerSnapshots(snapshots)
|
||||
}
|
||||
}
|
||||
|
||||
private func publishPeerSnapshots() {
|
||||
let now = Date()
|
||||
let snapshots = connectedPeers.map { peerID in
|
||||
TransportPeerSnapshot(
|
||||
peerID: peerID,
|
||||
nickname: peerNicknames[peerID] ?? "",
|
||||
isConnected: true,
|
||||
noisePublicKey: Data(hexString: peerID.bare),
|
||||
lastSeen: now
|
||||
)
|
||||
}
|
||||
updatePeerSnapshots(snapshots)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//
|
||||
// PublicMessagePipelineTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for PublicMessagePipeline ordering and deduplication.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
private final class TestPipelineDelegate: PublicMessagePipelineDelegate {
|
||||
private let dedupService = MessageDeduplicationService()
|
||||
var messages: [BitchatMessage] = []
|
||||
|
||||
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] {
|
||||
messages
|
||||
}
|
||||
|
||||
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) {
|
||||
self.messages = messages
|
||||
}
|
||||
|
||||
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String {
|
||||
dedupService.normalizedContentKey(content)
|
||||
}
|
||||
|
||||
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? {
|
||||
dedupService.contentTimestamp(forKey: key)
|
||||
}
|
||||
|
||||
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) {
|
||||
dedupService.recordContentKey(key, timestamp: timestamp)
|
||||
}
|
||||
|
||||
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) {}
|
||||
|
||||
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {}
|
||||
|
||||
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {}
|
||||
}
|
||||
|
||||
struct PublicMessagePipelineTests {
|
||||
|
||||
@Test @MainActor
|
||||
func flush_sortsByTimestamp() async {
|
||||
let pipeline = PublicMessagePipeline()
|
||||
let delegate = TestPipelineDelegate()
|
||||
pipeline.delegate = delegate
|
||||
|
||||
let earlier = Date().addingTimeInterval(-10)
|
||||
let later = Date()
|
||||
|
||||
let messageA = BitchatMessage(
|
||||
id: "a",
|
||||
sender: "A",
|
||||
content: "Later",
|
||||
timestamp: later,
|
||||
isRelay: false
|
||||
)
|
||||
let messageB = BitchatMessage(
|
||||
id: "b",
|
||||
sender: "A",
|
||||
content: "Earlier",
|
||||
timestamp: earlier,
|
||||
isRelay: false
|
||||
)
|
||||
|
||||
pipeline.enqueue(messageA)
|
||||
pipeline.enqueue(messageB)
|
||||
pipeline.flushIfNeeded()
|
||||
|
||||
#expect(delegate.messages.map { $0.id } == ["b", "a"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func flush_deduplicatesByContentWithinWindow() async {
|
||||
let pipeline = PublicMessagePipeline()
|
||||
let delegate = TestPipelineDelegate()
|
||||
pipeline.delegate = delegate
|
||||
|
||||
let now = Date()
|
||||
let messageA = BitchatMessage(
|
||||
id: "a",
|
||||
sender: "A",
|
||||
content: "Same",
|
||||
timestamp: now,
|
||||
isRelay: false
|
||||
)
|
||||
let messageB = BitchatMessage(
|
||||
id: "b",
|
||||
sender: "A",
|
||||
content: "Same",
|
||||
timestamp: now.addingTimeInterval(0.2),
|
||||
isRelay: false
|
||||
)
|
||||
|
||||
pipeline.enqueue(messageA)
|
||||
pipeline.enqueue(messageB)
|
||||
pipeline.flushIfNeeded()
|
||||
|
||||
#expect(delegate.messages.count == 1)
|
||||
#expect(delegate.messages.first?.content == "Same")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func lateInsert_meshAppendsRecentOlderMessage() async {
|
||||
let pipeline = PublicMessagePipeline()
|
||||
let delegate = TestPipelineDelegate()
|
||||
pipeline.delegate = delegate
|
||||
pipeline.updateActiveChannel(.mesh)
|
||||
|
||||
let base = Date()
|
||||
let newer = BitchatMessage(
|
||||
id: "new",
|
||||
sender: "A",
|
||||
content: "New",
|
||||
timestamp: base,
|
||||
isRelay: false
|
||||
)
|
||||
let older = BitchatMessage(
|
||||
id: "old",
|
||||
sender: "A",
|
||||
content: "Old",
|
||||
timestamp: base.addingTimeInterval(-5),
|
||||
isRelay: false
|
||||
)
|
||||
|
||||
delegate.messages = [newer]
|
||||
pipeline.enqueue(older)
|
||||
pipeline.flushIfNeeded()
|
||||
|
||||
#expect(delegate.messages.map { $0.id } == ["new", "old"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func lateInsert_locationInsertsByTimestamp() async {
|
||||
let pipeline = PublicMessagePipeline()
|
||||
let delegate = TestPipelineDelegate()
|
||||
pipeline.delegate = delegate
|
||||
pipeline.updateActiveChannel(.location(GeohashChannel(level: .city, geohash: "u4pruydq")))
|
||||
|
||||
let base = Date()
|
||||
let newer = BitchatMessage(
|
||||
id: "new",
|
||||
sender: "A",
|
||||
content: "New",
|
||||
timestamp: base,
|
||||
isRelay: false
|
||||
)
|
||||
let older = BitchatMessage(
|
||||
id: "old",
|
||||
sender: "A",
|
||||
content: "Old",
|
||||
timestamp: base.addingTimeInterval(-5),
|
||||
isRelay: false
|
||||
)
|
||||
|
||||
delegate.messages = [newer]
|
||||
pipeline.enqueue(older)
|
||||
pipeline.flushIfNeeded()
|
||||
|
||||
#expect(delegate.messages.map { $0.id } == ["old", "new"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// MessageRouterTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for MessageRouter transport selection and outbox behavior.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct MessageRouterTests {
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_usesReachableTransport() async {
|
||||
let peerID = PeerID(str: "0000000000000001")
|
||||
let transportA = MockTransport()
|
||||
let transportB = MockTransport()
|
||||
transportB.reachablePeers.insert(peerID)
|
||||
|
||||
let router = MessageRouter(transports: [transportA, transportB])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m1")
|
||||
|
||||
#expect(transportA.sentPrivateMessages.isEmpty)
|
||||
#expect(transportB.sentPrivateMessages.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_queuesThenFlushesWhenReachable() async {
|
||||
let peerID = PeerID(str: "0000000000000002")
|
||||
let transport = MockTransport()
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Queued", to: peerID, recipientNickname: "Peer", messageID: "m2")
|
||||
|
||||
#expect(transport.sentPrivateMessages.isEmpty)
|
||||
|
||||
transport.reachablePeers.insert(peerID)
|
||||
router.flushOutbox(for: peerID)
|
||||
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendReadReceipt_usesReachableTransport() async {
|
||||
let peerID = PeerID(str: "0000000000000003")
|
||||
let transport = MockTransport()
|
||||
transport.reachablePeers.insert(peerID)
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
let receipt = ReadReceipt(originalMessageID: "m3", readerID: transport.myPeerID, readerNickname: "Me")
|
||||
router.sendReadReceipt(receipt, to: peerID)
|
||||
|
||||
#expect(transport.sentReadReceipts.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendFavoriteNotification_usesConnectedOrReachable() async {
|
||||
let peerID = PeerID(str: "0000000000000004")
|
||||
let transport = MockTransport()
|
||||
transport.reachablePeers.insert(peerID)
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendFavoriteNotification(to: peerID, isFavorite: true)
|
||||
|
||||
#expect(transport.sentFavoriteNotifications.count == 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//
|
||||
// PrivateChatManagerTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for PrivateChatManager read receipt and selection behavior.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct PrivateChatManagerTests {
|
||||
|
||||
@Test @MainActor
|
||||
func startChat_setsSelectedAndClearsUnread() async {
|
||||
let transport = MockTransport()
|
||||
let manager = PrivateChatManager(meshService: transport)
|
||||
let peerID = PeerID(str: "00000000000000AA")
|
||||
|
||||
manager.privateChats[peerID] = [
|
||||
BitchatMessage(
|
||||
id: "pm-1",
|
||||
sender: "Peer",
|
||||
content: "Hi",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerID
|
||||
)
|
||||
]
|
||||
manager.unreadMessages.insert(peerID)
|
||||
|
||||
manager.startChat(with: peerID)
|
||||
|
||||
#expect(manager.selectedPeer == peerID)
|
||||
#expect(!manager.unreadMessages.contains(peerID))
|
||||
#expect(manager.privateChats[peerID] != nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func markAsRead_sendsReadReceiptViaRouter() async {
|
||||
let transport = MockTransport()
|
||||
let router = MessageRouter(transports: [transport])
|
||||
let manager = PrivateChatManager(meshService: transport)
|
||||
manager.messageRouter = router
|
||||
|
||||
let peerID = PeerID(str: "00000000000000BB")
|
||||
transport.reachablePeers.insert(peerID)
|
||||
|
||||
manager.privateChats[peerID] = [
|
||||
BitchatMessage(
|
||||
id: "pm-2",
|
||||
sender: "Peer",
|
||||
content: "Hi",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerID
|
||||
)
|
||||
]
|
||||
manager.unreadMessages.insert(peerID)
|
||||
|
||||
manager.markAsRead(from: peerID)
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
#expect(transport.sentReadReceipts.count == 1)
|
||||
#expect(manager.sentReadReceipts.contains("pm-2"))
|
||||
#expect(!manager.unreadMessages.contains(peerID))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//
|
||||
// RelayControllerTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for relay decision logic.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct RelayControllerTests {
|
||||
|
||||
@Test
|
||||
func ttlOne_doesNotRelay() async {
|
||||
let decision = RelayController.decide(
|
||||
ttl: 1,
|
||||
senderIsSelf: false,
|
||||
isEncrypted: false,
|
||||
isDirectedEncrypted: false,
|
||||
isFragment: false,
|
||||
isDirectedFragment: false,
|
||||
isHandshake: false,
|
||||
isAnnounce: false,
|
||||
degree: 0,
|
||||
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
|
||||
)
|
||||
|
||||
#expect(!decision.shouldRelay)
|
||||
#expect(decision.newTTL == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func handshake_alwaysRelaysWithTTLDecrement() async {
|
||||
let decision = RelayController.decide(
|
||||
ttl: 3,
|
||||
senderIsSelf: false,
|
||||
isEncrypted: false,
|
||||
isDirectedEncrypted: false,
|
||||
isFragment: false,
|
||||
isDirectedFragment: false,
|
||||
isHandshake: true,
|
||||
isAnnounce: false,
|
||||
degree: 3,
|
||||
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
|
||||
)
|
||||
|
||||
#expect(decision.shouldRelay)
|
||||
#expect(decision.newTTL == 2)
|
||||
#expect(decision.delayMs >= 10 && decision.delayMs <= 35)
|
||||
}
|
||||
|
||||
@Test
|
||||
func fragment_relaysWithFragmentCap() async {
|
||||
let decision = RelayController.decide(
|
||||
ttl: 10,
|
||||
senderIsSelf: false,
|
||||
isEncrypted: false,
|
||||
isDirectedEncrypted: false,
|
||||
isFragment: true,
|
||||
isDirectedFragment: false,
|
||||
isHandshake: false,
|
||||
isAnnounce: false,
|
||||
degree: 3,
|
||||
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
|
||||
)
|
||||
|
||||
let ttlCap = min(UInt8(10), TransportConfig.bleFragmentRelayTtlCap)
|
||||
let expected = ttlCap &- 1
|
||||
|
||||
#expect(decision.shouldRelay)
|
||||
#expect(decision.newTTL == expected)
|
||||
#expect(decision.delayMs >= TransportConfig.bleFragmentRelayMinDelayMs)
|
||||
#expect(decision.delayMs <= TransportConfig.bleFragmentRelayMaxDelayMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
func denseGraph_capsTTL() async {
|
||||
let decision = RelayController.decide(
|
||||
ttl: 10,
|
||||
senderIsSelf: false,
|
||||
isEncrypted: false,
|
||||
isDirectedEncrypted: false,
|
||||
isFragment: false,
|
||||
isDirectedFragment: false,
|
||||
isHandshake: false,
|
||||
isAnnounce: false,
|
||||
degree: TransportConfig.bleHighDegreeThreshold,
|
||||
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
|
||||
)
|
||||
|
||||
#expect(decision.shouldRelay)
|
||||
#expect(decision.newTTL == 4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//
|
||||
// UnifiedPeerServiceTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for UnifiedPeerService fingerprint and block resolution.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct UnifiedPeerServiceTests {
|
||||
|
||||
@Test @MainActor
|
||||
func getFingerprint_prefersMeshService() async {
|
||||
let transport = MockTransport()
|
||||
let identity = TestIdentityManager()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
|
||||
|
||||
let peerID = PeerID(str: "00000000000000CC")
|
||||
transport.peerFingerprints[peerID] = "fp-1"
|
||||
|
||||
let fingerprint = service.getFingerprint(for: peerID)
|
||||
|
||||
#expect(fingerprint == "fp-1")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func isBlocked_usesSocialIdentity() async {
|
||||
let transport = MockTransport()
|
||||
let identity = TestIdentityManager()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
|
||||
|
||||
let peerID = PeerID(str: "00000000000000DD")
|
||||
let fingerprint = "fp-blocked"
|
||||
transport.peerFingerprints[peerID] = fingerprint
|
||||
identity.setBlocked(fingerprint, isBlocked: true)
|
||||
|
||||
#expect(service.isBlocked(peerID))
|
||||
}
|
||||
}
|
||||
|
||||
private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
private var socialIdentities: [String: SocialIdentity] = [:]
|
||||
private var favorites: Set<String> = []
|
||||
private var blockedNostr: Set<String> = []
|
||||
private var verified: Set<String> = []
|
||||
|
||||
func forceSave() {}
|
||||
|
||||
func getSocialIdentity(for fingerprint: String) -> SocialIdentity? {
|
||||
socialIdentities[fingerprint]
|
||||
}
|
||||
|
||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) {}
|
||||
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {
|
||||
[]
|
||||
}
|
||||
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||
socialIdentities[identity.fingerprint] = identity
|
||||
}
|
||||
|
||||
func getFavorites() -> Set<String> {
|
||||
favorites
|
||||
}
|
||||
|
||||
func setFavorite(_ fingerprint: String, isFavorite: Bool) {
|
||||
if isFavorite {
|
||||
favorites.insert(fingerprint)
|
||||
} else {
|
||||
favorites.remove(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func isFavorite(fingerprint: String) -> Bool {
|
||||
favorites.contains(fingerprint)
|
||||
}
|
||||
|
||||
func isBlocked(fingerprint: String) -> Bool {
|
||||
socialIdentities[fingerprint]?.isBlocked ?? false
|
||||
}
|
||||
|
||||
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
||||
var identity = socialIdentities[fingerprint] ?? SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: "",
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
identity.isBlocked = isBlocked
|
||||
socialIdentities[fingerprint] = identity
|
||||
}
|
||||
|
||||
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
|
||||
blockedNostr.contains(pubkeyHexLowercased)
|
||||
}
|
||||
|
||||
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
|
||||
if isBlocked {
|
||||
blockedNostr.insert(pubkeyHexLowercased)
|
||||
} else {
|
||||
blockedNostr.remove(pubkeyHexLowercased)
|
||||
}
|
||||
}
|
||||
|
||||
func getBlockedNostrPubkeys() -> Set<String> {
|
||||
blockedNostr
|
||||
}
|
||||
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {}
|
||||
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {}
|
||||
|
||||
func clearAllIdentityData() {
|
||||
socialIdentities.removeAll()
|
||||
favorites.removeAll()
|
||||
blockedNostr.removeAll()
|
||||
verified.removeAll()
|
||||
}
|
||||
|
||||
func removeEphemeralSession(peerID: PeerID) {}
|
||||
|
||||
func setVerified(fingerprint: String, verified: Bool) {
|
||||
if verified {
|
||||
self.verified.insert(fingerprint)
|
||||
} else {
|
||||
self.verified.remove(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func isVerified(fingerprint: String) -> Bool {
|
||||
verified.contains(fingerprint)
|
||||
}
|
||||
|
||||
func getVerifiedFingerprints() -> Set<String> {
|
||||
verified
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,22 @@ final class TestHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func waitUntil(
|
||||
_ condition: @escaping () -> Bool,
|
||||
timeout: TimeInterval = TestConstants.defaultTimeout,
|
||||
pollInterval: TimeInterval = 0.01
|
||||
) async -> Bool {
|
||||
let start = Date()
|
||||
while !condition() {
|
||||
if Date().timeIntervalSince(start) > timeout {
|
||||
return condition()
|
||||
}
|
||||
try? await sleep(pollInterval)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
static func expectAsync<T>(
|
||||
timeout: TimeInterval = TestConstants.defaultTimeout,
|
||||
operation: @escaping () async throws -> T
|
||||
|
||||
Reference in New Issue
Block a user