Expand coverage for transport, chat, and media flows (#1056)

* Expand coverage for transport, chat, and media flows

* Stabilize transport and media coverage tests

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2026-03-12 16:20:19 -10:00
committed by GitHub
co-authored by jack
parent a136b5b7e9
commit 7e86d2061f
17 changed files with 2841 additions and 110 deletions
@@ -0,0 +1,50 @@
import Foundation
import Testing
@testable import bitchat
@Suite("AutocompleteService Tests")
struct AutocompleteServiceTests {
@Test("Mention suggestions are sorted, capped, and include replacement range")
func mentionSuggestionsAreSortedAndCapped() {
let service = AutocompleteService()
let text = "hi @al"
let result = service.getSuggestions(
for: text,
peers: ["zoe", "alice", "albert", "bob", "alex", "ally", "alpha"],
cursorPosition: text.count
)
#expect(result.suggestions == ["@albert", "@alex", "@alice", "@ally", "@alpha"])
#expect(result.range == NSRange(location: 3, length: 3))
}
@Test("Suggestions are empty when cursor is not at a trailing mention")
func suggestionsRequireTrailingMentionContext() {
let service = AutocompleteService()
let text = "hi @al there"
let result = service.getSuggestions(
for: text,
peers: ["alice", "albert"],
cursorPosition: text.count
)
#expect(result.suggestions.isEmpty)
#expect(result.range == nil)
}
@Test("Applying suggestions replaces the range and adds command spacing only when needed")
func applySuggestionReplacesRangeAndHandlesCommandSpacing() {
let service = AutocompleteService()
let mentionResult = service.applySuggestion("@alice", to: "hi @al", range: NSRange(location: 3, length: 3))
let msgCommand = service.applySuggestion("/msg", to: "/m", range: NSRange(location: 0, length: 2))
let clearCommand = service.applySuggestion("/clear", to: "/c", range: NSRange(location: 0, length: 2))
#expect(mentionResult == "hi @alice")
#expect(msgCommand == "/msg ")
#expect(clearCommand == "/clear")
}
}
@@ -0,0 +1,231 @@
import Foundation
import Testing
@testable import bitchat
@Suite("NoiseEncryptionService Tests")
struct NoiseEncryptionServiceTests {
@Test("Encryption status accessors cover all cases")
func encryptionStatusAccessorsCoverAllCases() {
#expect(EncryptionStatus.none.icon == "lock.slash")
#expect(EncryptionStatus.noHandshake.icon == nil)
#expect(EncryptionStatus.noiseHandshaking.icon == "lock.rotation")
#expect(EncryptionStatus.noiseSecured.icon == "lock.fill")
#expect(EncryptionStatus.noiseVerified.icon == "checkmark.seal.fill")
#expect(!EncryptionStatus.none.description.isEmpty)
#expect(!EncryptionStatus.noHandshake.description.isEmpty)
#expect(!EncryptionStatus.noiseHandshaking.description.isEmpty)
#expect(!EncryptionStatus.noiseSecured.description.isEmpty)
#expect(!EncryptionStatus.noiseVerified.description.isEmpty)
#expect(!EncryptionStatus.none.accessibilityDescription.isEmpty)
#expect(!EncryptionStatus.noHandshake.accessibilityDescription.isEmpty)
#expect(!EncryptionStatus.noiseHandshaking.accessibilityDescription.isEmpty)
#expect(!EncryptionStatus.noiseSecured.accessibilityDescription.isEmpty)
#expect(!EncryptionStatus.noiseVerified.accessibilityDescription.isEmpty)
}
@Test("Announce and packet signatures round-trip and detect tampering")
func announceAndPacketSignaturesRoundTrip() throws {
let service = NoiseEncryptionService(keychain: MockKeychain())
let signingPublicKey = service.getSigningPublicKeyData()
let noisePublicKey = service.getStaticPublicKeyData()
let signature = try #require(
service.buildAnnounceSignature(
peerID: Data([0xAA, 0xBB]),
noiseKey: noisePublicKey,
ed25519Key: signingPublicKey,
nickname: "Alice",
timestampMs: 12345
),
"Expected announce signature"
)
#expect(
service.verifyAnnounceSignature(
signature: signature,
peerID: Data([0xAA, 0xBB]),
noiseKey: noisePublicKey,
ed25519Key: signingPublicKey,
nickname: "Alice",
timestampMs: 12345,
publicKey: signingPublicKey
)
)
#expect(
!service.verifyAnnounceSignature(
signature: signature,
peerID: Data([0xAA, 0xBB]),
noiseKey: noisePublicKey,
ed25519Key: signingPublicKey,
nickname: "Mallory",
timestampMs: 12345,
publicKey: signingPublicKey
)
)
#expect(!service.verifySignature(signature, for: Data("data".utf8), publicKey: Data([1, 2, 3])))
let packet = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data([0, 1, 2, 3, 4, 5, 6, 7]),
recipientID: nil,
timestamp: 42,
payload: Data("payload".utf8),
signature: nil,
ttl: 7
)
let signedPacket = try #require(service.signPacket(packet), "Expected signed packet")
#expect(service.verifyPacketSignature(signedPacket, publicKey: signingPublicKey))
#expect(!service.verifyPacketSignature(packet, publicKey: signingPublicKey))
var tampered = signedPacket
tampered.signature = Data(repeating: 0xFF, count: 64)
#expect(!service.verifyPacketSignature(tampered, publicKey: signingPublicKey))
}
@Test("Service-level handshake, encryption, and fingerprint lifecycle work")
func handshakeEncryptionAndFingerprintLifecycle() async throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(str: "0011223344556677")
let bobPeerID = PeerID(str: "8899aabbccddeeff")
let recorder = AuthenticationRecorder()
#expect(alice.onPeerAuthenticated == nil)
alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:)
try establishSessions(alice: alice, bob: bob, alicePeerID: alicePeerID, bobPeerID: bobPeerID)
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 0.5)
#expect(authenticated)
#expect(alice.hasEstablishedSession(with: alicePeerID))
#expect(bob.hasEstablishedSession(with: bobPeerID))
#expect(alice.hasSession(with: alicePeerID))
#expect(bob.hasSession(with: bobPeerID))
#expect(alice.getPeerPublicKeyData(alicePeerID)?.count == 32)
#expect(bob.getPeerPublicKeyData(bobPeerID)?.count == 32)
#expect(alice.getPeerFingerprint(alicePeerID) != nil)
#expect(bob.getPeerFingerprint(bobPeerID) != nil)
let plaintext = Data("secret payload".utf8)
let ciphertext = try alice.encrypt(plaintext, for: alicePeerID)
let decrypted = try bob.decrypt(ciphertext, from: bobPeerID)
#expect(decrypted == plaintext)
alice.clearSession(for: alicePeerID)
#expect(!alice.hasSession(with: alicePeerID))
#expect(alice.getPeerFingerprint(alicePeerID) == nil)
bob.clearEphemeralStateForPanic()
#expect(!bob.hasSession(with: bobPeerID))
#expect(bob.getPeerFingerprint(bobPeerID) == nil)
}
@Test("Encrypt without a session requests handshake and decrypt without session fails")
func handshakeRequiredAndSessionNotEstablishedErrors() throws {
let service = NoiseEncryptionService(keychain: MockKeychain())
let peerID = PeerID(str: "1021324354657687")
var requestedPeerID: PeerID?
service.onHandshakeRequired = { requestedPeerID = $0 }
do {
_ = try service.encrypt(Data("hello".utf8), for: peerID)
Issue.record("Expected handshakeRequired error")
} catch NoiseEncryptionError.handshakeRequired {
#expect(requestedPeerID == peerID)
} catch {
Issue.record("Unexpected error: \(error)")
}
do {
_ = try service.decrypt(Data("hello".utf8), from: peerID)
Issue.record("Expected sessionNotEstablished error")
} catch NoiseEncryptionError.sessionNotEstablished {
// Expected
} catch {
Issue.record("Unexpected error: \(error)")
}
}
@Test("Clearing persistent identity removes saved keys")
func clearPersistentIdentityRemovesSavedKeys() {
let keychain = MockKeychain()
let service = NoiseEncryptionService(keychain: keychain)
#expect(service.getStaticPublicKeyData().count == 32)
#expect(service.getSigningPublicKeyData().count == 32)
service.clearPersistentIdentity()
if case .itemNotFound = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey") {
} else {
Issue.record("Expected noiseStaticKey to be removed")
}
if case .itemNotFound = keychain.getIdentityKeyWithResult(forKey: "ed25519SigningKey") {
} else {
Issue.record("Expected ed25519SigningKey to be removed")
}
}
@Test("NoiseMessage JSON and binary encoding round-trip")
func noiseMessageRoundTrips() throws {
let message = NoiseMessage(
type: .encryptedMessage,
sessionID: UUID().uuidString,
payload: Data([1, 2, 3, 4])
)
let encoded = try #require(message.encode(), "Expected JSON encoding")
let decoded = try #require(NoiseMessage.decode(from: encoded), "Expected JSON decode")
#expect(decoded.type == message.type)
#expect(decoded.sessionID == message.sessionID)
#expect(decoded.payload == message.payload)
#expect(NoiseMessage.decodeWithError(from: Data("bad".utf8)) == nil)
let binary = message.toBinaryData()
let roundTripped = try #require(NoiseMessage.fromBinaryData(binary), "Expected binary decode")
#expect(roundTripped.type == message.type)
#expect(roundTripped.sessionID == message.sessionID)
#expect(roundTripped.payload == message.payload)
#expect(NoiseMessage.fromBinaryData(Data()) == nil)
}
private func establishSessions(
alice: NoiseEncryptionService,
bob: NoiseEncryptionService,
alicePeerID: PeerID,
bobPeerID: PeerID
) throws {
let message1 = try alice.initiateHandshake(with: alicePeerID)
let response = try bob.processHandshakeMessage(from: bobPeerID, message: message1)
let message2 = try #require(response, "Expected handshake response")
let final = try alice.processHandshakeMessage(from: alicePeerID, message: message2)
let message3 = try #require(final, "Expected handshake final")
let finalMessage = try bob.processHandshakeMessage(from: bobPeerID, message: message3)
#expect(finalMessage == nil)
}
}
private final class AuthenticationRecorder: @unchecked Sendable {
private let lock = NSLock()
private var entries: [(PeerID, String)] = []
var count: Int {
lock.lock()
defer { lock.unlock() }
return entries.count
}
func record(peerID: PeerID, fingerprint: String) {
lock.lock()
entries.append((peerID, fingerprint))
lock.unlock()
}
}
+436 -49
View File
@@ -10,8 +10,303 @@ import Foundation
import Testing
@testable import bitchat
@Suite("NostrTransport Thread Safety Tests")
@Suite("NostrTransport Tests")
struct NostrTransportTests {
typealias FavoriteRelationship = FavoritesPersistenceService.FavoriteRelationship
@Test("Warm cache marks full and short IDs reachable")
@MainActor
func reachabilityCacheWarmsFromFavorites() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let recipient = try NostrIdentity.generate()
let noiseKey = Data((0..<32).map(UInt8.init))
let fullPeerID = PeerID(hexData: noiseKey)
let shortPeerID = fullPeerID.toShort()
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Alice"
)
let favorites = [noiseKey: relationship]
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
loadFavorites: { favorites },
favoriteStatusForNoiseKey: { favorites[$0] },
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
currentIdentity: { nil }
)
)
#expect(!transport.isPeerReachable(fullPeerID))
#expect(transport.isPeerReachable(shortPeerID))
#expect(!transport.isPeerReachable(PeerID(str: "feedfeedfeedfeed")))
}
@Test("Favorite status notification refreshes reachability cache")
@MainActor
func favoriteStatusNotificationRefreshesReachability() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let recipient = try NostrIdentity.generate()
let noiseKey = Data((32..<64).map(UInt8.init))
let peerID = PeerID(hexData: noiseKey).toShort()
let notificationCenter = NotificationCenter()
var favorites: [Data: FavoriteRelationship] = [:]
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
notificationCenter: notificationCenter,
loadFavorites: { favorites },
favoriteStatusForNoiseKey: { favorites[$0] },
favoriteStatusForPeerID: { _ in favorites.values.first },
currentIdentity: { nil }
)
)
#expect(!transport.isPeerReachable(peerID))
favorites[noiseKey] = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Bob"
)
notificationCenter.post(name: .favoriteStatusChanged, object: nil)
let didRefresh = await TestHelpers.waitUntil({ transport.isPeerReachable(peerID) }, timeout: 0.5)
#expect(didRefresh)
}
@Test("Private message resolves short peer ID and emits decryptable packet")
@MainActor
func sendPrivateMessageResolvesShortPeerID() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let noiseKey = Data((64..<96).map(UInt8.init))
let shortPeerID = PeerID(hexData: noiseKey).toShort()
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Carol"
)
let probe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: { _ in nil },
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
currentIdentity: { sender },
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
sendEvent: probe.record(event:),
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
transport.sendPrivateMessage("hello over nostr", to: shortPeerID, recipientNickname: "Carol", messageID: "pm-1")
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5)
#expect(didSend)
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
let privateMessage = try decodePrivateMessage(from: result.payload)
#expect(result.senderPubkey == sender.publicKeyHex)
#expect(privateMessage.messageID == "pm-1")
#expect(privateMessage.content == "hello over nostr")
#expect(result.packet.recipientID == shortPeerID.routingData)
#expect(probe.pendingGiftWrapIDs.isEmpty)
}
@Test("Favorite notification embeds current npub")
@MainActor
func sendFavoriteNotificationEmbedsCurrentIdentity() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let noiseKey = Data((96..<128).map(UInt8.init))
let fullPeerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Dan"
)
let probe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender },
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
sendEvent: probe.record(event:),
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true)
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5)
#expect(didSend)
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
let privateMessage = try decodePrivateMessage(from: result.payload)
#expect(privateMessage.content == "[FAVORITED]:\(sender.npub)")
}
@Test("Delivery ACK encodes delivered payload type")
@MainActor
func sendDeliveryAckEmitsDeliveredAck() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let noiseKey = Data((128..<160).map(UInt8.init))
let fullPeerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Eve"
)
let probe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender },
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
sendEvent: probe.record(event:),
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
transport.sendDeliveryAck(for: "ack-1", to: fullPeerID)
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5)
#expect(didSend)
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
#expect(result.payload.type == .delivered)
#expect(String(data: result.payload.data, encoding: .utf8) == "ack-1")
#expect(result.packet.recipientID == fullPeerID.toShort().routingData)
}
@Test("Geohash private message registers pending gift wrap")
@MainActor
func sendPrivateMessageGeohashRegistersPendingGiftWrap() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let probe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
currentIdentity: { sender },
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
sendEvent: probe.record(event:),
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
transport.sendPrivateMessageGeohash(
content: "geo hello",
toRecipientHex: recipient.publicKeyHex,
from: sender,
messageID: "geo-1"
)
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5)
#expect(didSend)
let event = probe.sentEvents[0]
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
let privateMessage = try decodePrivateMessage(from: result.payload)
#expect(privateMessage.messageID == "geo-1")
#expect(privateMessage.content == "geo hello")
#expect(result.packet.recipientID == nil)
#expect(probe.pendingGiftWrapIDs == [event.id])
}
@Test("Read receipt queue sends in order and waits for scheduler")
@MainActor
func readReceiptQueueThrottlesSequentially() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let noiseKey = Data((160..<192).map(UInt8.init))
let fullPeerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Frank"
)
let probe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender },
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
sendEvent: probe.record(event:),
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
let first = ReadReceipt(originalMessageID: "read-1", readerID: transport.myPeerID, readerNickname: "Me")
let second = ReadReceipt(originalMessageID: "read-2", readerID: transport.myPeerID, readerNickname: "Me")
transport.sendReadReceipt(first, to: fullPeerID)
transport.sendReadReceipt(second, to: fullPeerID)
let sentFirst = await TestHelpers.waitUntil(
{ probe.sentEvents.count == 1 && probe.scheduledActionCount == 1 },
timeout: 0.5
)
try #require(sentFirst, "Expected first read receipt and throttle action to be queued")
let firstEvent = try #require(probe.sentEvents.first, "Expected first queued read receipt event")
let firstPayload = try decodeEmbeddedPayload(from: firstEvent, recipient: recipient).payload
#expect(firstPayload.type == .readReceipt)
#expect(String(data: firstPayload.data, encoding: .utf8) == "read-1")
try #require(probe.runNextScheduledAction(), "Expected queued throttle action after first read receipt")
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 0.5)
try #require(sentSecond, "Expected second read receipt after running throttle action")
let secondEvent = try #require(probe.sentEvents.last, "Expected second queued read receipt event")
let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload
#expect(secondPayload.type == .readReceipt)
#expect(String(data: secondPayload.data, encoding: .utf8) == "read-2")
}
@Test("Concurrent read receipt enqueue does not crash")
@MainActor
@@ -19,8 +314,6 @@ struct NostrTransportTests {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
// Create 100 concurrent read receipt submissions
let iterations = 100
await withTaskGroup(of: Void.self) { group in
@@ -36,49 +329,6 @@ struct NostrTransportTests {
}
}
}
// If we reach here without crashing, the test passes
// The concurrent enqueue operations completed without data races
}
@Test("Read queue processes under concurrent load")
@MainActor
func readQueueProcessingUnderLoad() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
// Rapidly enqueue many receipts from multiple concurrent sources
let iterations = 50
// First batch - rapid fire
for i in 0..<iterations {
let receipt = ReadReceipt(
originalMessageID: UUID().uuidString,
readerID: PeerID(str: String(format: "%016x", i)),
readerNickname: "Reader\(i)"
)
transport.sendReadReceipt(receipt, to: PeerID(str: String(format: "%016x", i)))
}
// Give some time for processing to start
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
// Second batch - while first might be processing
await withTaskGroup(of: Void.self) { group in
for i in iterations..<(iterations * 2) {
group.addTask {
let receipt = ReadReceipt(
originalMessageID: UUID().uuidString,
readerID: PeerID(str: String(format: "%016x", i)),
readerNickname: "Reader\(i)"
)
transport.sendReadReceipt(receipt, to: PeerID(str: String(format: "%016x", i)))
}
}
}
// If we reach here without crashing or deadlocking, test passes
}
@Test("isPeerReachable is thread safe")
@@ -87,10 +337,8 @@ struct NostrTransportTests {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
let iterations = 100
// Concurrent reads on isPeerReachable
await withTaskGroup(of: Bool.self) { group in
for i in 0..<iterations {
group.addTask {
@@ -99,10 +347,149 @@ struct NostrTransportTests {
}
}
// Collect results (all should be false since no favorites configured)
for await result in group {
#expect(result == false)
}
}
}
@MainActor
private func makeDependencies(
notificationCenter: NotificationCenter = NotificationCenter(),
loadFavorites: @escaping @MainActor () -> [Data: FavoriteRelationship] = { [:] },
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoriteRelationship? = { _ in nil },
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil },
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in }
) -> NostrTransport.Dependencies {
NostrTransport.Dependencies(
notificationCenter: notificationCenter,
loadFavorites: loadFavorites,
favoriteStatusForNoiseKey: favoriteStatusForNoiseKey,
favoriteStatusForPeerID: favoriteStatusForPeerID,
currentIdentity: currentIdentity,
registerPendingGiftWrap: registerPendingGiftWrap,
sendEvent: sendEvent,
scheduleAfter: scheduleAfter
)
}
private func makeRelationship(
peerNoisePublicKey: Data,
peerNostrPublicKey: String?,
peerNickname: String
) -> FavoriteRelationship {
FavoriteRelationship(
peerNoisePublicKey: peerNoisePublicKey,
peerNostrPublicKey: peerNostrPublicKey,
peerNickname: peerNickname,
isFavorite: true,
theyFavoritedUs: true,
favoritedAt: Date(timeIntervalSince1970: 1),
lastUpdated: Date(timeIntervalSince1970: 2)
)
}
private func decodeEmbeddedPayload(
from event: NostrEvent,
recipient: NostrIdentity
) throws -> (packet: BitchatPacket, payload: NoisePayload, senderPubkey: String) {
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
giftWrap: event,
recipientIdentity: recipient
)
guard content.hasPrefix("bitchat1:") else {
throw NostrTransportTestError.invalidEmbeddedContent
}
let encoded = String(content.dropFirst("bitchat1:".count))
guard let packetData = base64URLDecode(encoded),
let packet = BitchatPacket.from(packetData),
let payload = NoisePayload.decode(packet.payload) else {
throw NostrTransportTestError.invalidPacket
}
return (packet, payload, senderPubkey)
}
private func decodePrivateMessage(from payload: NoisePayload) throws -> PrivateMessagePacket {
guard payload.type == .privateMessage,
let message = PrivateMessagePacket.decode(from: payload.data) else {
throw NostrTransportTestError.invalidPrivateMessage
}
return message
}
}
private enum NostrTransportTestError: Error {
case invalidEmbeddedContent
case invalidPacket
case invalidPrivateMessage
}
private func base64URLDecode(_ string: String) -> Data? {
var candidate = string
let padding = (4 - (candidate.count % 4)) % 4
if padding > 0 {
candidate += String(repeating: "=", count: padding)
}
candidate = candidate
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
return Data(base64Encoded: candidate)
}
private final class NostrTransportProbe: @unchecked Sendable {
private let lock = NSLock()
private var sentEventsStorage: [NostrEvent] = []
private var pendingGiftWrapIDsStorage: [String] = []
private var scheduledActionsStorage: [(@Sendable () -> Void)] = []
var sentEvents: [NostrEvent] {
lock.lock()
defer { lock.unlock() }
return sentEventsStorage
}
var pendingGiftWrapIDs: [String] {
lock.lock()
defer { lock.unlock() }
return pendingGiftWrapIDsStorage
}
var scheduledActionCount: Int {
lock.lock()
defer { lock.unlock() }
return scheduledActionsStorage.count
}
func record(event: NostrEvent) {
lock.lock()
sentEventsStorage.append(event)
lock.unlock()
}
func recordPendingGiftWrap(id: String) {
lock.lock()
pendingGiftWrapIDsStorage.append(id)
lock.unlock()
}
func enqueueScheduledAction(delay: TimeInterval, action: @escaping @Sendable () -> Void) {
_ = delay
lock.lock()
scheduledActionsStorage.append(action)
lock.unlock()
}
@discardableResult
func runNextScheduledAction() -> Bool {
let action: (@Sendable () -> Void)?
lock.lock()
action = scheduledActionsStorage.isEmpty ? nil : scheduledActionsStorage.removeFirst()
lock.unlock()
guard let action else { return false }
action()
return true
}
}
@@ -69,4 +69,202 @@ struct PrivateChatManagerTests {
#expect(manager.sentReadReceipts.contains("pm-2"))
#expect(!manager.unreadMessages.contains(peerID))
}
@Test @MainActor
func markAsRead_withoutRouterFallsBackToTransport() async {
let transport = MockTransport()
let manager = PrivateChatManager(meshService: transport)
let peerID = PeerID(str: "00000000000000CC")
manager.privateChats[peerID] = [
BitchatMessage(
id: "pm-fallback",
sender: "Peer",
content: "Hi",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerID
)
]
manager.markAsRead(from: peerID)
#expect(transport.sentReadReceipts.count == 1)
#expect(transport.sentReadReceipts.first?.receipt.originalMessageID == "pm-fallback")
}
@Test @MainActor
func consolidateMessages_mergesStableNoiseKeyHistoryAndMarksUnread() async {
let transport = MockTransport()
let manager = PrivateChatManager(meshService: transport)
let identityManager = MockIdentityManager(MockKeychain())
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let unifiedPeerService = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identityManager)
manager.unifiedPeerService = unifiedPeerService
let peerID = PeerID(str: "0123456789abcdef")
let noiseKey = Data((0..<32).map(UInt8.init))
let stablePeerID = PeerID(hexData: noiseKey)
transport.updatePeerSnapshots([
TransportPeerSnapshot(
peerID: peerID,
nickname: "Alice",
isConnected: true,
noisePublicKey: noiseKey,
lastSeen: Date()
)
])
try? await Task.sleep(nanoseconds: 50_000_000)
manager.privateChats[stablePeerID] = [
BitchatMessage(
id: "stable-msg",
sender: "Alice",
content: "Hello from stable",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: stablePeerID
)
]
manager.unreadMessages.insert(stablePeerID)
let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: "Alice", persistedReadReceipts: [])
#expect(hadUnread)
#expect(manager.privateChats[stablePeerID] == nil)
#expect(manager.privateChats[peerID]?.count == 1)
#expect(manager.privateChats[peerID]?.first?.senderPeerID == peerID)
#expect(manager.unreadMessages.contains(peerID))
}
@Test @MainActor
func consolidateMessages_movesTemporaryGeoDMHistoryByNickname() async {
let transport = MockTransport()
let manager = PrivateChatManager(meshService: transport)
let peerID = PeerID(str: "0011223344556677")
let tempPeerID = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000042")
manager.privateChats[tempPeerID] = [
BitchatMessage(
id: "geo-msg",
sender: "Alice",
content: "Geo hello",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: tempPeerID
)
]
manager.unreadMessages.insert(tempPeerID)
let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: "alice", persistedReadReceipts: [])
#expect(hadUnread)
#expect(manager.privateChats[tempPeerID] == nil)
#expect(manager.privateChats[peerID]?.count == 1)
#expect(manager.privateChats[peerID]?.first?.senderPeerID == peerID)
#expect(manager.unreadMessages.contains(peerID))
#expect(!manager.unreadMessages.contains(tempPeerID))
}
@Test @MainActor
func syncReadReceiptsForSentMessages_onlyCopiesDeliveredAndRead() async {
let transport = MockTransport()
let manager = PrivateChatManager(meshService: transport)
let peerID = PeerID(str: "00000000000000DD")
manager.privateChats[peerID] = [
BitchatMessage(
id: "sent-read",
sender: "Me",
content: "One",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: transport.myPeerID,
deliveryStatus: .read(by: "Peer", at: Date())
),
BitchatMessage(
id: "sent-delivered",
sender: "Me",
content: "Two",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: transport.myPeerID,
deliveryStatus: .delivered(to: "Peer", at: Date())
),
BitchatMessage(
id: "sent-failed",
sender: "Me",
content: "Three",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: transport.myPeerID,
deliveryStatus: .failed(reason: "nope")
)
]
var externalReceipts = Set<String>()
manager.syncReadReceiptsForSentMessages(peerID: peerID, nickname: "Me", externalReceipts: &externalReceipts)
#expect(externalReceipts == Set(["sent-read", "sent-delivered"]))
#expect(manager.sentReadReceipts == Set(["sent-read", "sent-delivered"]))
}
@Test @MainActor
func sanitizeChat_sortsChronologicallyAndKeepsLatestDuplicate() async {
let transport = MockTransport()
let manager = PrivateChatManager(meshService: transport)
let peerID = PeerID(str: "00000000000000EE")
let base = Date(timeIntervalSince1970: 10)
manager.privateChats[peerID] = [
BitchatMessage(
id: "same",
sender: "Peer",
content: "Older",
timestamp: base.addingTimeInterval(10),
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerID
),
BitchatMessage(
id: "first",
sender: "Peer",
content: "First",
timestamp: base,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerID
),
BitchatMessage(
id: "same",
sender: "Peer",
content: "Newest",
timestamp: base.addingTimeInterval(20),
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerID
)
]
manager.sanitizeChat(for: peerID)
#expect(manager.privateChats[peerID]?.map(\.id) == ["first", "same"])
#expect(manager.privateChats[peerID]?.last?.content == "Newest")
}
}
@@ -0,0 +1,125 @@
import Foundation
import Combine
import Testing
@testable import bitchat
@Suite("TransferProgressManager Tests")
struct TransferProgressManagerTests {
@Test("Start publishes started event and stores snapshot")
@MainActor
func startPublishesAndStoresSnapshot() async throws {
let manager = TransferProgressManager()
let transferID = "transfer-start"
var cancellable: AnyCancellable?
let recorder = EventRecorder()
cancellable = manager.publisher.sink { event in
if case .started(let id, let total) = event {
recorder.append("started:\(id):\(total)")
}
}
manager.start(id: transferID, totalFragments: 3)
let didReceive = await TestHelpers.waitUntil({
recorder.values == ["started:\(transferID):3"]
}, timeout: 0.5)
#expect(didReceive)
#expect(recorder.values == ["started:\(transferID):3"])
#expect(manager.snapshot(id: transferID)?.sent == 0)
#expect(manager.snapshot(id: transferID)?.total == 3)
_ = cancellable
}
@Test("Sending final fragment publishes update and completion then clears snapshot")
@MainActor
func recordFragmentSentPublishesProgressAndCompletion() async throws {
let manager = TransferProgressManager()
let transferID = "transfer-complete"
var cancellable: AnyCancellable?
let recorder = EventRecorder()
cancellable = manager.publisher.sink { event in
switch event {
case .started(let id, let total):
recorder.append("started:\(id):\(total)")
case .updated(let id, let sent, let total):
recorder.append("updated:\(id):\(sent):\(total)")
case .completed(let id, let total):
recorder.append("completed:\(id):\(total)")
case .cancelled:
break
}
}
manager.start(id: transferID, totalFragments: 1)
manager.recordFragmentSent(id: transferID)
let didReceive = await TestHelpers.waitUntil({
recorder.values.count == 3
}, timeout: 0.5)
#expect(didReceive)
#expect(recorder.values == [
"started:\(transferID):1",
"updated:\(transferID):1:1",
"completed:\(transferID):1"
])
#expect(manager.snapshot(id: transferID) == nil)
_ = cancellable
}
@Test("Cancel publishes cancelled event and clears state")
@MainActor
func cancelPublishesAndClearsState() async throws {
let manager = TransferProgressManager()
let transferID = "transfer-cancel"
var cancellable: AnyCancellable?
let recorder = EventRecorder()
cancellable = manager.publisher.sink { event in
switch event {
case .started(let id, let total):
recorder.append("started:\(id):\(total)")
case .cancelled(let id, let sent, let total):
recorder.append("cancelled:\(id):\(sent):\(total)")
case .updated, .completed:
break
}
}
manager.start(id: transferID, totalFragments: 4)
manager.recordFragmentSent(id: transferID)
manager.cancel(id: transferID)
let didReceive = await TestHelpers.waitUntil({
recorder.values.contains("started:\(transferID):4") &&
recorder.values.contains("cancelled:\(transferID):1:4")
}, timeout: 0.5)
#expect(didReceive)
#expect(recorder.values.contains("started:\(transferID):4"))
#expect(recorder.values.contains("cancelled:\(transferID):1:4"))
#expect(manager.snapshot(id: transferID) == nil)
_ = cancellable
}
}
private final class EventRecorder: @unchecked Sendable {
private let lock = NSLock()
private var storage: [String] = []
var values: [String] {
lock.lock()
defer { lock.unlock() }
return storage
}
func append(_ value: String) {
lock.lock()
storage.append(value)
lock.unlock()
}
}