mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
Mechanical style fixes across the enabled rule set, mostly via swiftlint --fix (trailing_comma, comma, colon, trailing_newline, comment_spacing, unused_closure_parameter, unneeded_break_in_switch, opening_brace) plus hand fixes: - non_optional_string_data_conversion (45): .data(using: .utf8)! and ?? Data() fallbacks replaced with the non-optional Data(_.utf8), including two production sites (NIP-44 HKDF info constant and the announce canonicalization context/nickname bytes — byte-identical output, only the impossible-nil handling is gone). - switch_case_alignment: LocationChannel had a misindented closing brace; also repaired an --fix artifact in BLEService's .none case. - redundant_string_enum_value: TrustLevel raw values equal to the case names (encoded form unchanged). - unused_optional_binding: let _ = binds replaced with != nil / is Bool. - static_over_final_class: PreviewView.layerClass. - Resolved the BinaryProtocolTests TODO by documenting that 8-byte recipient ID truncation is the fixed wire-field size, not a bug. The 4 remaining violations are all todo markers for a shared test-helpers module (tracked in #1088) and one Reuse note. Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
262 lines
10 KiB
Swift
262 lines
10 KiB
Swift
//
|
|
// ChatPeerListCoordinatorContextTests.swift
|
|
// bitchatTests
|
|
//
|
|
// Exercises `ChatPeerListCoordinator` against a mock `ChatPeerListContext` —
|
|
// proving the coordinator works without a `ChatViewModel`, following the
|
|
// `ChatDeliveryCoordinatorContextTests` /
|
|
// `ChatTransportEventCoordinatorContextTests` exemplars.
|
|
//
|
|
// Scope note: the network-availability notification now posts through the
|
|
// injected `ChatPeerListContext` (`notifyNetworkAvailable(peerCount:)`), so
|
|
// its gating is covered here; the wall-clock timer-driven reset flows are
|
|
// covered by integration-level tests.
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
import BitFoundation
|
|
@testable import bitchat
|
|
|
|
// MARK: - Mock Context
|
|
|
|
/// Lightweight stand-in for `ChatPeerListContext` proving that
|
|
/// `ChatPeerListCoordinator` is testable without a `ChatViewModel`.
|
|
@MainActor
|
|
private final class MockChatPeerListContext: ChatPeerListContext {
|
|
// Connection & chat state
|
|
var isConnected = false
|
|
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
|
|
|
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
|
|
privateChats[peerID] ?? []
|
|
}
|
|
var unreadPrivateMessages: Set<PeerID> = []
|
|
var hasTrackedPrivateChatSelection = false
|
|
private(set) var updatePrivateChatPeerIfNeededCount = 0
|
|
private(set) var cleanupOldReadReceiptsCount = 0
|
|
|
|
func markPrivateChatRead(_ peerID: PeerID) {
|
|
unreadPrivateMessages.remove(peerID)
|
|
}
|
|
|
|
func updatePrivateChatPeerIfNeeded() {
|
|
updatePrivateChatPeerIfNeededCount += 1
|
|
}
|
|
|
|
func cleanupOldReadReceipts() {
|
|
cleanupOldReadReceiptsCount += 1
|
|
}
|
|
|
|
// Peers & sessions
|
|
var unifiedPeers: [BitchatPeer] = []
|
|
var connectedMeshPeers: Set<PeerID> = []
|
|
var reachableMeshPeers: Set<PeerID> = []
|
|
var activeMeshPeerCountValue = 0
|
|
private(set) var registeredEphemeralSessions: [PeerID] = []
|
|
private(set) var updateEncryptionStatusForPeersCount = 0
|
|
|
|
func isPeerConnected(_ peerID: PeerID) -> Bool { connectedMeshPeers.contains(peerID) }
|
|
func isPeerReachable(_ peerID: PeerID) -> Bool { reachableMeshPeers.contains(peerID) }
|
|
func activeMeshPeerCount() -> Int { activeMeshPeerCountValue }
|
|
func registerEphemeralSession(peerID: PeerID) { registeredEphemeralSessions.append(peerID) }
|
|
func updateEncryptionStatusForPeers() { updateEncryptionStatusForPeersCount += 1 }
|
|
|
|
// Notifications
|
|
private(set) var networkAvailableNotifications: [Int] = []
|
|
|
|
func notifyNetworkAvailable(peerCount: Int) {
|
|
networkAvailableNotifications.append(peerCount)
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
/// Lets the coordinator's internal `Task { @MainActor … }` hops run.
|
|
@MainActor
|
|
private func drainMainActorTasks() async {
|
|
for _ in 0..<10 { await Task.yield() }
|
|
}
|
|
|
|
private func makeMessage(id: String, senderPeerID: PeerID? = nil) -> BitchatMessage {
|
|
BitchatMessage(
|
|
id: id,
|
|
sender: "alice",
|
|
content: "hello",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: "me",
|
|
senderPeerID: senderPeerID
|
|
)
|
|
}
|
|
|
|
// MARK: - Coordinator Tests Against Mock Context
|
|
|
|
/// Exercises `ChatPeerListCoordinator` against `MockChatPeerListContext` with
|
|
/// no `ChatViewModel`.
|
|
struct ChatPeerListCoordinatorContextTests {
|
|
|
|
@Test @MainActor
|
|
func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async {
|
|
let context = MockChatPeerListContext()
|
|
let coordinator = ChatPeerListCoordinator(context: context)
|
|
let peerA = PeerID(str: "0011223344556677")
|
|
let peerB = PeerID(str: "8899aabbccddeeff")
|
|
context.isConnected = true
|
|
|
|
// Empty list: disconnected, read-receipt hygiene still runs, no sessions.
|
|
coordinator.didUpdatePeerList([])
|
|
await drainMainActorTasks()
|
|
#expect(!context.isConnected)
|
|
#expect(context.cleanupOldReadReceiptsCount == 1)
|
|
#expect(context.registeredEphemeralSessions.isEmpty)
|
|
#expect(context.updateEncryptionStatusForPeersCount == 1)
|
|
|
|
// Non-empty list: connected, every peer gets an ephemeral session.
|
|
coordinator.didUpdatePeerList([peerA, peerB])
|
|
await drainMainActorTasks()
|
|
#expect(context.isConnected)
|
|
#expect(context.registeredEphemeralSessions == [peerA, peerB])
|
|
#expect(context.updateEncryptionStatusForPeersCount == 2)
|
|
#expect(context.cleanupOldReadReceiptsCount == 2)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func didUpdatePeerList_refreshesPrivateChatPeerOnlyWhenSelectionIsTracked() async {
|
|
let context = MockChatPeerListContext()
|
|
let coordinator = ChatPeerListCoordinator(context: context)
|
|
let peerID = PeerID(str: "0011223344556677")
|
|
|
|
coordinator.didUpdatePeerList([peerID])
|
|
await drainMainActorTasks()
|
|
#expect(context.updatePrivateChatPeerIfNeededCount == 0)
|
|
|
|
context.hasTrackedPrivateChatSelection = true
|
|
coordinator.didUpdatePeerList([peerID])
|
|
await drainMainActorTasks()
|
|
#expect(context.updatePrivateChatPeerIfNeededCount == 1)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func didUpdatePeerList_removesStaleUnreadPeerIDsButKeepsBackedConversations() async {
|
|
let context = MockChatPeerListContext()
|
|
let coordinator = ChatPeerListCoordinator(context: context)
|
|
|
|
let currentPeer = PeerID(str: "0011223344556677")
|
|
let staleShortPeer = PeerID(str: "8899aabbccddeeff")
|
|
let geoDMWithMessages = PeerID(str: "nostr_" + String(repeating: "ab", count: 8))
|
|
let geoDMWithoutMessages = PeerID(str: "nostr_" + String(repeating: "cd", count: 8))
|
|
let noiseKeyWithMessages = PeerID(str: String(repeating: "ef", count: 32))
|
|
|
|
context.unifiedPeers = [
|
|
BitchatPeer(
|
|
peerID: currentPeer,
|
|
noisePublicKey: Data(repeating: 0x01, count: 32),
|
|
nickname: "alice"
|
|
)
|
|
]
|
|
context.unreadPrivateMessages = [
|
|
currentPeer,
|
|
staleShortPeer,
|
|
geoDMWithMessages,
|
|
geoDMWithoutMessages,
|
|
noiseKeyWithMessages
|
|
]
|
|
context.privateChats = [
|
|
geoDMWithMessages: [makeMessage(id: "geo-1")],
|
|
noiseKeyWithMessages: [makeMessage(id: "noise-1")]
|
|
]
|
|
|
|
coordinator.didUpdatePeerList([currentPeer])
|
|
await drainMainActorTasks()
|
|
|
|
// Stale IDs without a backing conversation are dropped; geo-DM and
|
|
// Noise-key IDs with stored messages survive, as does the live peer.
|
|
#expect(context.unreadPrivateMessages == [currentPeer, geoDMWithMessages, noiseKeyWithMessages])
|
|
#expect(context.cleanupOldReadReceiptsCount == 1)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func didUpdatePeerList_notifiesNetworkAvailableOncePerCooldownForNewMeshPeers() async {
|
|
let context = MockChatPeerListContext()
|
|
let coordinator = ChatPeerListCoordinator(context: context)
|
|
let peerA = PeerID(str: "0011223344556677")
|
|
let peerB = PeerID(str: "8899aabbccddeeff")
|
|
context.connectedMeshPeers = [peerA, peerB]
|
|
|
|
// First sighting of a mesh-active peer notifies with the mesh peer count.
|
|
coordinator.didUpdatePeerList([peerA])
|
|
await drainMainActorTasks()
|
|
#expect(context.networkAvailableNotifications == [1])
|
|
|
|
// The same peer again is not new — no repeat notification.
|
|
coordinator.didUpdatePeerList([peerA])
|
|
await drainMainActorTasks()
|
|
#expect(context.networkAvailableNotifications == [1])
|
|
|
|
// A genuinely new peer inside the cooldown window stays silent too.
|
|
coordinator.didUpdatePeerList([peerA, peerB])
|
|
await drainMainActorTasks()
|
|
#expect(context.networkAvailableNotifications == [1])
|
|
}
|
|
|
|
@Test @MainActor
|
|
func didUpdatePeerList_peerJoiningExistingMeshDoesNotNotify() async {
|
|
// Cooldown zero so this proves the empty-transition gate alone — a
|
|
// new peer joining while already meshed must stay silent even with
|
|
// the cooldown long expired (the sitting-idle re-notify bug).
|
|
let context = MockChatPeerListContext()
|
|
let coordinator = ChatPeerListCoordinator(context: context, notificationCooldownSeconds: 0)
|
|
let peerA = PeerID(str: "0011223344556677")
|
|
let peerB = PeerID(str: "8899aabbccddeeff")
|
|
context.connectedMeshPeers = [peerA, peerB]
|
|
|
|
coordinator.didUpdatePeerList([peerA])
|
|
await drainMainActorTasks()
|
|
#expect(context.networkAvailableNotifications == [1])
|
|
|
|
// peerB arrives while peerA is still connected: no notification.
|
|
coordinator.didUpdatePeerList([peerA, peerB])
|
|
await drainMainActorTasks()
|
|
#expect(context.networkAvailableNotifications == [1])
|
|
|
|
// Repeat events while idle keep staying silent.
|
|
coordinator.didUpdatePeerList([peerA, peerB])
|
|
await drainMainActorTasks()
|
|
#expect(context.networkAvailableNotifications == [1])
|
|
}
|
|
|
|
@Test @MainActor
|
|
func didUpdatePeerList_briefMeshFlapDoesNotRenotify() async {
|
|
let context = MockChatPeerListContext()
|
|
let coordinator = ChatPeerListCoordinator(context: context, notificationCooldownSeconds: 0)
|
|
let peerA = PeerID(str: "0011223344556677")
|
|
context.connectedMeshPeers = [peerA]
|
|
|
|
coordinator.didUpdatePeerList([peerA])
|
|
await drainMainActorTasks()
|
|
#expect(context.networkAvailableNotifications == [1])
|
|
|
|
// Link flap: empty list, then the peer returns before the 30s empty
|
|
// confirmation fires — silent.
|
|
coordinator.didUpdatePeerList([])
|
|
await drainMainActorTasks()
|
|
coordinator.didUpdatePeerList([peerA])
|
|
await drainMainActorTasks()
|
|
#expect(context.networkAvailableNotifications == [1])
|
|
}
|
|
|
|
@Test @MainActor
|
|
func didUpdatePeerList_meshInactivePeersNeverNotify() async {
|
|
let context = MockChatPeerListContext()
|
|
let coordinator = ChatPeerListCoordinator(context: context)
|
|
let peerA = PeerID(str: "0011223344556677")
|
|
|
|
// Peer present but neither connected nor reachable: no notification.
|
|
coordinator.didUpdatePeerList([peerA])
|
|
await drainMainActorTasks()
|
|
#expect(context.networkAvailableNotifications.isEmpty)
|
|
}
|
|
}
|