import Foundation import Testing import BitFoundation @testable import bitchat struct GossipSyncManagerTests { private let myPeerID = PeerID(str: "0102030405060708") @Test func concurrentPacketIntakeAndSyncRequest() async throws { let requestSyncManager = RequestSyncManager() let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: requestSyncManager) let delegate = RecordingDelegate() manager.delegate = delegate try await confirmation("sync request sent") { sent in delegate.onSend = { delegate.onSend = nil sent() } let iterations = 200 let senderID = try #require(Data(hexString: "1122334455667788")) for i in 0..= baseTimestamp + 12) #expect(since < baseTimestamp + UInt64(totalMessages)) } @Test func fullCoverageFilterOmitsSinceCursor() throws { var config = GossipSyncManager.Config() config.seenCapacity = 100 config.messageSyncIntervalSeconds = 1 config.fragmentSyncIntervalSeconds = 0 config.fileTransferSyncIntervalSeconds = 0 config.maintenanceIntervalSeconds = 0 let requestSyncManager = RequestSyncManager() let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) let delegate = RecordingDelegate() manager.delegate = delegate let sender = try #require(Data(hexString: "1122334455667788")) let packet = BitchatPacket( type: MessageType.message.rawValue, senderID: sender, recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: Data([0x01]), signature: nil, ttl: 1 ) manager.onPublicPacketSeen(packet) manager._performMaintenanceSynchronously(now: Date()) let sent = try #require(delegate.packets.first) let request = try #require(RequestSyncPacket.decode(from: sent.payload)) #expect(request.sinceTimestamp == nil) } @Test func handleRequestSyncHonorsSinceCursorButAlwaysSendsAnnounces() async throws { var config = GossipSyncManager.Config() config.seenCapacity = 5 config.messageSyncIntervalSeconds = 0 config.fragmentSyncIntervalSeconds = 0 config.fileTransferSyncIntervalSeconds = 0 let requestSyncManager = RequestSyncManager() let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) let delegate = RecordingDelegate() manager.delegate = delegate let sender = try #require(Data(hexString: "aabbccddeeff0011")) let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) // Announce older than the cursor: must still be sent (identity is // needed to verify everything else). let announcePacket = BitchatPacket( type: MessageType.announce.rawValue, senderID: sender, recipientID: nil, timestamp: nowMs - 50_000, payload: Data(), signature: nil, ttl: 1 ) let oldMessage = BitchatPacket( type: MessageType.message.rawValue, senderID: sender, recipientID: nil, timestamp: nowMs - 60_000, payload: Data([0x01]), signature: nil, ttl: 1 ) let newMessage = BitchatPacket( type: MessageType.message.rawValue, senderID: sender, recipientID: nil, timestamp: nowMs, payload: Data([0x02]), signature: nil, ttl: 1 ) manager.onPublicPacketSeen(announcePacket) manager.onPublicPacketSeen(oldMessage) manager.onPublicPacketSeen(newMessage) let peer = PeerID(str: "FFFFFFFFFFFFFFFF") let request = RequestSyncPacket( p: 7, m: 1, data: Data(), types: .publicMessages, sinceTimestamp: nowMs - 30_000 ) manager.handleRequestSync(from: peer, request: request) try await TestHelpers.waitFor({ delegate.packets.count == 2 }, timeout: TestConstants.shortTimeout) // Barrier: flush the sync queue so a late third packet would be visible. manager._performMaintenanceSynchronously(now: Date()) let sentPackets = delegate.packets #expect(sentPackets.count == 2) #expect(sentPackets.contains { $0.type == MessageType.announce.rawValue }) let sentMessages = sentPackets.filter { $0.type == MessageType.message.rawValue } #expect(sentMessages.count == 1) #expect(sentMessages.first?.payload == Data([0x02])) #expect(sentPackets.allSatisfy { $0.isRSR }) } @Test func handleRequestSyncIsRateLimitedPerPeer() async throws { var config = GossipSyncManager.Config() config.seenCapacity = 5 config.messageSyncIntervalSeconds = 0 config.fragmentSyncIntervalSeconds = 0 config.fileTransferSyncIntervalSeconds = 0 config.responseRateLimitMaxResponses = 1 config.responseRateLimitWindowSeconds = 60 let requestSyncManager = RequestSyncManager() let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) let delegate = RecordingDelegate() manager.delegate = delegate let sender = try #require(Data(hexString: "aabbccddeeff0011")) let messagePacket = BitchatPacket( type: MessageType.message.rawValue, senderID: sender, recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: Data([0x10]), signature: nil, ttl: 1 ) manager.onPublicPacketSeen(messagePacket) let peer = PeerID(str: "FFFFFFFFFFFFFFFF") let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .message) manager.handleRequestSync(from: peer, request: request) manager.handleRequestSync(from: peer, request: request) try await TestHelpers.waitFor({ delegate.packets.count >= 1 }, timeout: TestConstants.shortTimeout) // Barrier: both requests have been processed once this returns. manager._performMaintenanceSynchronously(now: Date()) #expect(delegate.packets.count == 1) } @Test func initialSyncCoalescesEnabledTypes() async throws { var config = GossipSyncManager.Config() config.seenCapacity = 10 config.fragmentCapacity = 5 config.fileTransferCapacity = 4 config.fragmentSyncIntervalSeconds = 1 config.fileTransferSyncIntervalSeconds = 1 let requestSyncManager = RequestSyncManager() let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) let delegate = RecordingDelegate() manager.delegate = delegate manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0) try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) let packet = try #require(delegate.packets.first) let request = try #require(RequestSyncPacket.decode(from: packet.payload)) let types = try #require(request.types) #expect(types.contains(.announce)) #expect(types.contains(.message)) #expect(types.contains(.fragment)) #expect(types.contains(.fileTransfer)) } @Test func handleRequestSyncHonorsTypeFilter() async throws { var config = GossipSyncManager.Config() config.seenCapacity = 5 config.fragmentCapacity = 5 config.fileTransferCapacity = 0 config.messageSyncIntervalSeconds = 0 config.fragmentSyncIntervalSeconds = 0 config.fileTransferSyncIntervalSeconds = 0 let requestSyncManager = RequestSyncManager() let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) let delegate = RecordingDelegate() manager.delegate = delegate let sender = try #require(Data(hexString: "aabbccddeeff0011")) let now = UInt64(Date().timeIntervalSince1970 * 1000) let messagePacket = BitchatPacket( type: MessageType.message.rawValue, senderID: sender, recipientID: nil, timestamp: now, payload: Data([0x10]), signature: nil, ttl: 1 ) let fragmentPacket = BitchatPacket( type: MessageType.fragment.rawValue, senderID: sender, recipientID: nil, timestamp: now, payload: Data([0x20]), signature: nil, ttl: 1 ) manager.onPublicPacketSeen(messagePacket) manager.onPublicPacketSeen(fragmentPacket) let peer = PeerID(str: "FFFFFFFFFFFFFFFF") let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment) manager.handleRequestSync(from: peer, request: request) try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) let sentPackets = delegate.packets #expect(sentPackets.count == 1) #expect(sentPackets[0].type == MessageType.fragment.rawValue) } // MARK: - Archive persistence @Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("gossip-archive-\(UUID().uuidString).json") defer { try? FileManager.default.removeItem(at: fileURL) } let senderID = try #require(Data(hexString: "1122334455667788")) let packet = BitchatPacket( type: MessageType.message.rawValue, senderID: senderID, recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: Data([0x01, 0x02]), signature: nil, ttl: 1 ) let first = GossipSyncManager( myPeerID: myPeerID, requestSyncManager: RequestSyncManager(), archive: GossipMessageArchive(fileURL: fileURL) ) first.onPublicPacketSeen(packet) // Maintenance persists the dirty store to disk. first._performMaintenanceSynchronously(now: Date()) #expect(FileManager.default.fileExists(atPath: fileURL.path)) // "App restart": a fresh manager over the same archive re-serves it. let second = GossipSyncManager( myPeerID: myPeerID, requestSyncManager: RequestSyncManager(), archive: GossipMessageArchive(fileURL: fileURL) ) let restored = await TestHelpers.waitUntil( { second._messageCount(for: PeerID(hexData: senderID)) == 1 }, timeout: TestConstants.shortTimeout ) #expect(restored) } @Test func archiveDropsMessagesOlderThanPublicWindow() throws { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("gossip-archive-\(UUID().uuidString).json") defer { try? FileManager.default.removeItem(at: fileURL) } var config = GossipSyncManager.Config() config.publicMessageMaxAgeSeconds = 60 let senderID = try #require(Data(hexString: "1122334455667788")) let stale = BitchatPacket( type: MessageType.message.rawValue, senderID: senderID, recipientID: nil, timestamp: UInt64((Date().timeIntervalSince1970 - 120) * 1000), payload: Data([0x01]), signature: nil, ttl: 1 ) let archive = GossipMessageArchive(fileURL: fileURL) archive.save([stale.toBinaryData(padding: false)!]) let manager = GossipSyncManager( myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager(), archive: archive ) manager._performMaintenanceSynchronously(now: Date()) #expect(manager._messageCount(for: PeerID(hexData: senderID)) == 0) } } private final class RecordingDelegate: GossipSyncManager.Delegate { var onSend: (() -> Void)? private(set) var lastPacket: BitchatPacket? private(set) var packets: [BitchatPacket] = [] private let lock = NSLock() func sendPacket(_ packet: BitchatPacket) { lock.lock() lastPacket = packet packets.append(packet) lock.unlock() onSend?() } func sendPacket(to peerID: PeerID, packet: BitchatPacket) { sendPacket(packet) } func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket { packet } func getConnectedPeers() -> [PeerID] { return [] } }