Files
bitchat/bitchatTests/Services/BLEPublicMessageHandlerTests.swift
T
jackandClaude Fable 5 3a995e20b6 Extract BLE packet handlers and migrate coordinators to narrow contexts
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>
2026-06-10 16:22:52 +01:00

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)
}
}