mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
BLEService's per-packet-type orchestration moves into owned, tested components (BLEAnnounceHandler, BLEPublicMessageHandler, BLENoisePacketHandler, BLEFileTransferHandler, BLEFragmentHandler), each taking an environment struct of closures so every queue hop stays in BLEService and the handlers are synchronously testable. Behavior is preserved verbatim, including Noise session recovery on decrypt failure and single-block UI event ordering. handleLeave/handleRequestSync stay in place as already-thin delegations. BLEService drops to 3393 lines. Four coordinators (delivery, private conversation, Nostr, public conversation) drop their unowned/weak ChatViewModel back-references for narrow @MainActor context protocols, with ChatViewModel conformances as single shared witnesses for overlapping members. Their true coupling is now an explicit, reviewable surface, and each gains a mock-context test suite covering flows previously testable only through the full view model. Delivery/read acks now also clear the router's retained-send outbox via the delivery context. New LargeTopologyTests exercise production-shaped meshes with the in-memory harness: an 8-peer relay chain with per-hop TTL decay, a 14-peer cyclic mesh with exactly-once delivery, partition/heal, and topology churn. App-layer runtime/model files updated alongside. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
252 lines
9.8 KiB
Swift
252 lines
9.8 KiB
Swift
import BitFoundation
|
|
import Foundation
|
|
import Testing
|
|
@testable import bitchat
|
|
|
|
struct BLEPublicMessageHandlerTests {
|
|
private final class Recorder {
|
|
var localNickname = "Me"
|
|
var peers: [PeerID: BLEPeerInfo] = [:]
|
|
var signedName: String?
|
|
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
|
var selfBroadcastMessageID: String?
|
|
|
|
var peersSnapshotReads = 0
|
|
var signedNameQueries: [PeerID] = []
|
|
var trackedPackets: [BitchatPacket] = []
|
|
var selfBroadcastTakes: [BitchatPacket] = []
|
|
var deliveries: [(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)] = []
|
|
}
|
|
|
|
private let localPeerID = PeerID(str: "0102030405060708")
|
|
private let remotePeerID = PeerID(str: "1122334455667788")
|
|
|
|
private func makeHandler(
|
|
recorder: Recorder,
|
|
localPeerID: PeerID? = nil,
|
|
now: Date = Date(timeIntervalSince1970: 1_000)
|
|
) -> BLEPublicMessageHandler {
|
|
let resolvedLocalPeerID = localPeerID ?? self.localPeerID
|
|
let environment = BLEPublicMessageHandlerEnvironment(
|
|
localPeerID: { resolvedLocalPeerID },
|
|
localNickname: { recorder.localNickname },
|
|
now: { now },
|
|
peersSnapshot: {
|
|
recorder.peersSnapshotReads += 1
|
|
return recorder.peers
|
|
},
|
|
signedSenderDisplayName: { _, peerID in
|
|
recorder.signedNameQueries.append(peerID)
|
|
return recorder.signedName
|
|
},
|
|
trackPacketSeen: { packet in
|
|
recorder.trackedPackets.append(packet)
|
|
},
|
|
linkState: { _ in recorder.linkState },
|
|
takeSelfBroadcastMessageID: { packet in
|
|
recorder.selfBroadcastTakes.append(packet)
|
|
return recorder.selfBroadcastMessageID
|
|
},
|
|
deliverPublicMessage: { peerID, nickname, content, timestamp, messageID in
|
|
recorder.deliveries.append((peerID, nickname, content, timestamp, messageID))
|
|
}
|
|
)
|
|
return BLEPublicMessageHandler(environment: environment)
|
|
}
|
|
|
|
@Test
|
|
func verifiedPeerBroadcastIsTrackedAndDelivered() {
|
|
let now = Date(timeIntervalSince1970: 1_000)
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
|
let handler = makeHandler(recorder: recorder, now: now)
|
|
let packet = makeMessagePacket(sender: remotePeerID, content: "hello mesh", timestamp: timestamp(now))
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
#expect(recorder.peersSnapshotReads == 1)
|
|
#expect(recorder.signedNameQueries.isEmpty)
|
|
#expect(recorder.trackedPackets.count == 1)
|
|
#expect(recorder.selfBroadcastTakes.isEmpty)
|
|
#expect(recorder.deliveries.count == 1)
|
|
#expect(recorder.deliveries.first?.peerID == remotePeerID)
|
|
#expect(recorder.deliveries.first?.nickname == "Alice")
|
|
#expect(recorder.deliveries.first?.content == "hello mesh")
|
|
#expect(recorder.deliveries.first?.timestamp == now)
|
|
#expect(recorder.deliveries.first?.messageID == nil)
|
|
}
|
|
|
|
@Test
|
|
func selfEchoIsDropped() {
|
|
let now = Date(timeIntervalSince1970: 1_000)
|
|
let recorder = Recorder()
|
|
let handler = makeHandler(recorder: recorder, now: now)
|
|
let packet = makeMessagePacket(sender: localPeerID, content: "echo", timestamp: timestamp(now), ttl: 3)
|
|
|
|
handler.handle(packet, from: localPeerID)
|
|
|
|
expectNoSideEffects(recorder)
|
|
}
|
|
|
|
@Test
|
|
func staleBroadcastIsDropped() {
|
|
let now = Date(timeIntervalSince1970: 1_000)
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
|
let handler = makeHandler(recorder: recorder, now: now)
|
|
let staleTimestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000)
|
|
let packet = makeMessagePacket(sender: remotePeerID, content: "old", timestamp: staleTimestamp)
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
expectNoSideEffects(recorder)
|
|
}
|
|
|
|
@Test
|
|
func unknownPeerWithoutValidSignatureIsDroppedBeforeSyncTracking() {
|
|
let now = Date(timeIntervalSince1970: 1_000)
|
|
let recorder = Recorder()
|
|
let handler = makeHandler(recorder: recorder, now: now)
|
|
let packet = makeMessagePacket(sender: remotePeerID, content: "hi", timestamp: timestamp(now))
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
#expect(recorder.signedNameQueries == [remotePeerID])
|
|
// The sender must resolve before the packet is tracked for sync.
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.deliveries.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func connectedButUnverifiedPeerIsDropped() {
|
|
let now = Date(timeIntervalSince1970: 1_000)
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Mallory", isVerified: false, isConnected: true)]
|
|
let handler = makeHandler(recorder: recorder, now: now)
|
|
let packet = makeMessagePacket(sender: remotePeerID, content: "hi", timestamp: timestamp(now))
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
// Public messages never accept connected-but-unverified registry entries.
|
|
#expect(recorder.signedNameQueries == [remotePeerID])
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.deliveries.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func signedSenderFallbackDeliversWithSignedName() {
|
|
let now = Date(timeIntervalSince1970: 1_000)
|
|
let recorder = Recorder()
|
|
recorder.signedName = "SignedAlice"
|
|
let handler = makeHandler(recorder: recorder, now: now)
|
|
let packet = makeMessagePacket(sender: remotePeerID, content: "signed hello", timestamp: timestamp(now))
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
#expect(recorder.signedNameQueries == [remotePeerID])
|
|
#expect(recorder.trackedPackets.count == 1)
|
|
#expect(recorder.deliveries.count == 1)
|
|
#expect(recorder.deliveries.first?.nickname == "SignedAlice")
|
|
}
|
|
|
|
@Test
|
|
func invalidUTF8PayloadIsTrackedForSyncButNotDelivered() {
|
|
let now = Date(timeIntervalSince1970: 1_000)
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
|
let handler = makeHandler(recorder: recorder, now: now)
|
|
let packet = makeMessagePacket(sender: remotePeerID, payload: Data([0xFF, 0xFE, 0xFD]), timestamp: timestamp(now))
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
// Sync tracking happens before payload decoding, matching the original order.
|
|
#expect(recorder.trackedPackets.count == 1)
|
|
#expect(recorder.deliveries.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func selfSyncReplayResolvesOriginalMessageID() {
|
|
let now = Date(timeIntervalSince1970: 1_000)
|
|
let recorder = Recorder()
|
|
recorder.selfBroadcastMessageID = "original-id"
|
|
let handler = makeHandler(recorder: recorder, now: now)
|
|
// TTL 0 marks a sync replay of our own broadcast, not a self echo.
|
|
let packet = makeMessagePacket(sender: localPeerID, content: "mine", timestamp: timestamp(now), ttl: 0)
|
|
|
|
handler.handle(packet, from: localPeerID)
|
|
|
|
#expect(recorder.selfBroadcastTakes.count == 1)
|
|
#expect(recorder.deliveries.count == 1)
|
|
#expect(recorder.deliveries.first?.peerID == localPeerID)
|
|
#expect(recorder.deliveries.first?.nickname == recorder.localNickname)
|
|
#expect(recorder.deliveries.first?.messageID == "original-id")
|
|
}
|
|
|
|
@Test
|
|
func directedMessageIsDeliveredWithoutSyncTracking() {
|
|
let now = Date(timeIntervalSince1970: 1_000)
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
|
let handler = makeHandler(recorder: recorder, now: now)
|
|
let packet = makeMessagePacket(
|
|
sender: remotePeerID,
|
|
content: "direct",
|
|
timestamp: timestamp(now),
|
|
recipientID: Data(hexString: localPeerID.id)
|
|
)
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.deliveries.count == 1)
|
|
#expect(recorder.deliveries.first?.content == "direct")
|
|
}
|
|
|
|
private func expectNoSideEffects(_ recorder: Recorder) {
|
|
#expect(recorder.signedNameQueries.isEmpty)
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.selfBroadcastTakes.isEmpty)
|
|
#expect(recorder.deliveries.isEmpty)
|
|
}
|
|
|
|
private func makePeerInfo(
|
|
_ peerID: PeerID,
|
|
nickname: String,
|
|
isVerified: Bool,
|
|
isConnected: Bool = true
|
|
) -> BLEPeerInfo {
|
|
BLEPeerInfo(
|
|
peerID: peerID,
|
|
nickname: nickname,
|
|
isConnected: isConnected,
|
|
noisePublicKey: nil,
|
|
signingPublicKey: nil,
|
|
isVerifiedNickname: isVerified,
|
|
lastSeen: Date(timeIntervalSince1970: 999)
|
|
)
|
|
}
|
|
|
|
private func makeMessagePacket(
|
|
sender: PeerID,
|
|
content: String? = nil,
|
|
payload: Data? = nil,
|
|
timestamp: UInt64,
|
|
ttl: UInt8 = TransportConfig.messageTTLDefault,
|
|
recipientID: Data? = nil
|
|
) -> BitchatPacket {
|
|
BitchatPacket(
|
|
type: MessageType.message.rawValue,
|
|
senderID: Data(hexString: sender.id) ?? Data(),
|
|
recipientID: recipientID,
|
|
timestamp: timestamp,
|
|
payload: payload ?? Data((content ?? "").utf8),
|
|
signature: nil,
|
|
ttl: ttl
|
|
)
|
|
}
|
|
|
|
private func timestamp(_ date: Date) -> UInt64 {
|
|
UInt64(date.timeIntervalSince1970 * 1000)
|
|
}
|
|
}
|