Extract BLE and chat architecture policies (#1324)

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2026-06-02 09:01:59 +02:00
committed by GitHub
co-authored by jack
parent 193cfdc06a
commit 3eb4f2bd72
46 changed files with 3286 additions and 786 deletions
@@ -0,0 +1,40 @@
import CoreBluetooth
import Testing
@testable import bitchat
struct ChatBluetoothAlertPolicyTests {
@Test
func poweredOffShowsAlertMessage() {
let update = ChatBluetoothAlertPolicy.update(for: .poweredOff)
#expect(update.isPresented)
#expect(update.message?.isEmpty == false)
}
@Test
func unauthorizedShowsAlertMessage() {
let update = ChatBluetoothAlertPolicy.update(for: .unauthorized)
#expect(update.isPresented)
#expect(update.message?.isEmpty == false)
}
@Test
func poweredOnHidesAndClearsAlertMessage() {
let update = ChatBluetoothAlertPolicy.update(for: .poweredOn)
#expect(!update.isPresented)
#expect(update.message == "")
}
@Test
func transientStatesHideWithoutChangingAlertMessage() {
let unknown = ChatBluetoothAlertPolicy.update(for: .unknown)
let resetting = ChatBluetoothAlertPolicy.update(for: .resetting)
#expect(!unknown.isPresented)
#expect(unknown.message == nil)
#expect(!resetting.isPresented)
#expect(resetting.message == nil)
}
}
@@ -0,0 +1,70 @@
import Testing
@testable import bitchat
struct ChatFavoriteTogglePolicyTests {
@Test
func addingWithoutExistingStatusUsesFallbackNicknameAndBridgeKey() {
let plan = ChatFavoriteTogglePolicy.plan(
currentStatus: nil,
fallbackNickname: "alice",
bridgedNostrKey: "npub-alice"
)
#expect(plan == ChatFavoriteTogglePlan(
persistenceAction: .add(nickname: "alice", nostrKey: "npub-alice"),
notification: .none
))
}
@Test
func addingMutualFavoriteSendsPositiveNotification() {
let plan = ChatFavoriteTogglePolicy.plan(
currentStatus: ChatFavoriteStatusSnapshot(
peerNickname: "alice",
peerNostrPublicKey: "npub-current",
isFavorite: false,
theyFavoritedUs: true
),
fallbackNickname: "fallback",
bridgedNostrKey: "npub-bridge"
)
#expect(plan == ChatFavoriteTogglePlan(
persistenceAction: .add(nickname: "alice", nostrKey: "npub-current"),
notification: .send(isFavorite: true)
))
}
@Test
func addingWithoutAnyNicknameUsesUnknown() {
let plan = ChatFavoriteTogglePolicy.plan(
currentStatus: nil,
fallbackNickname: nil,
bridgedNostrKey: nil
)
#expect(plan == ChatFavoriteTogglePlan(
persistenceAction: .add(nickname: "Unknown", nostrKey: nil),
notification: .none
))
}
@Test
func removingFavoriteSendsNegativeNotification() {
let plan = ChatFavoriteTogglePolicy.plan(
currentStatus: ChatFavoriteStatusSnapshot(
peerNickname: "alice",
peerNostrPublicKey: "npub-current",
isFavorite: true,
theyFavoritedUs: false
),
fallbackNickname: nil,
bridgedNostrKey: nil
)
#expect(plan == ChatFavoriteTogglePlan(
persistenceAction: .remove,
notification: .send(isFavorite: false)
))
}
}
@@ -0,0 +1,92 @@
import BitFoundation
import Foundation
import Testing
#if os(iOS)
import UIKit
#else
import AppKit
#endif
@testable import bitchat
struct ChatMediaPreparationTests {
@Test
func prepareVoiceNotePacket_buildsEncodedAudioPacket() throws {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url, options: .atomic)
defer { try? FileManager.default.removeItem(at: url) }
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
#expect(packet.fileName == url.lastPathComponent)
#expect(packet.mimeType == "audio/mp4")
#expect(packet.fileSize == 5)
#expect(packet.encode() != nil)
}
@Test
func prepareVoiceNotePacket_rejectsOversizedAudio() throws {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-too-large-\(UUID().uuidString).m4a")
try Data(repeating: 0x55, count: FileTransferLimits.maxVoiceNoteBytes + 1).write(to: url, options: .atomic)
defer { try? FileManager.default.removeItem(at: url) }
#expect(throws: ChatMediaPreparationError.voiceNoteTooLarge(bytes: FileTransferLimits.maxVoiceNoteBytes + 1)) {
try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
}
}
@Test
func prepareImagePacket_rejectsInvalidImage() throws {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("invalid-\(UUID().uuidString).jpg")
try Data("not-an-image".utf8).write(to: url, options: .atomic)
defer { try? FileManager.default.removeItem(at: url) }
#expect(throws: ImageUtilsError.invalidImage) {
try ChatMediaPreparation.prepareImagePacket(from: url)
}
}
@Test
func prepareImagePacket_buildsEncodedJpegPacket() throws {
let sourceURL = try makeTemporaryImageURL()
defer { try? FileManager.default.removeItem(at: sourceURL) }
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
defer { try? FileManager.default.removeItem(at: prepared.outputURL) }
#expect(prepared.packet.fileName == prepared.outputURL.lastPathComponent)
#expect(prepared.packet.mimeType == "image/jpeg")
#expect(prepared.packet.fileSize == UInt64(prepared.packet.content.count))
#expect(prepared.packet.encode() != nil)
}
}
private func makeTemporaryImageURL() throws -> URL {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("image-\(UUID().uuidString).png")
#if os(iOS)
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 64))
let image = renderer.image { context in
UIColor.systemTeal.setFill()
context.fill(CGRect(x: 0, y: 0, width: 64, height: 64))
}
guard let data = image.pngData() else {
throw ChatMediaPreparationTestError.imageEncodingFailed
}
#else
let image = NSImage(size: NSSize(width: 64, height: 64))
image.lockFocus()
NSColor.systemTeal.setFill()
NSRect(x: 0, y: 0, width: 64, height: 64).fill()
image.unlockFocus()
guard let tiff = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiff),
let data = bitmap.representation(using: .png, properties: [:]) else {
throw ChatMediaPreparationTestError.imageEncodingFailed
}
#endif
try data.write(to: url, options: .atomic)
return url
}
private enum ChatMediaPreparationTestError: Error {
case imageEncodingFailed
}
@@ -0,0 +1,115 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
struct ChatUnreadStateResolverTests {
@Test
func directUnreadPeerReturnsTrue() {
let peerID = PeerID(str: "peer-a")
let context = ChatUnreadPeerContext(
peerID: peerID,
noiseKeyPeerID: nil,
nostrPeerID: nil,
nickname: nil
)
#expect(ChatUnreadStateResolver.hasUnreadMessages(
for: context,
unreadPrivateMessages: [peerID],
privateChats: [:]
))
}
@Test
func stableNoiseKeyUnreadReturnsTrue() {
let peerID = PeerID(str: "ephemeral")
let stableID = PeerID(str: "stable")
let context = ChatUnreadPeerContext(
peerID: peerID,
noiseKeyPeerID: stableID,
nostrPeerID: nil,
nickname: nil
)
#expect(ChatUnreadStateResolver.hasUnreadMessages(
for: context,
unreadPrivateMessages: [stableID],
privateChats: [:]
))
}
@Test
func nostrConversationUnreadReturnsTrue() {
let peerID = PeerID(str: "ephemeral")
let nostrID = PeerID(nostr_: "abcdef")
let context = ChatUnreadPeerContext(
peerID: peerID,
noiseKeyPeerID: nil,
nostrPeerID: nostrID,
nickname: nil
)
#expect(ChatUnreadStateResolver.hasUnreadMessages(
for: context,
unreadPrivateMessages: [nostrID],
privateChats: [:]
))
}
@Test
func temporaryGeoDMWithMatchingNicknameReturnsTrue() {
let peerID = PeerID(str: "mesh-peer")
let geoDM = PeerID(nostr_: "0123456789abcdef")
let context = ChatUnreadPeerContext(
peerID: peerID,
noiseKeyPeerID: nil,
nostrPeerID: nil,
nickname: "Alice"
)
let message = BitchatMessage(
sender: "alice",
content: "hi",
timestamp: Date(timeIntervalSince1970: 1),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "me",
senderPeerID: geoDM
)
#expect(ChatUnreadStateResolver.hasUnreadMessages(
for: context,
unreadPrivateMessages: [geoDM],
privateChats: [geoDM: [message]]
))
}
@Test
func unmatchedUnreadStateReturnsFalse() {
let peerID = PeerID(str: "mesh-peer")
let geoDM = PeerID(nostr_: "0123456789abcdef")
let context = ChatUnreadPeerContext(
peerID: peerID,
noiseKeyPeerID: nil,
nostrPeerID: nil,
nickname: "Alice"
)
let message = BitchatMessage(
sender: "bob",
content: "hi",
timestamp: Date(timeIntervalSince1970: 1),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "me",
senderPeerID: geoDM
)
#expect(!ChatUnreadStateResolver.hasUnreadMessages(
for: context,
unreadPrivateMessages: [geoDM],
privateChats: [geoDM: [message]]
))
}
}
@@ -797,6 +797,7 @@ struct ChatViewModelGeoDMTests {
}
}
@Suite(.serialized)
struct ChatViewModelMediaTransferTests {
@Test @MainActor
@@ -931,7 +932,7 @@ struct ChatViewModelMediaTransferTests {
let didNotify = await TestHelpers.waitUntil({
viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
}, timeout: 2.0)
}, timeout: 5.0)
#expect(didNotify)
#expect(transport.sentPrivateFiles.isEmpty)
#expect(viewModel.privateChats[peerID]?.isEmpty != false)
@@ -0,0 +1,30 @@
import Foundation
import Testing
@testable import bitchat
struct Base64URLCodingTests {
@Test
func encodesWithoutPaddingOrURLUnsafeCharacters() {
let encoded = Base64URLCoding.encode(Data([0xff, 0xee, 0xdd, 0xcc]))
#expect(!encoded.contains("="))
#expect(!encoded.contains("+"))
#expect(!encoded.contains("/"))
}
@Test
func decodesUnpaddedValue() throws {
let data = try #require(Base64URLCoding.decode("_-7dzA"))
#expect(data == Data([0xff, 0xee, 0xdd, 0xcc]))
}
@Test
func roundTripsData() throws {
let original = Data("hello bitchat".utf8)
let decoded = try #require(Base64URLCoding.decode(Base64URLCoding.encode(original)))
#expect(decoded == original)
}
}
@@ -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
)
}
}