Files
bitchat/bitchatTests/Services/NostrTransportTests.swift
T
jackandClaude Opus 5 ac13a0c22f Make the flake class unrepeatable, not just fixed
Raising four deadlines fixed the four tests that happened to fire. The
class was still there: fourteen separate waitUntil helpers, most defaulting
to 1.0s, plus wait call sites with literal budgets. The fifth instance
would have landed the same way, on someone else's unrelated PR.

The rule, now written down in TestConstants.settleTimeout: **a wait
deadline is not a latency budget.** It exists so a genuine hang eventually
fails the suite, so size it for the worst-case scheduler, never for how
long the operation should take. Waits return as soon as their condition
holds, so a generous deadline is free in the passing case and only extends
genuine failures.

- Every wait helper now defaults to TestConstants.settleTimeout (30s), and
  the literal wait call sites below the floor were converted too — sixteen
  sites across ten files.
- TestTimingHygieneTests enforces it by scanning the test sources: wait
  defaults and wait call sites must be at least minimumSettleTimeout, and
  no test may assert an upper bound on elapsed wall-clock time (the
  assertion that started this, which cannot separate correct behaviour from
  a slow machine).
- Both rules waive per line with "test-timing-ok: <reason>", accepted on
  the line or in the comment block above it so the reason has room to be a
  sentence. One legitimate use so far: a NEGATIVE wait in NoiseCoverageTests
  asserting a promotion has *not* completed within 50ms, where a long
  deadline would only make the suite slow while still passing.

Injected production timeouts are deliberately not matched — the Noise
handshake timeouts are the behaviour under test, and short values are
correct there.

Verified the guard actually fails: a canary file with both banned shapes
was flagged with file and line, and the suite went green again once it was
removed. Full suite passes with all 18 cores saturated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:23:40 +01:00

557 lines
23 KiB
Swift

//
// NostrTransportTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Combine
import Foundation
import Testing
import BitFoundation
@testable import bitchat
@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 }
)
)
// Offline favorites are addressed by the full 64-hex noise key, so
// both forms must resolve to the same reachability answer.
#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: 5.0)
#expect(didRefresh)
}
@Test("Prompt delivery requires both a known npub and a relay connection")
@MainActor
func canDeliverPromptlyTracksRelayConnectivity() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let recipient = try NostrIdentity.generate()
let noiseKey = Data((0..<32).map(UInt8.init))
let peerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Alice"
)
let connectivity = CurrentValueSubject<Bool, Never>(false)
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
loadFavorites: { [noiseKey: relationship] },
relayConnectivity: { connectivity.eraseToAnyPublisher() }
)
)
// Reachable (npub known) but relays down: the peer must not be
// treated as promptly deliverable, or the router would skip the
// courier and let the message rot in the Nostr send queue.
#expect(transport.isPeerReachable(peerID))
#expect(!transport.canDeliverPromptly(to: peerID))
connectivity.send(true)
let deliverable = await TestHelpers.waitUntil(
{ transport.canDeliverPromptly(to: peerID) },
timeout: 5.0
)
#expect(deliverable)
connectivity.send(false)
let undeliverable = await TestHelpers.waitUntil(
{ !transport.canDeliverPromptly(to: peerID) },
timeout: 5.0
)
#expect(undeliverable)
}
@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: TestConstants.settleTimeout)
#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: TestConstants.settleTimeout)
#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: TestConstants.settleTimeout)
#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: TestConstants.settleTimeout)
#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 readReceiptTimeout: TimeInterval = 5.0
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count >= 1 }, timeout: readReceiptTimeout)
try #require(sentFirst, "Expected first queued read receipt event")
let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: readReceiptTimeout)
try #require(scheduledThrottle, "Expected queued throttle action after first read receipt")
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: readReceiptTimeout)
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")
withExtendedLifetime(transport) {}
}
// These thread-safety tests must hammer from the dispatch pool
// (concurrentPerform), NOT a task group: transport calls block in
// queue.sync, and a 100-task group runs them on the Swift Concurrency
// cooperative pool — one thread per core, just 3 on CI runners. Parking
// every cooperative thread in a blocking sync violates the forward
// progress contract and wedged dispatch on the CI runners' macOS,
// deadlocking the whole app suite into the 15-minute job timeout
// (watchdog stacks: NostrTransport.isPeerReachable syncs holding all
// pool threads). Blocking is legal on dispatch worker threads.
@Test("Concurrent read receipt enqueue does not crash")
@MainActor
func concurrentReadReceiptEnqueue() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
let iterations = 100
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
DispatchQueue.global().async {
DispatchQueue.concurrentPerform(iterations: iterations) { i in
let receipt = ReadReceipt(
originalMessageID: UUID().uuidString,
readerID: PeerID(str: String(format: "%016x", i)),
readerNickname: "Reader\(i)"
)
let peerID = PeerID(str: String(format: "%016x", i))
transport.sendReadReceipt(receipt, to: peerID)
}
continuation.resume()
}
}
withExtendedLifetime(transport) {}
}
@Test("isPeerReachable is thread safe")
@MainActor
func isPeerReachableThreadSafety() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
let iterations = 100
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
DispatchQueue.global().async {
DispatchQueue.concurrentPerform(iterations: iterations) { i in
let peerID = PeerID(str: String(format: "%016x", i))
#expect(transport.isPeerReachable(peerID) == false)
}
continuation.resume()
}
}
withExtendedLifetime(transport) {}
}
@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 },
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() }
) -> NostrTransport.Dependencies {
NostrTransport.Dependencies(
notificationCenter: notificationCenter,
loadFavorites: loadFavorites,
favoriteStatusForNoiseKey: favoriteStatusForNoiseKey,
favoriteStatusForPeerID: favoriteStatusForPeerID,
currentIdentity: currentIdentity,
registerPendingGiftWrap: registerPendingGiftWrap,
sendEvent: sendEvent,
scheduleAfter: scheduleAfter,
relayConnectivity: relayConnectivity
)
}
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
}
}