mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05:19 +00:00
Extract BLE and chat architecture policies (#1324)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEAnnounceHandlingPolicyTests {
|
||||
@Test
|
||||
func preflightAcceptsMatchingFreshAnnounce() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x11, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(noisePublicKey: noiseKey, peerID: peerID, timestamp: timestamp(now))
|
||||
|
||||
let decision = BLEAnnouncePreflightPolicy.evaluate(
|
||||
packet: packet,
|
||||
from: peerID,
|
||||
localPeerID: PeerID(str: "0102030405060708"),
|
||||
now: now
|
||||
)
|
||||
|
||||
guard case .accept(let accepted) = decision else {
|
||||
Issue.record("Expected announce preflight acceptance")
|
||||
return
|
||||
}
|
||||
#expect(accepted.announcement.nickname == "Alice")
|
||||
#expect(accepted.derivedPeerID == peerID)
|
||||
}
|
||||
|
||||
@Test
|
||||
func preflightRejectsMalformedPayload() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: Data(hexString: peerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp(now),
|
||||
payload: Data([0x01, 0x20]),
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
|
||||
let decision = BLEAnnouncePreflightPolicy.evaluate(
|
||||
packet: packet,
|
||||
from: peerID,
|
||||
localPeerID: PeerID(str: "0102030405060708"),
|
||||
now: now
|
||||
)
|
||||
|
||||
guard case .reject(.malformed) = decision else {
|
||||
Issue.record("Expected malformed announce rejection")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func preflightRejectsSenderMismatchWithDerivedPeerID() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x22, count: 32)
|
||||
let derivedPeerID = PeerID(publicKey: noiseKey)
|
||||
let claimedPeerID = PeerID(str: "1122334455667788")
|
||||
let packet = try makeAnnouncePacket(noisePublicKey: noiseKey, peerID: claimedPeerID, timestamp: timestamp(now))
|
||||
|
||||
let decision = BLEAnnouncePreflightPolicy.evaluate(
|
||||
packet: packet,
|
||||
from: claimedPeerID,
|
||||
localPeerID: PeerID(str: "0102030405060708"),
|
||||
now: now
|
||||
)
|
||||
|
||||
guard case .reject(.senderMismatch(let rejectedDerivedPeerID)) = decision else {
|
||||
Issue.record("Expected sender mismatch rejection")
|
||||
return
|
||||
}
|
||||
#expect(rejectedDerivedPeerID == derivedPeerID)
|
||||
}
|
||||
|
||||
@Test
|
||||
func preflightRejectsSelfAnnounceAfterSenderMatches() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x33, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(noisePublicKey: noiseKey, peerID: peerID, timestamp: timestamp(now))
|
||||
|
||||
let decision = BLEAnnouncePreflightPolicy.evaluate(
|
||||
packet: packet,
|
||||
from: peerID,
|
||||
localPeerID: peerID,
|
||||
now: now
|
||||
)
|
||||
|
||||
guard case .reject(.selfAnnounce) = decision else {
|
||||
Issue.record("Expected self announce rejection")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func preflightRejectsStaleAnnounceWithAge() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x44, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let oldTimestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000)
|
||||
let packet = try makeAnnouncePacket(noisePublicKey: noiseKey, peerID: peerID, timestamp: oldTimestamp)
|
||||
|
||||
let decision = BLEAnnouncePreflightPolicy.evaluate(
|
||||
packet: packet,
|
||||
from: peerID,
|
||||
localPeerID: PeerID(str: "0102030405060708"),
|
||||
now: now
|
||||
)
|
||||
|
||||
guard case .reject(.stale(let ageSeconds)) = decision else {
|
||||
Issue.record("Expected stale announce rejection")
|
||||
return
|
||||
}
|
||||
#expect(abs(ageSeconds - 901) < 0.001)
|
||||
}
|
||||
|
||||
@Test
|
||||
func trustPolicyRequiresSignature() {
|
||||
let decision = BLEAnnounceTrustPolicy.evaluate(
|
||||
hasSignature: false,
|
||||
signatureValid: false,
|
||||
existingNoisePublicKey: nil,
|
||||
announcedNoisePublicKey: Data(repeating: 0x11, count: 32)
|
||||
)
|
||||
|
||||
#expect(decision == .reject(.missingSignature))
|
||||
#expect(!decision.isVerified)
|
||||
}
|
||||
|
||||
@Test
|
||||
func trustPolicyRejectsInvalidSignature() {
|
||||
let decision = BLEAnnounceTrustPolicy.evaluate(
|
||||
hasSignature: true,
|
||||
signatureValid: false,
|
||||
existingNoisePublicKey: nil,
|
||||
announcedNoisePublicKey: Data(repeating: 0x11, count: 32)
|
||||
)
|
||||
|
||||
#expect(decision == .reject(.invalidSignature))
|
||||
}
|
||||
|
||||
@Test
|
||||
func trustPolicyRejectsExistingKeyMismatch() {
|
||||
let decision = BLEAnnounceTrustPolicy.evaluate(
|
||||
hasSignature: true,
|
||||
signatureValid: true,
|
||||
existingNoisePublicKey: Data(repeating: 0xAA, count: 32),
|
||||
announcedNoisePublicKey: Data(repeating: 0xBB, count: 32)
|
||||
)
|
||||
|
||||
#expect(decision == .reject(.keyMismatch))
|
||||
}
|
||||
|
||||
@Test
|
||||
func trustPolicyAcceptsValidSignatureWithMatchingExistingKey() {
|
||||
let noiseKey = Data(repeating: 0xCC, count: 32)
|
||||
|
||||
let decision = BLEAnnounceTrustPolicy.evaluate(
|
||||
hasSignature: true,
|
||||
signatureValid: true,
|
||||
existingNoisePublicKey: noiseKey,
|
||||
announcedNoisePublicKey: noiseKey
|
||||
)
|
||||
|
||||
#expect(decision == .verified)
|
||||
#expect(decision.isVerified)
|
||||
}
|
||||
|
||||
@Test
|
||||
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
|
||||
let directNew = BLEAnnounceResponsePolicy.plan(
|
||||
isDirectAnnounce: true,
|
||||
isNewPeer: true,
|
||||
isReconnectedPeer: false,
|
||||
shouldSendAnnounceBack: false
|
||||
)
|
||||
#expect(directNew.shouldNotifyPeerConnected)
|
||||
#expect(directNew.shouldScheduleInitialSync)
|
||||
#expect(directNew.shouldScheduleAfterglow)
|
||||
|
||||
let relayedNew = BLEAnnounceResponsePolicy.plan(
|
||||
isDirectAnnounce: false,
|
||||
isNewPeer: true,
|
||||
isReconnectedPeer: false,
|
||||
shouldSendAnnounceBack: true
|
||||
)
|
||||
#expect(!relayedNew.shouldNotifyPeerConnected)
|
||||
#expect(!relayedNew.shouldScheduleInitialSync)
|
||||
#expect(relayedNew.shouldSendAnnounceBack)
|
||||
#expect(relayedNew.shouldScheduleAfterglow)
|
||||
|
||||
let directExisting = BLEAnnounceResponsePolicy.plan(
|
||||
isDirectAnnounce: true,
|
||||
isNewPeer: false,
|
||||
isReconnectedPeer: false,
|
||||
shouldSendAnnounceBack: false
|
||||
)
|
||||
#expect(!directExisting.shouldNotifyPeerConnected)
|
||||
#expect(!directExisting.shouldScheduleInitialSync)
|
||||
#expect(!directExisting.shouldScheduleAfterglow)
|
||||
}
|
||||
|
||||
private func makeAnnouncePacket(
|
||||
noisePublicKey: Data,
|
||||
peerID: PeerID,
|
||||
timestamp: UInt64
|
||||
) throws -> BitchatPacket {
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: "Alice",
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: Data(repeating: 0x99, count: 32),
|
||||
directNeighbors: nil
|
||||
)
|
||||
let payload = try #require(announcement.encode())
|
||||
|
||||
return BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: Data(hexString: peerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
}
|
||||
|
||||
private func timestamp(_ date: Date) -> UInt64 {
|
||||
UInt64(date.timeIntervalSince1970 * 1000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEAnnounceThrottleTests {
|
||||
@Test
|
||||
func firstAnnounceIsAllowed() {
|
||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
|
||||
let shouldSend = throttle.shouldSend(force: false, now: Date(timeIntervalSince1970: 100))
|
||||
|
||||
#expect(shouldSend)
|
||||
}
|
||||
|
||||
@Test
|
||||
func regularAnnounceUsesNormalMinimumInterval() {
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
|
||||
let first = throttle.shouldSend(force: false, now: now)
|
||||
let suppressed = throttle.shouldSend(force: false, now: now.addingTimeInterval(9.9))
|
||||
let afterInterval = throttle.shouldSend(force: false, now: now.addingTimeInterval(10))
|
||||
|
||||
#expect(first)
|
||||
#expect(!suppressed)
|
||||
#expect(afterInterval)
|
||||
}
|
||||
|
||||
@Test
|
||||
func forcedAnnounceUsesShorterMinimumInterval() {
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
|
||||
let first = throttle.shouldSend(force: false, now: now)
|
||||
let suppressed = throttle.shouldSend(force: true, now: now.addingTimeInterval(1.9))
|
||||
let afterInterval = throttle.shouldSend(force: true, now: now.addingTimeInterval(2))
|
||||
|
||||
#expect(first)
|
||||
#expect(!suppressed)
|
||||
#expect(afterInterval)
|
||||
}
|
||||
|
||||
@Test
|
||||
func elapsedReportsTimeSinceAcceptedSend() {
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
|
||||
throttle.shouldSend(force: false, now: now)
|
||||
|
||||
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEDirectedRelaySpoolTests {
|
||||
@Test
|
||||
func enqueueDeduplicatesByRecipientAndMessageID() {
|
||||
let recipient = PeerID(str: "1122334455667788")
|
||||
let now = Date()
|
||||
var spool = BLEDirectedRelaySpool()
|
||||
|
||||
let inserted = spool.enqueue(
|
||||
packet: makePacket(payload: [0x01]),
|
||||
recipient: recipient,
|
||||
messageID: "message-1",
|
||||
enqueuedAt: now
|
||||
)
|
||||
let duplicate = spool.enqueue(
|
||||
packet: makePacket(payload: [0x02]),
|
||||
recipient: recipient,
|
||||
messageID: "message-1",
|
||||
enqueuedAt: now
|
||||
)
|
||||
|
||||
#expect(inserted)
|
||||
#expect(!duplicate)
|
||||
#expect(spool.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func drainUnexpiredReturnsFreshPacketsAndClearsSpool() {
|
||||
let recipient = PeerID(str: "1122334455667788")
|
||||
let now = Date()
|
||||
var spool = BLEDirectedRelaySpool()
|
||||
let freshPacket = makePacket(payload: [0x01])
|
||||
let expiredPacket = makePacket(payload: [0x02])
|
||||
|
||||
spool.enqueue(packet: freshPacket, recipient: recipient, messageID: "fresh", enqueuedAt: now.addingTimeInterval(-1))
|
||||
spool.enqueue(packet: expiredPacket, recipient: recipient, messageID: "old", enqueuedAt: now.addingTimeInterval(-20))
|
||||
|
||||
let drained = spool.drainUnexpired(now: now, window: 5)
|
||||
|
||||
#expect(drained.count == 1)
|
||||
#expect(drained.first?.recipient == recipient)
|
||||
#expect(drained.first?.packet.payload == freshPacket.payload)
|
||||
#expect(spool.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func pruneExpiredKeepsFreshPacketsAcrossRecipients() {
|
||||
let firstRecipient = PeerID(str: "1122334455667788")
|
||||
let secondRecipient = PeerID(str: "8877665544332211")
|
||||
let now = Date()
|
||||
var spool = BLEDirectedRelaySpool()
|
||||
|
||||
spool.enqueue(packet: makePacket(payload: [0x01]), recipient: firstRecipient, messageID: "fresh-1", enqueuedAt: now)
|
||||
spool.enqueue(packet: makePacket(payload: [0x02]), recipient: secondRecipient, messageID: "fresh-2", enqueuedAt: now.addingTimeInterval(-2))
|
||||
spool.enqueue(packet: makePacket(payload: [0x03]), recipient: secondRecipient, messageID: "old", enqueuedAt: now.addingTimeInterval(-10))
|
||||
|
||||
spool.pruneExpired(now: now, window: 5)
|
||||
|
||||
#expect(spool.count == 2)
|
||||
let drained = spool.drainUnexpired(now: now, window: 5)
|
||||
#expect(Set(drained.map(\.recipient)) == Set([firstRecipient, secondRecipient]))
|
||||
}
|
||||
|
||||
@Test
|
||||
func removeAllClearsStoredPackets() {
|
||||
var spool = BLEDirectedRelaySpool()
|
||||
|
||||
spool.enqueue(
|
||||
packet: makePacket(payload: [0x01]),
|
||||
recipient: PeerID(str: "1122334455667788"),
|
||||
messageID: "message-1",
|
||||
enqueuedAt: Date()
|
||||
)
|
||||
spool.removeAll()
|
||||
|
||||
#expect(spool.isEmpty)
|
||||
}
|
||||
|
||||
private func makePacket(payload: [UInt8]) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: "8877665544332211") ?? Data(),
|
||||
recipientID: Data(hexString: "1122334455667788"),
|
||||
timestamp: 1234,
|
||||
payload: Data(payload),
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEFileTransferPolicyTests {
|
||||
@Test
|
||||
func selfEchoRejectsOnlyNonSyncReplayFromLocalPeer() {
|
||||
let localPeerID = PeerID(str: "1122334455667788")
|
||||
let localPacket = makePacket(sender: localPeerID, ttl: 3)
|
||||
let replayPacket = makePacket(sender: localPeerID, ttl: 0)
|
||||
let remotePacket = makePacket(sender: PeerID(str: "8877665544332211"), ttl: 3)
|
||||
|
||||
#expect(BLEFileTransferPolicy.isSelfEcho(packet: localPacket, from: localPeerID, localPeerID: localPeerID))
|
||||
#expect(!BLEFileTransferPolicy.isSelfEcho(packet: replayPacket, from: localPeerID, localPeerID: localPeerID))
|
||||
#expect(!BLEFileTransferPolicy.isSelfEcho(packet: remotePacket, from: PeerID(str: "8877665544332211"), localPeerID: localPeerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
func deliveryPlanTracksPublicBroadcastsForSync() {
|
||||
let localPeerID = PeerID(str: "1122334455667788")
|
||||
|
||||
#expect(BLEFileTransferPolicy.deliveryPlan(
|
||||
packet: makePacket(sender: PeerID(str: "8877665544332211"), recipientID: nil),
|
||||
localPeerID: localPeerID
|
||||
) == BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true))
|
||||
|
||||
#expect(BLEFileTransferPolicy.deliveryPlan(
|
||||
packet: makePacket(
|
||||
sender: PeerID(str: "8877665544332211"),
|
||||
recipientID: Data(repeating: 0xFF, count: 8)
|
||||
),
|
||||
localPeerID: localPeerID
|
||||
) == BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true))
|
||||
}
|
||||
|
||||
@Test
|
||||
func deliveryPlanAcceptsOnlyDirectedPacketsForLocalPeer() {
|
||||
let localPeerID = PeerID(str: "1122334455667788")
|
||||
let otherPeerID = PeerID(str: "8877665544332211")
|
||||
|
||||
#expect(BLEFileTransferPolicy.deliveryPlan(
|
||||
packet: makePacket(
|
||||
sender: otherPeerID,
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
),
|
||||
localPeerID: localPeerID
|
||||
) == BLEFileTransferDeliveryPlan(isPrivateMessage: true, shouldTrackForSync: false))
|
||||
|
||||
#expect(BLEFileTransferPolicy.deliveryPlan(
|
||||
packet: makePacket(
|
||||
sender: otherPeerID,
|
||||
recipientID: Data(hexString: otherPeerID.id)
|
||||
),
|
||||
localPeerID: localPeerID
|
||||
) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func validatorAcceptsMatchingMimeAndMagicBytes() throws {
|
||||
let payload = try makeFilePayload(
|
||||
mimeType: "application/pdf",
|
||||
content: Data("%PDF-1.7".utf8)
|
||||
)
|
||||
|
||||
let result = BLEIncomingFileValidator.validate(payload: payload)
|
||||
|
||||
guard case .success(let acceptance) = result else {
|
||||
Issue.record("Expected file validation success")
|
||||
return
|
||||
}
|
||||
#expect(acceptance.mime == .pdf)
|
||||
#expect(acceptance.filePacket.content == Data("%PDF-1.7".utf8))
|
||||
}
|
||||
|
||||
@Test
|
||||
func validatorRejectsMalformedPayload() {
|
||||
let result = BLEIncomingFileValidator.validate(payload: Data([0x01, 0x02, 0x03]))
|
||||
|
||||
#expect(result.rejection == .malformedPayload)
|
||||
}
|
||||
|
||||
@Test
|
||||
func validatorRejectsUnsupportedMime() throws {
|
||||
let payload = try makeFilePayload(
|
||||
mimeType: nil,
|
||||
content: Data([0x4D, 0x5A, 0x00, 0x00])
|
||||
)
|
||||
|
||||
let result = BLEIncomingFileValidator.validate(payload: payload)
|
||||
|
||||
#expect(result.rejection == .unsupportedMime(mimeType: nil, bytes: 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
func validatorRejectsMimeMagicMismatch() throws {
|
||||
let payload = try makeFilePayload(
|
||||
mimeType: "image/png",
|
||||
content: Data([0x00, 0x01, 0x02, 0x03])
|
||||
)
|
||||
|
||||
let result = BLEIncomingFileValidator.validate(payload: payload)
|
||||
|
||||
#expect(result.rejection == .magicMismatch(
|
||||
mime: .png,
|
||||
bytes: 4,
|
||||
prefixHex: "00 01 02 03"
|
||||
))
|
||||
}
|
||||
|
||||
private func makePacket(
|
||||
sender: PeerID,
|
||||
ttl: UInt8 = 3,
|
||||
recipientID: Data? = nil
|
||||
) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.fileTransfer.rawValue,
|
||||
senderID: Data(hexString: sender.id) ?? Data(),
|
||||
recipientID: recipientID,
|
||||
timestamp: 900_000,
|
||||
payload: Data([0x01, 0x02]),
|
||||
signature: nil,
|
||||
ttl: ttl
|
||||
)
|
||||
}
|
||||
|
||||
private func makeFilePayload(mimeType: String?, content: Data) throws -> Data {
|
||||
let packet = BitchatFilePacket(
|
||||
fileName: "sample",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: mimeType,
|
||||
content: content
|
||||
)
|
||||
return try #require(packet.encode())
|
||||
}
|
||||
}
|
||||
|
||||
private extension Result where Failure == BLEIncomingFileRejection {
|
||||
var rejection: BLEIncomingFileRejection? {
|
||||
guard case .failure(let rejection) = self else { return nil }
|
||||
return rejection
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("BLE maintenance policy tests")
|
||||
struct BLEMaintenancePolicyTests {
|
||||
@Test("discovery mode keeps announces frequent and advertising on")
|
||||
func discoveryModeAnnouncesAndEnsuresAdvertising() {
|
||||
let plan = BLEMaintenancePolicy.plan(
|
||||
cycle: 1,
|
||||
connectedCount: 0,
|
||||
peerRegistryIsEmpty: true,
|
||||
elapsedSinceLastAnnounce: TransportConfig.bleAnnounceIntervalSeconds,
|
||||
hasRecentTraffic: false
|
||||
)
|
||||
|
||||
#expect(plan.shouldSendAnnounce)
|
||||
#expect(plan.shouldEnsureAdvertising)
|
||||
#expect(plan.shouldFlushDirectedSpool)
|
||||
#expect(!plan.shouldRunCleanup)
|
||||
#expect(!plan.shouldResetCounter)
|
||||
}
|
||||
|
||||
@Test("connected sparse meshes use sparse announce cadence")
|
||||
func connectedSparseMeshUsesSparseAnnounceCadence() {
|
||||
let beforeTarget = BLEMaintenancePolicy.plan(
|
||||
cycle: 2,
|
||||
connectedCount: 2,
|
||||
peerRegistryIsEmpty: false,
|
||||
elapsedSinceLastAnnounce: TransportConfig.bleConnectedAnnounceBaseSecondsSparse - 0.1,
|
||||
hasRecentTraffic: false,
|
||||
connectedAnnounceJitterOffset: 0
|
||||
)
|
||||
let atTarget = BLEMaintenancePolicy.plan(
|
||||
cycle: 2,
|
||||
connectedCount: 2,
|
||||
peerRegistryIsEmpty: false,
|
||||
elapsedSinceLastAnnounce: TransportConfig.bleConnectedAnnounceBaseSecondsSparse,
|
||||
hasRecentTraffic: false,
|
||||
connectedAnnounceJitterOffset: 0
|
||||
)
|
||||
|
||||
#expect(!beforeTarget.shouldSendAnnounce)
|
||||
#expect(atTarget.shouldSendAnnounce)
|
||||
#expect(!beforeTarget.shouldEnsureAdvertising)
|
||||
}
|
||||
|
||||
@Test("dense meshes use dense announce cadence")
|
||||
func denseMeshUsesDenseAnnounceCadence() {
|
||||
let beforeTarget = BLEMaintenancePolicy.plan(
|
||||
cycle: 3,
|
||||
connectedCount: TransportConfig.bleHighDegreeThreshold,
|
||||
peerRegistryIsEmpty: false,
|
||||
elapsedSinceLastAnnounce: TransportConfig.bleConnectedAnnounceBaseSecondsDense - 0.1,
|
||||
hasRecentTraffic: false,
|
||||
connectedAnnounceJitterOffset: 0
|
||||
)
|
||||
let atTarget = BLEMaintenancePolicy.plan(
|
||||
cycle: 3,
|
||||
connectedCount: TransportConfig.bleHighDegreeThreshold,
|
||||
peerRegistryIsEmpty: false,
|
||||
elapsedSinceLastAnnounce: TransportConfig.bleConnectedAnnounceBaseSecondsDense,
|
||||
hasRecentTraffic: false,
|
||||
connectedAnnounceJitterOffset: 0
|
||||
)
|
||||
|
||||
#expect(!beforeTarget.shouldSendAnnounce)
|
||||
#expect(atTarget.shouldSendAnnounce)
|
||||
#expect(atTarget.shouldRunCleanup)
|
||||
}
|
||||
|
||||
@Test("recent traffic can request a quick announce")
|
||||
func recentTrafficRequestsQuickAnnounce() {
|
||||
let plan = BLEMaintenancePolicy.plan(
|
||||
cycle: 4,
|
||||
connectedCount: 2,
|
||||
peerRegistryIsEmpty: false,
|
||||
elapsedSinceLastAnnounce: 10.0,
|
||||
hasRecentTraffic: true,
|
||||
connectedAnnounceJitterOffset: TransportConfig.bleConnectedAnnounceJitterSparse
|
||||
)
|
||||
|
||||
#expect(plan.shouldSendAnnounce)
|
||||
#expect(!plan.shouldFlushDirectedSpool)
|
||||
}
|
||||
|
||||
@Test("maintenance cadence controls cleanup spool flushing and counter reset")
|
||||
func maintenanceCadenceControlsPeriodicWork() {
|
||||
let cleanupAndFlush = BLEMaintenancePolicy.plan(
|
||||
cycle: 3,
|
||||
connectedCount: 1,
|
||||
peerRegistryIsEmpty: false,
|
||||
elapsedSinceLastAnnounce: 0,
|
||||
hasRecentTraffic: false,
|
||||
connectedAnnounceJitterOffset: 0
|
||||
)
|
||||
let reset = BLEMaintenancePolicy.plan(
|
||||
cycle: 6,
|
||||
connectedCount: 1,
|
||||
peerRegistryIsEmpty: false,
|
||||
elapsedSinceLastAnnounce: 0,
|
||||
hasRecentTraffic: false,
|
||||
connectedAnnounceJitterOffset: 0
|
||||
)
|
||||
|
||||
#expect(cleanupAndFlush.shouldRunCleanup)
|
||||
#expect(cleanupAndFlush.shouldFlushDirectedSpool)
|
||||
#expect(!cleanupAndFlush.shouldResetCounter)
|
||||
#expect(reset.shouldRunCleanup)
|
||||
#expect(!reset.shouldFlushDirectedSpool)
|
||||
#expect(reset.shouldResetCounter)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEOutboundLinkPlannerTests {
|
||||
@Test
|
||||
func recipientIDSuppliesDirectedHintAndUsesAllAvailableLinks() throws {
|
||||
let recipient = PeerID(str: "1122334455667788")
|
||||
let packet = makePacket(type: .noiseEncrypted, recipient: recipient)
|
||||
|
||||
let plan = BLEOutboundLinkPlanner.plan(
|
||||
packet: packet,
|
||||
dataCount: 32,
|
||||
peripheralIDs: ["p1", "p2"],
|
||||
peripheralWriteLimits: [128, 128],
|
||||
centralIDs: ["c1"],
|
||||
centralNotifyLimits: [128],
|
||||
ingressRecord: nil,
|
||||
excludedLinks: [],
|
||||
directedOnlyPeer: nil
|
||||
)
|
||||
|
||||
#expect(plan.directedPeerHint == recipient)
|
||||
#expect(plan.fragmentChunkSize == nil)
|
||||
#expect(plan.selectedLinks.peripheralIDs == Set(["p1", "p2"]))
|
||||
#expect(plan.selectedLinks.centralIDs == Set(["c1"]))
|
||||
#expect(!plan.shouldSpoolDirectedPacket)
|
||||
}
|
||||
|
||||
@Test
|
||||
func oversizedNonFragmentPacketPlansFragmentationFromSmallestLinkLimit() {
|
||||
let packet = makePacket(type: .message)
|
||||
let smallestLimit = 96
|
||||
|
||||
let plan = BLEOutboundLinkPlanner.plan(
|
||||
packet: packet,
|
||||
dataCount: 160,
|
||||
peripheralIDs: ["p1"],
|
||||
peripheralWriteLimits: [128],
|
||||
centralIDs: ["c1"],
|
||||
centralNotifyLimits: [smallestLimit],
|
||||
ingressRecord: nil,
|
||||
excludedLinks: [],
|
||||
directedOnlyPeer: nil
|
||||
)
|
||||
|
||||
#expect(plan.fragmentChunkSize == BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: smallestLimit))
|
||||
#expect(plan.selectedLinks.peripheralIDs.isEmpty)
|
||||
#expect(plan.selectedLinks.centralIDs.isEmpty)
|
||||
#expect(!plan.shouldSpoolDirectedPacket)
|
||||
}
|
||||
|
||||
@Test
|
||||
func fragmentPacketsBypassFragmentationPlanning() {
|
||||
let packet = makePacket(type: .fragment)
|
||||
|
||||
let plan = BLEOutboundLinkPlanner.plan(
|
||||
packet: packet,
|
||||
dataCount: 512,
|
||||
peripheralIDs: ["p1"],
|
||||
peripheralWriteLimits: [64],
|
||||
centralIDs: ["c1"],
|
||||
centralNotifyLimits: [64],
|
||||
ingressRecord: nil,
|
||||
excludedLinks: [],
|
||||
directedOnlyPeer: nil
|
||||
)
|
||||
|
||||
#expect(plan.fragmentChunkSize == nil)
|
||||
#expect(plan.selectedLinks.peripheralIDs == Set(["p1"]))
|
||||
#expect(plan.selectedLinks.centralIDs == Set(["c1"]))
|
||||
}
|
||||
|
||||
@Test
|
||||
func directedEncryptedPacketSpoolsWhenNoLinksAreAvailable() {
|
||||
let recipient = PeerID(str: "1122334455667788")
|
||||
let packet = makePacket(type: .noiseEncrypted, recipient: recipient)
|
||||
|
||||
let plan = BLEOutboundLinkPlanner.plan(
|
||||
packet: packet,
|
||||
dataCount: 32,
|
||||
peripheralIDs: [],
|
||||
peripheralWriteLimits: [],
|
||||
centralIDs: [],
|
||||
centralNotifyLimits: [],
|
||||
ingressRecord: nil,
|
||||
excludedLinks: [],
|
||||
directedOnlyPeer: recipient
|
||||
)
|
||||
|
||||
#expect(plan.directedPeerHint == recipient)
|
||||
#expect(plan.shouldSpoolDirectedPacket)
|
||||
}
|
||||
|
||||
@Test
|
||||
func publicBroadcastDoesNotSpoolWhenNoLinksAreAvailable() {
|
||||
let packet = makePacket(type: .message)
|
||||
|
||||
let plan = BLEOutboundLinkPlanner.plan(
|
||||
packet: packet,
|
||||
dataCount: 32,
|
||||
peripheralIDs: [],
|
||||
peripheralWriteLimits: [],
|
||||
centralIDs: [],
|
||||
centralNotifyLimits: [],
|
||||
ingressRecord: nil,
|
||||
excludedLinks: [],
|
||||
directedOnlyPeer: nil
|
||||
)
|
||||
|
||||
#expect(plan.directedPeerHint == nil)
|
||||
#expect(!plan.shouldSpoolDirectedPacket)
|
||||
}
|
||||
|
||||
@Test
|
||||
func minimumLinkLimitUsesTheSmallestPresentRoleLimit() {
|
||||
#expect(BLEOutboundLinkPlanner.minimumLinkLimit(peripheralWriteLimits: [80, 120], centralNotifyLimits: []) == 80)
|
||||
#expect(BLEOutboundLinkPlanner.minimumLinkLimit(peripheralWriteLimits: [], centralNotifyLimits: [60, 90]) == 60)
|
||||
#expect(BLEOutboundLinkPlanner.minimumLinkLimit(peripheralWriteLimits: [], centralNotifyLimits: []) == nil)
|
||||
}
|
||||
|
||||
private func makePacket(
|
||||
type: MessageType,
|
||||
sender: PeerID = PeerID(str: "8877665544332211"),
|
||||
recipient: PeerID? = nil
|
||||
) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: type.rawValue,
|
||||
senderID: Data(hexString: sender.id) ?? Data(),
|
||||
recipientID: recipient.flatMap { Data(hexString: $0.id) },
|
||||
timestamp: 1234,
|
||||
payload: Data([0x01, 0x02]),
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEPacketFreshnessPolicyTests {
|
||||
@Test
|
||||
func nilRecipientCountsAsBroadcast() {
|
||||
#expect(BLEPacketFreshnessPolicy.isBroadcastRecipient(nil))
|
||||
}
|
||||
|
||||
@Test
|
||||
func allOnesEightByteRecipientCountsAsBroadcast() {
|
||||
#expect(BLEPacketFreshnessPolicy.isBroadcastRecipient(Data(repeating: 0xFF, count: 8)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func directedRecipientIsNotBroadcast() {
|
||||
#expect(!BLEPacketFreshnessPolicy.isBroadcastRecipient(Data([0, 1, 2, 3, 4, 5, 6, 7])))
|
||||
}
|
||||
|
||||
@Test
|
||||
func recentPacketIsNotStale() {
|
||||
let now = Date(timeIntervalSince1970: 1000)
|
||||
let timestamp = UInt64((now.timeIntervalSince1970 - 100) * 1000)
|
||||
|
||||
#expect(!BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: timestamp, now: now))
|
||||
}
|
||||
|
||||
@Test
|
||||
func oldPacketIsStale() {
|
||||
let now = Date(timeIntervalSince1970: 1000)
|
||||
let timestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000)
|
||||
|
||||
#expect(BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: timestamp, now: now))
|
||||
}
|
||||
|
||||
@Test
|
||||
func futurePacketAgeIsZero() {
|
||||
let now = Date(timeIntervalSince1970: 1000)
|
||||
let timestamp = UInt64((now.timeIntervalSince1970 + 10) * 1000)
|
||||
|
||||
#expect(BLEPacketFreshnessPolicy.ageSeconds(timestampMilliseconds: timestamp, now: now) == 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEPeerEventDebouncerTests {
|
||||
@Test
|
||||
func suppressesRepeatedPeerEventsInsideInterval() {
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
var debouncer = BLEPeerEventDebouncer()
|
||||
|
||||
let first = debouncer.shouldEmit(peerID: peerID, now: now, minimumInterval: 5)
|
||||
let suppressed = debouncer.shouldEmit(peerID: peerID, now: now.addingTimeInterval(4.9), minimumInterval: 5)
|
||||
let afterInterval = debouncer.shouldEmit(peerID: peerID, now: now.addingTimeInterval(5), minimumInterval: 5)
|
||||
|
||||
#expect(first)
|
||||
#expect(!suppressed)
|
||||
#expect(afterInterval)
|
||||
}
|
||||
|
||||
@Test
|
||||
func tracksPeersIndependently() {
|
||||
let first = PeerID(str: "1122334455667788")
|
||||
let second = PeerID(str: "8877665544332211")
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
var debouncer = BLEPeerEventDebouncer()
|
||||
|
||||
let firstEmit = debouncer.shouldEmit(peerID: first, now: now, minimumInterval: 5)
|
||||
let secondEmit = debouncer.shouldEmit(peerID: second, now: now.addingTimeInterval(1), minimumInterval: 5)
|
||||
let firstRepeat = debouncer.shouldEmit(peerID: first, now: now.addingTimeInterval(1), minimumInterval: 5)
|
||||
|
||||
#expect(firstEmit)
|
||||
#expect(secondEmit)
|
||||
#expect(!firstRepeat)
|
||||
#expect(debouncer.count == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func removeAllClearsDebounceState() {
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
var debouncer = BLEPeerEventDebouncer()
|
||||
|
||||
debouncer.shouldEmit(peerID: peerID, now: now, minimumInterval: 5)
|
||||
debouncer.removeAll()
|
||||
|
||||
#expect(debouncer.count == 0)
|
||||
let afterClear = debouncer.shouldEmit(peerID: peerID, now: now.addingTimeInterval(1), minimumInterval: 5)
|
||||
#expect(afterClear)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEPeerPublishCoalescerTests {
|
||||
@Test
|
||||
func firstRequestPublishesImmediately() {
|
||||
var coalescer = BLEPeerPublishCoalescer(minimumInterval: 0.1)
|
||||
|
||||
#expect(coalescer.requestPublish(now: Date(timeIntervalSince1970: 100)) == .publishNow)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rapidRequestSchedulesAfterRemainingInterval() {
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
var coalescer = BLEPeerPublishCoalescer(minimumInterval: 0.1)
|
||||
|
||||
_ = coalescer.requestPublish(now: now)
|
||||
|
||||
if case .schedule(let delay) = coalescer.requestPublish(now: now.addingTimeInterval(0.04)) {
|
||||
#expect(abs(delay - 0.06) < 0.001)
|
||||
} else {
|
||||
Issue.record("Expected a scheduled publish")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func requestSkipsWhilePublishIsAlreadyPending() {
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
var coalescer = BLEPeerPublishCoalescer(minimumInterval: 0.1)
|
||||
|
||||
_ = coalescer.requestPublish(now: now)
|
||||
_ = coalescer.requestPublish(now: now.addingTimeInterval(0.04))
|
||||
|
||||
#expect(coalescer.requestPublish(now: now.addingTimeInterval(0.05)) == .skip)
|
||||
}
|
||||
|
||||
@Test
|
||||
func elapsedRequestPublishesImmediatelyEvenWithPendingPublish() {
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
var coalescer = BLEPeerPublishCoalescer(minimumInterval: 0.1)
|
||||
|
||||
_ = coalescer.requestPublish(now: now)
|
||||
_ = coalescer.requestPublish(now: now.addingTimeInterval(0.04))
|
||||
|
||||
#expect(coalescer.requestPublish(now: now.addingTimeInterval(0.11)) == .publishNow)
|
||||
}
|
||||
|
||||
@Test
|
||||
func scheduledPublishFiredClearsPendingState() {
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
var coalescer = BLEPeerPublishCoalescer(minimumInterval: 0.1)
|
||||
|
||||
_ = coalescer.requestPublish(now: now)
|
||||
_ = coalescer.requestPublish(now: now.addingTimeInterval(0.04))
|
||||
coalescer.scheduledPublishFired(now: now.addingTimeInterval(0.1))
|
||||
|
||||
if case .schedule(let delay) = coalescer.requestPublish(now: now.addingTimeInterval(0.15)) {
|
||||
#expect(abs(delay - 0.05) < 0.001)
|
||||
} else {
|
||||
Issue.record("Expected a scheduled publish")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEPublicMessagePolicyTests {
|
||||
@Test
|
||||
func selfAuthoredNonSyncReplayIsRejected() {
|
||||
let localPeerID = PeerID(str: "1122334455667788")
|
||||
let packet = makePacket(sender: localPeerID, ttl: 3)
|
||||
|
||||
let decision = BLEPublicMessagePolicy.evaluate(
|
||||
packet: packet,
|
||||
from: localPeerID,
|
||||
localPeerID: localPeerID,
|
||||
now: Date(timeIntervalSince1970: 1_000)
|
||||
)
|
||||
|
||||
#expect(decision == .reject(.selfEcho))
|
||||
}
|
||||
|
||||
@Test
|
||||
func selfAuthoredSyncReplayIsAccepted() {
|
||||
let localPeerID = PeerID(str: "1122334455667788")
|
||||
let packet = makePacket(sender: localPeerID, ttl: 0)
|
||||
|
||||
let decision = BLEPublicMessagePolicy.evaluate(
|
||||
packet: packet,
|
||||
from: localPeerID,
|
||||
localPeerID: localPeerID,
|
||||
now: Date(timeIntervalSince1970: 1_000)
|
||||
)
|
||||
|
||||
#expect(decision == .accept(BLEPublicMessageAcceptance(shouldTrackForSync: true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func staleBroadcastIsRejectedWithAge() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let sender = PeerID(str: "8877665544332211")
|
||||
let packet = makePacket(
|
||||
sender: sender,
|
||||
timestamp: UInt64((now.timeIntervalSince1970 - 901) * 1000),
|
||||
recipientID: nil
|
||||
)
|
||||
|
||||
let decision = BLEPublicMessagePolicy.evaluate(
|
||||
packet: packet,
|
||||
from: sender,
|
||||
localPeerID: PeerID(str: "1122334455667788"),
|
||||
now: now
|
||||
)
|
||||
|
||||
#expect(decision == .reject(.staleBroadcast(ageSeconds: 901)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func staleDirectedMessageIsAcceptedAndNotTrackedForSync() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let sender = PeerID(str: "8877665544332211")
|
||||
let recipient = PeerID(str: "1122334455667788")
|
||||
let packet = makePacket(
|
||||
sender: sender,
|
||||
timestamp: UInt64((now.timeIntervalSince1970 - 901) * 1000),
|
||||
recipientID: Data(hexString: recipient.id)
|
||||
)
|
||||
|
||||
let decision = BLEPublicMessagePolicy.evaluate(
|
||||
packet: packet,
|
||||
from: sender,
|
||||
localPeerID: recipient,
|
||||
now: now
|
||||
)
|
||||
|
||||
#expect(decision == .accept(BLEPublicMessageAcceptance(shouldTrackForSync: false)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func freshBroadcastMessageIsTrackedForSync() {
|
||||
let sender = PeerID(str: "8877665544332211")
|
||||
let packet = makePacket(sender: sender)
|
||||
|
||||
let decision = BLEPublicMessagePolicy.evaluate(
|
||||
packet: packet,
|
||||
from: sender,
|
||||
localPeerID: PeerID(str: "1122334455667788"),
|
||||
now: Date(timeIntervalSince1970: 1_000)
|
||||
)
|
||||
|
||||
#expect(decision == .accept(BLEPublicMessageAcceptance(shouldTrackForSync: true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func broadcastNonMessageIsNotTrackedForSync() {
|
||||
let sender = PeerID(str: "8877665544332211")
|
||||
let packet = makePacket(sender: sender, type: .leave)
|
||||
|
||||
let decision = BLEPublicMessagePolicy.evaluate(
|
||||
packet: packet,
|
||||
from: sender,
|
||||
localPeerID: PeerID(str: "1122334455667788"),
|
||||
now: Date(timeIntervalSince1970: 1_000)
|
||||
)
|
||||
|
||||
#expect(decision == .accept(BLEPublicMessageAcceptance(shouldTrackForSync: false)))
|
||||
}
|
||||
|
||||
private func makePacket(
|
||||
sender: PeerID,
|
||||
type: MessageType = .message,
|
||||
timestamp: UInt64 = 900_000,
|
||||
ttl: UInt8 = 3,
|
||||
recipientID: Data? = nil
|
||||
) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: type.rawValue,
|
||||
senderID: Data(hexString: sender.id) ?? Data(),
|
||||
recipientID: recipientID,
|
||||
timestamp: timestamp,
|
||||
payload: Data("Hello".utf8),
|
||||
signature: nil,
|
||||
ttl: ttl
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("BLE route forwarding policy tests")
|
||||
struct BLERouteForwardingPolicyTests {
|
||||
@Test("local recipient suppresses flood relay")
|
||||
func localRecipientSuppressesFloodRelay() {
|
||||
let local = peer("1111111111111111")
|
||||
let remote = peer("2222222222222222")
|
||||
let packet = makePacket(sender: remote, recipient: local)
|
||||
|
||||
let plan = forwardingPlan(packet, local: local)
|
||||
|
||||
#expect(plan.shouldSuppressFloodRelay)
|
||||
#expect(plan.forwardPacket == nil)
|
||||
#expect(plan.nextHop == nil)
|
||||
}
|
||||
|
||||
@Test("unrouted packets are left for flood relay")
|
||||
func unroutedPacketAllowsFloodRelay() {
|
||||
let local = peer("1111111111111111")
|
||||
let remote = peer("2222222222222222")
|
||||
let packet = makePacket(sender: remote, recipient: peer("3333333333333333"), route: nil)
|
||||
|
||||
let plan = forwardingPlan(packet, local: local)
|
||||
|
||||
#expect(!plan.shouldSuppressFloodRelay)
|
||||
#expect(plan.forwardPacket == nil)
|
||||
#expect(plan.nextHop == nil)
|
||||
}
|
||||
|
||||
@Test("origin forwards routed packet to first hop")
|
||||
func originForwardsToFirstHop() {
|
||||
let local = peer("1111111111111111")
|
||||
let firstHop = peer("2222222222222222")
|
||||
let destination = peer("3333333333333333")
|
||||
let packet = makePacket(sender: local, recipient: destination, ttl: 6, route: [routeData(firstHop)])
|
||||
|
||||
let plan = forwardingPlan(packet, local: local, connected: [firstHop])
|
||||
|
||||
#expect(plan.shouldSuppressFloodRelay)
|
||||
#expect(plan.nextHop == firstHop)
|
||||
#expect(plan.forwardPacket?.ttl == 5)
|
||||
}
|
||||
|
||||
@Test("origin leaves packet for flood relay when first hop is unavailable")
|
||||
func originAllowsFloodWhenFirstHopUnavailable() {
|
||||
let local = peer("1111111111111111")
|
||||
let firstHop = peer("2222222222222222")
|
||||
let destination = peer("3333333333333333")
|
||||
let packet = makePacket(sender: local, recipient: destination, route: [routeData(firstHop)])
|
||||
|
||||
let plan = forwardingPlan(packet, local: local, connected: [])
|
||||
|
||||
#expect(!plan.shouldSuppressFloodRelay)
|
||||
#expect(plan.forwardPacket == nil)
|
||||
#expect(plan.nextHop == nil)
|
||||
}
|
||||
|
||||
@Test("intermediate forwards routed packet to next route hop")
|
||||
func intermediateForwardsToNextHop() {
|
||||
let previous = peer("1111111111111111")
|
||||
let local = peer("2222222222222222")
|
||||
let nextHop = peer("3333333333333333")
|
||||
let destination = peer("4444444444444444")
|
||||
let packet = makePacket(
|
||||
sender: previous,
|
||||
recipient: destination,
|
||||
ttl: 4,
|
||||
route: [routeData(local), routeData(nextHop)]
|
||||
)
|
||||
|
||||
let plan = forwardingPlan(packet, local: local, connected: [nextHop])
|
||||
|
||||
#expect(plan.shouldSuppressFloodRelay)
|
||||
#expect(plan.nextHop == nextHop)
|
||||
#expect(plan.forwardPacket?.ttl == 3)
|
||||
}
|
||||
|
||||
@Test("last intermediate forwards routed packet to destination")
|
||||
func lastIntermediateForwardsToDestination() {
|
||||
let previous = peer("1111111111111111")
|
||||
let local = peer("2222222222222222")
|
||||
let destination = peer("3333333333333333")
|
||||
let packet = makePacket(
|
||||
sender: previous,
|
||||
recipient: destination,
|
||||
ttl: 4,
|
||||
route: [routeData(local)]
|
||||
)
|
||||
|
||||
let plan = forwardingPlan(packet, local: local, connected: [destination])
|
||||
|
||||
#expect(plan.shouldSuppressFloodRelay)
|
||||
#expect(plan.nextHop == destination)
|
||||
#expect(plan.forwardPacket?.ttl == 3)
|
||||
}
|
||||
|
||||
@Test("expired routed packets suppress further relay")
|
||||
func expiredRoutedPacketSuppressesRelay() {
|
||||
let local = peer("1111111111111111")
|
||||
let firstHop = peer("2222222222222222")
|
||||
let destination = peer("3333333333333333")
|
||||
let packet = makePacket(sender: local, recipient: destination, ttl: 1, route: [routeData(firstHop)])
|
||||
|
||||
let plan = forwardingPlan(packet, local: local, connected: [firstHop])
|
||||
|
||||
#expect(plan.shouldSuppressFloodRelay)
|
||||
#expect(plan.forwardPacket == nil)
|
||||
#expect(plan.nextHop == nil)
|
||||
}
|
||||
|
||||
private func forwardingPlan(
|
||||
_ packet: BitchatPacket,
|
||||
local: PeerID,
|
||||
connected: Set<PeerID> = []
|
||||
) -> BLERouteForwardingPlan {
|
||||
BLERouteForwardingPolicy.plan(
|
||||
for: packet,
|
||||
localPeerID: local,
|
||||
localRoutingData: local.routingData,
|
||||
routingPeer: PeerID.init(routingData:),
|
||||
isPeerConnected: { connected.contains($0) }
|
||||
)
|
||||
}
|
||||
|
||||
private func makePacket(
|
||||
sender: PeerID,
|
||||
recipient: PeerID?,
|
||||
ttl: UInt8 = 7,
|
||||
route: [Data]? = []
|
||||
) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: routeData(sender),
|
||||
recipientID: recipient.map { routeData($0) },
|
||||
timestamp: 1,
|
||||
payload: Data([0x01, 0x02]),
|
||||
signature: nil,
|
||||
ttl: ttl,
|
||||
route: route
|
||||
)
|
||||
}
|
||||
|
||||
private func peer(_ id: String) -> PeerID {
|
||||
PeerID(str: id)
|
||||
}
|
||||
|
||||
private func routeData(_ peerID: PeerID) -> Data {
|
||||
peerID.routingData ?? Data()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEScanDutyPolicyTests {
|
||||
@Test
|
||||
func continuousScanWhenDutyIsDisabledInactiveDisconnectedOrTrafficIsRecent() {
|
||||
#expect(BLEScanDutyPolicy.plan(
|
||||
dutyEnabled: false,
|
||||
appIsActive: true,
|
||||
connectedCount: 4,
|
||||
hasRecentTraffic: false
|
||||
) == .continuous)
|
||||
|
||||
#expect(BLEScanDutyPolicy.plan(
|
||||
dutyEnabled: true,
|
||||
appIsActive: false,
|
||||
connectedCount: 4,
|
||||
hasRecentTraffic: false
|
||||
) == .continuous)
|
||||
|
||||
#expect(BLEScanDutyPolicy.plan(
|
||||
dutyEnabled: true,
|
||||
appIsActive: true,
|
||||
connectedCount: 0,
|
||||
hasRecentTraffic: false
|
||||
) == .continuous)
|
||||
|
||||
#expect(BLEScanDutyPolicy.plan(
|
||||
dutyEnabled: true,
|
||||
appIsActive: true,
|
||||
connectedCount: 4,
|
||||
hasRecentTraffic: true
|
||||
) == .continuous)
|
||||
}
|
||||
|
||||
@Test
|
||||
func continuousScanForSmallMeshes() {
|
||||
#expect(BLEScanDutyPolicy.plan(
|
||||
dutyEnabled: true,
|
||||
appIsActive: true,
|
||||
connectedCount: 2,
|
||||
hasRecentTraffic: false
|
||||
) == .continuous)
|
||||
}
|
||||
|
||||
@Test
|
||||
func dutyCyclesSparseActiveMeshes() {
|
||||
let plan = BLEScanDutyPolicy.plan(
|
||||
dutyEnabled: true,
|
||||
appIsActive: true,
|
||||
connectedCount: 3,
|
||||
hasRecentTraffic: false
|
||||
)
|
||||
|
||||
#expect(plan == .dutyCycle(
|
||||
onDuration: TransportConfig.bleDutyOnDuration,
|
||||
offDuration: TransportConfig.bleDutyOffDuration
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func dutyCyclesDenseActiveMeshesWithDenseDurations() {
|
||||
let plan = BLEScanDutyPolicy.plan(
|
||||
dutyEnabled: true,
|
||||
appIsActive: true,
|
||||
connectedCount: TransportConfig.bleHighDegreeThreshold,
|
||||
hasRecentTraffic: false
|
||||
)
|
||||
|
||||
#expect(plan == .dutyCycle(
|
||||
onDuration: TransportConfig.bleDutyOnDurationDense,
|
||||
offDuration: TransportConfig.bleDutyOffDurationDense
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("BLE scheduled relay store tests")
|
||||
struct BLEScheduledRelayStoreTests {
|
||||
@Test("schedule tracks relay count and remove returns work item")
|
||||
func scheduleAndRemove() {
|
||||
var store = BLEScheduledRelayStore()
|
||||
let first = DispatchWorkItem {}
|
||||
let second = DispatchWorkItem {}
|
||||
|
||||
store.schedule(first, messageID: "one")
|
||||
store.schedule(second, messageID: "two")
|
||||
|
||||
#expect(store.count == 2)
|
||||
#expect(!store.isEmpty)
|
||||
let removed = store.remove(messageID: "one")
|
||||
#expect(removed.map { $0 === first } == true)
|
||||
#expect(store.count == 1)
|
||||
let missing = store.remove(messageID: "missing")
|
||||
#expect(missing == nil)
|
||||
}
|
||||
|
||||
@Test("cancel removes and cancels a scheduled relay")
|
||||
func cancelScheduledRelay() {
|
||||
var store = BLEScheduledRelayStore()
|
||||
let workItem = DispatchWorkItem {}
|
||||
|
||||
store.schedule(workItem, messageID: "relay")
|
||||
|
||||
let didCancel = store.cancel(messageID: "relay")
|
||||
#expect(didCancel)
|
||||
#expect(workItem.isCancelled)
|
||||
#expect(store.isEmpty)
|
||||
let didCancelAgain = store.cancel(messageID: "relay")
|
||||
#expect(!didCancelAgain)
|
||||
}
|
||||
|
||||
@Test("cancel all cancels every scheduled relay")
|
||||
func cancelAllScheduledRelays() {
|
||||
var store = BLEScheduledRelayStore()
|
||||
let first = DispatchWorkItem {}
|
||||
let second = DispatchWorkItem {}
|
||||
|
||||
store.schedule(first, messageID: "first")
|
||||
store.schedule(second, messageID: "second")
|
||||
store.cancelAll()
|
||||
|
||||
#expect(first.isCancelled)
|
||||
#expect(second.isCancelled)
|
||||
#expect(store.isEmpty)
|
||||
}
|
||||
|
||||
@Test("capacity guard only removes when over limit")
|
||||
func capacityGuardRemovesOnlyWhenOverLimit() {
|
||||
var store = BLEScheduledRelayStore()
|
||||
|
||||
store.schedule(DispatchWorkItem {}, messageID: "one")
|
||||
store.schedule(DispatchWorkItem {}, messageID: "two")
|
||||
store.removeAllIfOverCapacity(2)
|
||||
#expect(store.count == 2)
|
||||
|
||||
store.schedule(DispatchWorkItem {}, messageID: "three")
|
||||
store.removeAllIfOverCapacity(2)
|
||||
#expect(store.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLESelfBroadcastTrackerTests {
|
||||
@Test
|
||||
func recordAndTakeMessageIDForMatchingPacket() {
|
||||
let packet = makePacket(timestamp: 1234)
|
||||
var tracker = BLESelfBroadcastTracker()
|
||||
|
||||
tracker.record(messageID: "local-message", packet: packet, sentAt: Date())
|
||||
|
||||
#expect(tracker.takeMessageID(for: packet) == "local-message")
|
||||
#expect(tracker.takeMessageID(for: packet) == nil)
|
||||
#expect(tracker.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func differentPacketDoesNotResolveMessageID() {
|
||||
let packet = makePacket(timestamp: 1234)
|
||||
let otherPacket = makePacket(timestamp: 1235)
|
||||
var tracker = BLESelfBroadcastTracker()
|
||||
|
||||
tracker.record(messageID: "local-message", packet: packet, sentAt: Date())
|
||||
|
||||
#expect(tracker.takeMessageID(for: otherPacket) == nil)
|
||||
#expect(tracker.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func pruneRemovesEntriesBeforeCutoff() {
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
let oldPacket = makePacket(timestamp: 1)
|
||||
let freshPacket = makePacket(timestamp: 2)
|
||||
var tracker = BLESelfBroadcastTracker()
|
||||
|
||||
tracker.record(messageID: "old", packet: oldPacket, sentAt: now.addingTimeInterval(-10))
|
||||
tracker.record(messageID: "fresh", packet: freshPacket, sentAt: now)
|
||||
|
||||
tracker.prune(before: now.addingTimeInterval(-5))
|
||||
|
||||
#expect(tracker.takeMessageID(for: oldPacket) == nil)
|
||||
#expect(tracker.takeMessageID(for: freshPacket) == "fresh")
|
||||
}
|
||||
|
||||
@Test
|
||||
func removeAllClearsTrackedMessages() {
|
||||
let packet = makePacket(timestamp: 1234)
|
||||
var tracker = BLESelfBroadcastTracker()
|
||||
|
||||
tracker.record(messageID: "local-message", packet: packet, sentAt: Date())
|
||||
tracker.removeAll()
|
||||
|
||||
#expect(tracker.isEmpty)
|
||||
#expect(tracker.takeMessageID(for: packet) == nil)
|
||||
}
|
||||
|
||||
private func makePacket(timestamp: UInt64) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data(hexString: "8877665544332211") ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: Data("hello".utf8),
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user