Harden PTT audio and the 1.7.1 release (#1423)

Centralize PTT and voice audio-session ownership, harden courier/bridge/outbox delivery and recovery, correct location and delivery-state races, add privacy/release metadata, and ship reproducible universal Arti slices with Release CI coverage.

Validated by the full iOS suite, repeated audio/fragment/performance regressions, BitFoundation tests, strict lint and dead-code analysis, universal iOS Release builds, and iOS/macOS archives.
This commit is contained in:
jack
2026-07-10 14:04:09 +02:00
committed by GitHub
parent b205a55523
commit 820c933958
91 changed files with 8381 additions and 1083 deletions
@@ -0,0 +1,510 @@
//
// AudioSessionCoordinatorTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import bitchat
/// Thread-safe: the coordinator invokes it on its private serial queue (that
/// the calls happen off the main thread is itself under test) while the test
/// reads from the main actor.
private final class MockAudioSession: SessionApplying, @unchecked Sendable {
enum Call: Equatable {
case setCategory(AudioSessionCoordinator.Category)
case setActive(Bool, notifyOthers: Bool)
}
private let lock = NSLock()
private var _calls: [Call] = []
private var _callsOnMainThread: [Bool] = []
private var _nextError: Error?
private var _nextActivationError: Error?
var calls: [Call] { lock.withLock { _calls } }
/// Whether each recorded call ran on the main thread the coordinator's
/// whole point is that none ever does (the real calls block on IPC to the
/// audio server).
var callsOnMainThread: [Bool] { lock.withLock { _callsOnMainThread } }
var nextError: Error? {
get { lock.withLock { _nextError } }
set { lock.withLock { _nextError = newValue } }
}
/// Fails only the next `setActive` (so `setCategory` can succeed first).
var nextActivationError: Error? {
get { lock.withLock { _nextActivationError } }
set { lock.withLock { _nextActivationError = newValue } }
}
func setCategory(_ category: AudioSessionCoordinator.Category) throws {
try lock.withLock {
if let error = _nextError {
_nextError = nil
throw error
}
_calls.append(.setCategory(category))
_callsOnMainThread.append(Thread.isMainThread)
}
}
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
try lock.withLock {
if let error = _nextError {
_nextError = nil
throw error
}
if let error = _nextActivationError {
_nextActivationError = nil
throw error
}
_calls.append(.setActive(active, notifyOthers: notifyOthersOnDeactivation))
_callsOnMainThread.append(Thread.isMainThread)
}
}
var categoryCalls: [AudioSessionCoordinator.Category] {
calls.compactMap { if case .setCategory(let category) = $0 { category } else { nil } }
}
var activationCalls: [Bool] {
calls.compactMap { if case .setActive(let active, _) = $0 { active } else { nil } }
}
}
private struct MockSessionError: Error {}
/// Async suspension gate used to force lifecycle races without sleeps. The
/// production operation announces that it reached the gated boundary, then
/// stays suspended until the test opens it.
private actor AsyncGate {
private var isOpen = false
private var hasWaiter = false
private var arrivalWaiters: [CheckedContinuation<Void, Never>] = []
private var gateWaiters: [CheckedContinuation<Void, Never>] = []
func wait() async {
hasWaiter = true
let arrivals = arrivalWaiters
arrivalWaiters = []
for continuation in arrivals {
continuation.resume()
}
guard !isOpen else { return }
await withCheckedContinuation { continuation in
gateWaiters.append(continuation)
}
}
func waitUntilEntered() async {
guard !hasWaiter else { return }
await withCheckedContinuation { continuation in
arrivalWaiters.append(continuation)
}
}
func open() {
isOpen = true
let waiters = gateWaiters
gateWaiters = []
for continuation in waiters {
continuation.resume()
}
}
}
@MainActor
struct AudioSessionCoordinatorTests {
// MARK: - Reference-counted activation
@Test func activatesOnFirstAcquireAndDeactivatesOnLastRelease() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let first = try await coordinator.acquire(.playback) {}
let second = try await coordinator.acquire(.playback) {}
#expect(session.activationCalls == [true])
coordinator.release(first)
await coordinator.drain()
#expect(session.activationCalls == [true])
coordinator.release(second)
await coordinator.drain()
#expect(session.activationCalls == [true, false])
#expect(session.calls.last == .setActive(false, notifyOthers: true))
}
@Test func releasingOneOfTwoClientsDoesNotDeactivate() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let playback = try await coordinator.acquire(.playback) {}
let capture = try await coordinator.acquire(.capture) {}
coordinator.release(capture)
await coordinator.drain()
#expect(session.activationCalls == [true])
coordinator.release(playback)
await coordinator.drain()
#expect(session.activationCalls == [true, false])
}
@Test func doubleReleaseIsIdempotent() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let first = try await coordinator.acquire(.playback) {}
let second = try await coordinator.acquire(.playback) {}
coordinator.release(first)
coordinator.release(first)
await coordinator.drain()
// The stale second release must not tear the session out from under
// the remaining holder.
#expect(session.activationCalls == [true])
coordinator.release(second)
coordinator.release(second)
await coordinator.drain()
#expect(session.activationCalls == [true, false])
}
// MARK: - Off-main session calls
@Test func sessionCallsNeverRunOnTheMainThread() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
// The real setCategory/setActive block on IPC to the audio server
// (>1 s observed under contention, tripping the system gesture gate
// on PTT press) every call must land on the coordinator's queue.
let token = try await coordinator.acquire(.capture) {}
coordinator.release(token)
await coordinator.drain()
#expect(session.calls.count == 3) // setCategory + activate + deactivate
#expect(session.callsOnMainThread == [false, false, false])
}
@Test func failedActivationDoesNotRegisterAHolder() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
session.nextError = MockSessionError()
await #expect(throws: MockSessionError.self) {
try await coordinator.acquire(.playback) {}
}
// The failed acquire left no holder behind: the next one is 0->1
// again and activates.
let token = try await coordinator.acquire(.playback) {}
#expect(session.activationCalls == [true])
coordinator.release(token)
await coordinator.drain()
#expect(session.activationCalls == [true, false])
}
@Test func failedActivationRollsBackEscalatedCategory() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
// setCategory(.playAndRecord) succeeds, setActive throws (e.g. a
// phone call owns the hardware).
session.nextActivationError = MockSessionError()
await #expect(throws: MockSessionError.self) {
try await coordinator.acquire(.capture) {}
}
// With no holder registered the escalated category must not stick:
// the next playback-only acquire runs under .playback, not the
// leftover .playAndRecord.
let token = try await coordinator.acquire(.playback) {}
#expect(session.categoryCalls == [.playAndRecord, .playback])
// And the failed acquire left no holder behind: this one was 0->1.
#expect(session.activationCalls == [true])
coordinator.release(token)
await coordinator.drain()
#expect(session.activationCalls == [true, false])
}
// MARK: - Category escalation
@Test func captureWhilePlaybackEscalatesExactlyOnceAndNeverDowngrades() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let playback = try await coordinator.acquire(.playback) {}
#expect(session.categoryCalls == [.playback])
let capture = try await coordinator.acquire(.capture) {}
#expect(session.categoryCalls == [.playback, .playAndRecord])
// More clients of either use don't touch the category again.
let secondCapture = try await coordinator.acquire(.capture) {}
let secondPlayback = try await coordinator.acquire(.playback) {}
#expect(session.categoryCalls == [.playback, .playAndRecord])
// Capture ending must not downgrade the route under live playback.
coordinator.release(capture)
coordinator.release(secondCapture)
await coordinator.drain()
#expect(session.categoryCalls == [.playback, .playAndRecord])
// Even a fresh playback acquire stays on playAndRecord while held.
let thirdPlayback = try await coordinator.acquire(.playback) {}
#expect(session.categoryCalls == [.playback, .playAndRecord])
coordinator.release(playback)
coordinator.release(secondPlayback)
coordinator.release(thirdPlayback)
await coordinator.drain()
}
@Test func categoryResetsAfterAllHoldersRelease() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let capture = try await coordinator.acquire(.capture) {}
coordinator.release(capture)
await coordinator.drain()
#expect(session.categoryCalls == [.playAndRecord])
// With no holders left the next playback-only session downgrades.
let playback = try await coordinator.acquire(.playback) {}
#expect(session.categoryCalls == [.playAndRecord, .playback])
coordinator.release(playback)
await coordinator.drain()
}
@Test func escalationNotifiesExistingHoldersSoEnginesCanRestart() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
var playbackInterruptions = 0
var captureInterruptions = 0
let playback = try await coordinator.acquire(.playback) { playbackInterruptions += 1 }
let capture = try await coordinator.acquire(.capture) { captureInterruptions += 1 }
// The pre-existing playback holder was reconfigured underneath (the
// fan-out is delivered before acquire returns); the newly acquiring
// capture client was not.
#expect(playbackInterruptions == 1)
#expect(captureInterruptions == 0)
// A second capture doesn't change the category nobody is notified.
let secondCapture = try await coordinator.acquire(.capture) {}
#expect(playbackInterruptions == 1)
#expect(captureInterruptions == 0)
coordinator.release(playback)
coordinator.release(capture)
coordinator.release(secondCapture)
await coordinator.drain()
}
@Test func escalationPrefersCategoryChangeCallbackOverInterruption() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
var escalations = 0
var interruptions = 0
let playback = try await coordinator.acquire(
.playback,
onInterrupted: { interruptions += 1 },
onCategoryEscalated: { escalations += 1 }
)
// Escalation reaches the dedicated callback (the holder restarts and
// keeps playing) not onInterrupted (which would stop it for good).
let capture = try await coordinator.acquire(.capture) {}
#expect(escalations == 1)
#expect(interruptions == 0)
// A real interruption still stops it.
await coordinator.handleInterruptionBegan()
#expect(escalations == 1)
#expect(interruptions == 1)
coordinator.release(playback)
coordinator.release(capture)
await coordinator.drain()
}
// MARK: - Interruptions and route changes
@Test func interruptionDuringAcquireHandoffCancelsAcquire() async throws {
let session = MockAudioSession()
let handoffGate = AsyncGate()
let coordinator = AudioSessionCoordinator(
session: session,
testingHooks: .init(beforeAcquireHandoff: {
await handoffGate.wait()
})
)
var interruptionCount = 0
let acquireTask = Task { @MainActor in
try await coordinator.acquire(.capture) {
interruptionCount += 1
}
}
// The session-queue registration is complete, but the caller has not
// received its token. An interruption here used to invoke the callback
// immediately, when capture clients could not release the token yet.
await handoffGate.waitUntilEntered()
await coordinator.handleInterruptionBegan()
#expect(interruptionCount == 0)
await handoffGate.open()
await #expect(throws: CancellationError.self) {
try await acquireTask.value
}
await coordinator.drain()
#expect(interruptionCount == 0)
// The OS already deactivated the interrupted session; removing the
// provisional token must not issue a redundant setActive(false).
#expect(session.activationCalls == [true])
// The canceled acquire left no holder behind and the now-open test gate
// does not affect a subsequent ownership handoff.
let replacement = try await coordinator.acquire(.playback) {}
#expect(session.activationCalls == [true, true])
coordinator.release(replacement)
await coordinator.drain()
#expect(session.activationCalls == [true, true, false])
}
@Test func releasedSnapshotCannotInterruptReacquiredToken() async throws {
let session = MockAudioSession()
let deliveryGate = AsyncGate()
let coordinator = AudioSessionCoordinator(
session: session,
testingHooks: .init(beforeCallbackDelivery: {
await deliveryGate.wait()
})
)
// Model a single client whose callback acts on whichever token it owns
// now. If the old snapshot is delivered after reacquisition, it would
// incorrectly release the new session.
var activeToken: AudioSessionCoordinator.Token?
var interruptionCount = 0
let onInterrupted: @MainActor () -> Void = {
interruptionCount += 1
activeToken.map(coordinator.release)
}
let first = try await coordinator.acquire(.playback, onInterrupted: onInterrupted)
activeToken = first
let interruptionTask = Task {
await coordinator.handleInterruptionBegan()
}
// The queue snapshot contains `first`, but main-actor delivery is held.
await deliveryGate.waitUntilEntered()
coordinator.release(first)
activeToken = nil
await coordinator.drain()
let second = try await coordinator.acquire(.playback, onInterrupted: onInterrupted)
activeToken = second
#expect(session.activationCalls == [true, true])
await deliveryGate.open()
await interruptionTask.value
await coordinator.drain()
#expect(interruptionCount == 0)
// A stale callback would have released `second` and appended false.
#expect(session.activationCalls == [true, true])
coordinator.release(second)
activeToken = nil
await coordinator.drain()
#expect(session.activationCalls == [true, true, false])
}
@Test func interruptionFansOutToAllHoldersAndResetsActiveState() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
var playbackInterruptions = 0
var captureInterruptions = 0
// Capture first so no escalation fan-out muddies the counters.
let capture = try await coordinator.acquire(.capture) { captureInterruptions += 1 }
let playback = try await coordinator.acquire(.playback) { playbackInterruptions += 1 }
#expect(session.activationCalls == [true])
await coordinator.handleInterruptionBegan()
#expect(playbackInterruptions == 1)
#expect(captureInterruptions == 1)
// The OS deactivated the session; the coordinator must not issue its
// own setActive(false) on top of it.
#expect(session.activationCalls == [true])
// The active state was reset: the next acquire re-activates even
// though holders never released.
let resumed = try await coordinator.acquire(.playback) {}
#expect(session.activationCalls == [true, true])
coordinator.release(playback)
coordinator.release(capture)
coordinator.release(resumed)
await coordinator.drain()
#expect(session.activationCalls == [true, true, false])
}
@Test func interruptedHoldersReleasingDuringFanOutStaySafe() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
// Real clients release from within onInterrupted (stop() paths);
// release is fire-and-forget onto the coordinator's queue, so it is
// safe from inside the main-actor fan-out.
var tokens: [AudioSessionCoordinator.Token] = []
for _ in 0..<2 {
var token: AudioSessionCoordinator.Token?
token = try await coordinator.acquire(.playback) {
token.map(coordinator.release)
}
tokens.append(token!)
}
await coordinator.handleInterruptionBegan()
await coordinator.drain()
// Every holder released mid-fan-out; the session was already
// deactivated by the OS, so no redundant setActive(false).
#expect(session.activationCalls == [true])
// All holders are gone: a fresh acquire is 0->1 again.
let token = try await coordinator.acquire(.playback) {}
#expect(session.activationCalls == [true, true])
coordinator.release(token)
await coordinator.drain()
#expect(session.activationCalls == [true, true, false])
}
@Test func routeDeviceUnavailableNotifiesHoldersButKeepsSessionActive() async throws {
let session = MockAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
var interruptions = 0
// Capture first so no escalation fan-out muddies the counter.
let capture = try await coordinator.acquire(.capture) { interruptions += 1 }
let playback = try await coordinator.acquire(.playback) { interruptions += 1 }
await coordinator.handleRouteDeviceUnavailable()
#expect(interruptions == 2)
// Unlike an interruption, the session itself is still active the
// last holder's release performs the deactivation.
coordinator.release(playback)
coordinator.release(capture)
await coordinator.drain()
#expect(session.activationCalls == [true, false])
}
}
+125
View File
@@ -219,11 +219,54 @@ struct BLEServiceCoreTests {
)
let payload = try #require(announcement.encode(), "Failed to encode announcement")
let victimPeerID = PeerID(publicKey: announcement.noisePublicKey)
let courierStore = CourierStore(persistsToDisk: false)
ble.courierStore = courierStore
let carriedEnvelope = CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: announcement.noisePublicKey,
epochDay: CourierEnvelope.epochDay(for: Date())
),
expiry: UInt64(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000),
ciphertext: Data(repeating: 0xA5, count: 128)
)
#expect(courierStore.deposit(
carriedEnvelope,
from: Data(repeating: 0xC0, count: 32),
tier: .favorite
))
let sprayRecipientKey = Data(repeating: 0xB4, count: 32)
let sprayEnvelope = CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: sprayRecipientKey,
epochDay: CourierEnvelope.epochDay(for: Date())
),
expiry: UInt64(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000),
ciphertext: Data(repeating: 0xB5, count: 128),
copies: 4
)
#expect(courierStore.deposit(
sprayEnvelope,
from: Data(repeating: 0xC0, count: 32),
tier: .favorite
))
ble._test_seedConnectedPeer(victimPeerID, nickname: "victim")
ble._test_bindCentral(victimLink, to: victimPeerID)
ble._test_seedConnectedPeer(attackerPeerID, nickname: "attacker")
ble._test_bindCentral(attackerLink, to: attackerPeerID)
// Preserve the hard case: a valid victim session still exists on the
// victim's own physical link when the announce is replayed elsewhere.
let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID)
let message2 = try #require(
try victimSigner.processHandshakeMessage(from: ble.myPeerID, message: message1)
)
let message3 = try #require(
try ble._test_noiseProcessHandshakeMessage(from: victimPeerID, message: message2)
)
_ = try victimSigner.processHandshakeMessage(from: ble.myPeerID, message: message3)
#expect(ble.canDeliverSecurely(to: victimPeerID))
ble._test_markNoiseAuthenticatedCentral(victimLink, to: victimPeerID)
// The victim's fresh signed announce replayed on the attacker's bound
// link with its direct TTL restored (TTL is excluded from signing, so
// the signature still verifies).
@@ -249,6 +292,16 @@ struct BLEServiceCoreTests {
#expect(!stolen)
#expect(ble._test_centralBinding(attackerLink) == attackerPeerID)
#expect(ble._test_centralBinding(victimLink) == victimPeerID)
// A valid signature authenticates the announce contents, not the
// unsigned direct TTL. Without a Noise-authenticated session on the
// ingress link, the replay must not retire mail or consume spray state.
#expect(!courierStore.isEmpty)
await Task.yield()
await Task.yield()
let stillEligibleForSpray = courierStore.takeSprayCopies(for: announcement.noisePublicKey)
#expect(stillEligibleForSpray.map(\.copies) == [2])
#expect(courierStore.takeEnvelopes(for: announcement.noisePublicKey) == [carriedEnvelope])
#expect(ble.canDeliverSecurely(to: victimPeerID))
// And the replay must not retire the link's real bound peer.
#expect(ble.currentPeerSnapshots().map(\.peerID).contains(attackerPeerID))
}
@@ -315,6 +368,78 @@ struct BLEServiceCoreTests {
#expect(!ble.canDeliverSecurely(to: victimPeerID))
}
@Test
func replayedDirectAnnounceWithStaleVictimSessionCannotBridgeThroughForeignLink() async throws {
let ble = makeService()
// Keep announce handling out of the carried-mail path: the regression
// is specifically BridgeCourierService's direct delivery preflight.
ble.courierStore = CourierStore(persistsToDisk: false)
let attackerPeerID = PeerID(str: "1122334455667788")
let attackerLink = "central-attacker-stale-victim-session"
ble._test_seedConnectedPeer(attackerPeerID, nickname: "attacker")
ble._test_bindCentral(attackerLink, to: attackerPeerID)
let victim = NoiseEncryptionService(keychain: MockKeychain())
let announcement = AnnouncementPacket(
nickname: "victim",
noisePublicKey: victim.getStaticPublicKeyData(),
signingPublicKey: victim.getSigningPublicKeyData(),
directNeighbors: nil
)
let victimPeerID = PeerID(publicKey: announcement.noisePublicKey)
// Establish a real peer-level victim session without associating it
// with the attacker's physical link. This is the stale-session case
// that a plain `canDeliverSecurely` check cannot distinguish.
let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID)
let message2 = try #require(
try victim.processHandshakeMessage(from: ble.myPeerID, message: message1)
)
let message3 = try #require(
try ble._test_noiseProcessHandshakeMessage(from: victimPeerID, message: message2)
)
_ = try victim.processHandshakeMessage(from: ble.myPeerID, message: message3)
#expect(ble.canDeliverSecurely(to: victimPeerID))
let payload = try #require(announcement.encode(), "Failed to encode announcement")
let unsigned = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: victimPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 7
)
let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce")
#expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink))
ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false)
let rebound = await TestHelpers.waitUntil(
{ ble._test_centralBinding(attackerLink) == victimPeerID },
timeout: TestConstants.longTimeout
)
#expect(rebound)
#expect(ble.canDeliverSecurely(to: victimPeerID))
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = { outbound.record($0) }
let envelope = CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: announcement.noisePublicKey,
epochDay: CourierEnvelope.epochDay(for: Date())
),
expiry: UInt64(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000),
ciphertext: Data(repeating: 0xA5, count: 128)
)
#expect(!ble.deliverBridgedEnvelope(envelope, to: victimPeerID))
// Reject before even entering the outbound pipeline: otherwise a
// real attacker CBCentral could accept the opaque courier packet and
// cause the relay drop's persisted seen ID to be consumed forever.
#expect(outbound.count(ofType: .courierEnvelope) == 0)
}
/// A legitimate rotation announce necessarily arrives on a link still
/// bound to the OLD ID, so its registry upsert stores the new peer
/// disconnected. The successful rebind must promote it: a healed
@@ -80,7 +80,7 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
@discardableResult
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool {
guard var chat = privateChats[peerID],
guard let chat = privateChats[peerID],
let index = chat.firstIndex(where: { $0.id == messageID }) else {
return false
}
@@ -268,7 +268,7 @@ struct ChatVerificationCoordinatorContextTests {
context.verifiedFingerprints = ["fp-verified"]
coordinator.setupNoiseCallbacks()
let callbacks = try? #require(context.installedCallbacks)
let callbacks = context.installedCallbacks
// Authenticated with a verified fingerprint -> verified status and a
// cached stable peer ID derived from the session key.
@@ -130,6 +130,14 @@ struct ChatViewModelDeliveryStatusTests {
#expect(Conversation.shouldSkipStatusUpdate(current: .delivered(to: "Peer", at: Date()), new: .sending))
#expect(Conversation.shouldSkipStatusUpdate(current: .read(by: "Peer", at: Date()), new: .sending))
#expect(Conversation.shouldSkipStatusUpdate(
current: .delivered(to: "Peer", at: Date()),
new: .failed(reason: "late transport failure")
))
#expect(Conversation.shouldSkipStatusUpdate(
current: .read(by: "Peer", at: Date()),
new: .failed(reason: "late transfer failure")
))
// A late async `.sending` (pre-handshake resend) must not visibly
// downgrade a truthful "Sent" either...
#expect(Conversation.shouldSkipStatusUpdate(current: .sent, new: .sending))
+34
View File
@@ -760,6 +760,40 @@ struct ChatViewModelRateLimitingTests {
struct ChatViewModelPublicConversationTests {
@Test @MainActor
func bridgeAliasReplacementDoesNotContentDedupAwayAuthenticatedRadioRow() {
let (viewModel, _) = makeTestableViewModel()
let content = "same bridge and radio payload"
let timestamp = Date()
let bridgeMessage = BitchatMessage(
id: "bridge-event-id",
sender: "remote#beef",
content: content,
timestamp: timestamp,
isRelay: false,
senderPeerID: PeerID(bridge: String(repeating: "a", count: 64)),
isBridged: true
)
viewModel.handlePublicMessage(bridgeMessage)
viewModel.publicMessagePipeline.flushIfNeeded()
#expect(viewModel.publicConversationContainsMessage(withID: bridgeMessage.id, in: .mesh))
viewModel.removeBridgeInjectedPublicMessage(withID: bridgeMessage.id)
let radioMessage = BitchatMessage(
id: "radio-stable-id",
sender: "remote",
content: content,
timestamp: timestamp,
isRelay: false,
senderPeerID: PeerID(str: "1122334455667788")
)
viewModel.handlePublicMessage(radioMessage)
viewModel.publicMessagePipeline.flushIfNeeded()
#expect(!viewModel.publicConversationContainsMessage(withID: bridgeMessage.id, in: .mesh))
#expect(viewModel.publicConversationContainsMessage(withID: radioMessage.id, in: .mesh))
}
@Test @MainActor
func addPublicSystemMessage_persistsAcrossTimelineRefresh() async {
let (viewModel, _) = makeTestableViewModel()
+131
View File
@@ -66,6 +66,52 @@ struct CourierStoreTests {
#expect(store.takeEnvelopes(for: Data(repeating: 0xB0, count: 32)).count == 1)
}
@Test func rejectedPhysicalHandoverRetainsEnvelopeUntilAcceptedRetry() {
let store = makeStore()
let recipientKey = Data(repeating: 0xB0, count: 32)
let envelope = makeEnvelope(recipientKey: recipientKey)
#expect(store.deposit(envelope, from: depositorA))
var rejectedOffers: [CourierEnvelope] = []
let rejected = store.handoverEnvelopes(for: recipientKey) { offered in
rejectedOffers.append(offered)
return false
}
#expect(rejected == 0)
#expect(rejectedOffers == [envelope])
#expect(!store.isEmpty)
var acceptedOffers: [CourierEnvelope] = []
let accepted = store.handoverEnvelopes(for: recipientKey) { offered in
acceptedOffers.append(offered)
return true
}
#expect(accepted == 1)
#expect(acceptedOffers == [envelope])
#expect(store.isEmpty)
}
@Test func midTrainFragmentRejectionRetainsDurableEnvelope() {
let store = makeStore()
let recipientKey = Data(repeating: 0xB0, count: 32)
let envelope = makeEnvelope(recipientKey: recipientKey)
#expect(store.deposit(envelope, from: depositorA))
var attemptedFragments: [Int] = []
let accepted = store.handoverEnvelopes(for: recipientKey) { _ in
BLEStrictFragmentAdmission.admitAll([0, 1, 2]) { fragment in
attemptedFragments.append(fragment)
return fragment != 1
}
}
#expect(accepted == 0)
#expect(attemptedFragments == [0, 1])
#expect(!store.isEmpty)
#expect(store.takeEnvelopes(for: recipientKey) == [envelope])
}
@Test func duplicateDepositIsIdempotent() {
let store = makeStore()
let recipientKey = Data(repeating: 0xB0, count: 32)
@@ -174,6 +220,46 @@ struct CourierStoreTests {
#expect(second.takeEnvelopes(for: recipientKey) == [envelope])
}
@Test func protectedDataReadFailureDoesNotOverwriteDurableMailAndMergesOnRecovery() {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("courier-protected-data-\(UUID().uuidString)", isDirectory: true)
.appendingPathComponent("envelopes.json")
defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) }
let durableRecipient = Data(repeating: 0xE1, count: 32)
let wakeRecipient = Data(repeating: 0xE2, count: 32)
let durableEnvelope = makeEnvelope(recipientKey: durableRecipient)
let wakeEnvelope = makeEnvelope(recipientKey: wakeRecipient)
let seed = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
#expect(seed.deposit(durableEnvelope, from: depositorA))
let durableBytes = try? Data(contentsOf: fileURL)
var protectedDataUnavailable = true
let restored = CourierStore(
persistsToDisk: true,
fileURL: fileURL,
now: { Self.baseDate },
readData: { url in
if protectedDataUnavailable {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
}
return try Data(contentsOf: url)
}
)
#expect(restored.deposit(wakeEnvelope, from: depositorB))
// The locked wake accepted new work in memory but did not replace the
// unreadable file with that partial view.
#expect((try? Data(contentsOf: fileURL)) == durableBytes)
protectedDataUnavailable = false
restored.retryDeferredPersistence()
let afterUnlock = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
#expect(afterUnlock.takeEnvelopes(for: durableRecipient) == [durableEnvelope])
#expect(afterUnlock.takeEnvelopes(for: wakeRecipient) == [wakeEnvelope])
}
// MARK: - Tiers (open couriering)
@Test func verifiedTierGetsSmallerPerDepositorQuota() {
@@ -282,6 +368,29 @@ struct CourierStoreTests {
#expect(store.takeEnvelopes(for: recipientKey).count == 1)
}
@Test func rejectedSprayTransferPreservesBudgetAndCourierEligibility() {
let store = makeStore()
let recipientKey = Data(repeating: 0xB0, count: 32)
let courier = Data(repeating: 0xC1, count: 32)
#expect(store.deposit(makeEnvelope(recipientKey: recipientKey).withCopies(4), from: depositorA))
var rejectedOffers: [CourierEnvelope] = []
let rejected = store.transferSprayCopies(to: courier) { offered in
rejectedOffers.append(offered)
return false
}
#expect(rejected == 0)
#expect(rejectedOffers.map(\.copies) == [2])
// The same courier remains eligible and receives the original half
// budget, proving neither `copies` nor `sprayedTo` changed on failure.
let acceptedRetry = store.takeSprayCopies(for: courier)
#expect(acceptedRetry.map(\.copies) == [2])
let nextCourier = store.takeSprayCopies(for: Data(repeating: 0xC2, count: 32))
#expect(nextCourier.map(\.copies) == [1])
}
@Test func carryOnlyEnvelopesAreNeverSprayed() {
let store = makeStore()
#expect(store.deposit(makeEnvelope(), from: depositorA))
@@ -300,6 +409,28 @@ struct CourierStoreTests {
#expect(sprayed.first?.copies == 2)
}
@Test func duplicateReplayCannotReplenishSpentSprayBudget() {
let store = makeStore()
let recipientKey = Data(repeating: 0xB0, count: 32)
let original = makeEnvelope(recipientKey: recipientKey).withCopies(8)
#expect(store.deposit(original, from: depositorA))
let courierX = Data(repeating: 0xC1, count: 32)
let courierY = Data(repeating: 0xC2, count: 32)
let courierZ = Data(repeating: 0xC3, count: 32)
let courierW = Data(repeating: 0xC4, count: 32)
#expect(store.takeSprayCopies(for: courierX).map(\.copies) == [4])
// Replaying the original signed deposit still accepts idempotently,
// but it cannot reset the local branch from 4 copies back to 8.
#expect(store.deposit(original, from: depositorA))
#expect(store.takeSprayCopies(for: courierY).map(\.copies) == [2])
#expect(store.deposit(original, from: depositorA))
#expect(store.takeSprayCopies(for: courierZ).map(\.copies) == [1])
#expect(store.deposit(original, from: depositorA))
#expect(store.takeSprayCopies(for: courierW).isEmpty)
}
// MARK: - Remote handover (relayed announces)
@Test func remoteHandoverIsNonDestructiveAndCooledDown() {
@@ -96,6 +96,20 @@ struct CourierEndToEndTests {
service._test_handlePacket(packet, fromPeerID: peer.myPeerID)
}
/// Establishes the Noise session that proves the direct link's peer owns
/// its announced static identity. CoreBluetooth is disabled in this suite,
/// so the handshake is ferried in-process just like courier packets.
private func establishNoiseSession(between initiator: BLEService, and responder: BLEService) throws {
let message1 = try initiator._test_noiseInitiateHandshake(with: responder.myPeerID)
let message2 = try #require(
try responder._test_noiseProcessHandshakeMessage(from: initiator.myPeerID, message: message1)
)
let message3 = try #require(
try initiator._test_noiseProcessHandshakeMessage(from: responder.myPeerID, message: message2)
)
_ = try responder._test_noiseProcessHandshakeMessage(from: initiator.myPeerID, message: message3)
}
// MARK: - Tests
@Test func courierCarriesMessageAcrossDisjointConnectivity() async throws {
@@ -141,7 +155,9 @@ struct CourierEndToEndTests {
)
#expect(carried)
// 3. Later, Bob announces near Carol handover fires.
// 3. Later, Bob proves link ownership and announces near Carol
// handover fires.
try establishNoiseSession(between: carol, and: bob)
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
@@ -156,7 +172,10 @@ struct CourierEndToEndTests {
timeout: TestConstants.defaultTimeout
)
#expect(handedOver)
#expect(carol.courierStore.isEmpty)
// With CoreBluetooth disabled there is no physical link for the send
// planner to accept, so Carol truthfully retains the durable copy even
// though the packet tap lets us ferry the attempted handover below.
#expect(!carol.courierStore.isEmpty)
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
#expect(PeerID(hexData: handoverPacket.recipientID) == bob.myPeerID)
@@ -222,6 +241,7 @@ struct CourierEndToEndTests {
)
#expect(carried)
try establishNoiseSession(between: carol, and: bob)
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
@@ -309,7 +329,7 @@ struct CourierEndToEndTests {
timeout: TestConstants.defaultTimeout
)
#expect(handedOver)
#expect(carol.courierStore.isEmpty)
#expect(!carol.courierStore.isEmpty)
}
@Test func relayedAnnounceTriggersNonDestructiveRemoteHandover() async throws {
@@ -395,7 +415,11 @@ struct CourierEndToEndTests {
#expect(!refloodedInCooldown)
#expect(!carol.courierStore.isEmpty)
// A later *direct* announce still performs the destructive handover.
// A peer-level Noise session is still insufficient without a physical
// ingress link that completed that handshake. This CoreBluetooth-free
// harness deliberately has no such link proof, so restoring the
// unsigned direct TTL cannot authorize destructive handover.
try establishNoiseSession(between: carol, and: bob)
try await Task.sleep(nanoseconds: 1_100_000_000)
bob.sendBroadcastAnnounce()
let announcedAgain = await TestHelpers.waitUntil(
@@ -408,12 +432,12 @@ struct CourierEndToEndTests {
)
carol._test_handlePacket(directAgain, fromPeerID: bob.myPeerID, preseedPeer: false)
let handedOver = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) == 2 },
timeout: TestConstants.defaultTimeout
let handedOverWithoutLinkProof = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) > 1 },
timeout: TestConstants.shortTimeout
)
#expect(handedOver)
#expect(carol.courierStore.isEmpty)
#expect(!handedOverWithoutLinkProof)
#expect(!carol.courierStore.isEmpty)
}
@Test func sendCourierMessageRejectsInvalidRecipientKeyBeforeQueueing() async throws {
@@ -755,9 +779,9 @@ struct MessageRouterCourierTests {
)
let router = MessageRouter(transports: [transport], courierDirectory: directory)
var drops: [(content: String, messageID: String, key: Data)] = []
router.bridgeCourierDeposit = { content, messageID, key in
router.bridgeCourierDeposit = { content, messageID, key, completion in
drops.append((content, messageID, key))
return true
completion(true)
}
var carried: [String] = []
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
@@ -35,19 +35,15 @@ struct FragmentationTests {
// Use a small fragment size to ensure multiple pieces
let fragments = fragmentPacket(original, fragmentSize: 400)
// Shuffle fragments to simulate out-of-order arrival
let shuffled = fragments.shuffled()
// Reverse deterministically to simulate out-of-order arrival without
// making a failure depend on a random permutation.
let outOfOrder = fragments.reversed()
// Send fragments sequentially with small delays (no fire-and-forget Tasks)
for (i, fragment) in shuffled.enumerated() {
if i > 0 {
try await Task.sleep(for: .milliseconds(5))
}
for fragment in outOfOrder {
ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey)
}
// Wait for delegate callback with proper timeout
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(5))
await ble._test_drainFragmentPipeline()
#expect(capture.publicMessages.count == 1)
#expect(capture.publicMessages.first?.content.count == 3_000)
@@ -73,16 +69,11 @@ struct FragmentationTests {
frags.insert(dup, at: 1)
}
// Send fragments sequentially with small delays (no fire-and-forget Tasks)
for (i, fragment) in frags.enumerated() {
if i > 0 {
try await Task.sleep(for: .milliseconds(5))
}
for fragment in frags {
ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey)
}
// Wait for delegate callback with proper timeout
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(5))
await ble._test_drainFragmentPipeline()
#expect(capture.publicMessages.count == 1)
#expect(capture.publicMessages.first?.content.count == 2048)
@@ -126,14 +117,11 @@ struct FragmentationTests {
let fragments = fragmentPacket(packet, fragmentSize: 4096, pad: false)
#expect(!fragments.isEmpty)
for (i, fragment) in fragments.enumerated() {
if i > 0 {
try await Task.sleep(for: .milliseconds(5))
}
for fragment in fragments {
ble._test_handlePacket(fragment, fromPeerID: remoteID, signingPublicKey: signingKey)
}
try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(5))
await ble._test_drainFragmentPipeline()
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
#expect(message.content.hasPrefix("[file]"))
@@ -173,15 +161,11 @@ struct FragmentationTests {
corrupted[0] = p
}
for (i, fragment) in corrupted.enumerated() {
if i > 0 {
try await Task.sleep(for: .milliseconds(5))
}
for fragment in corrupted {
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
}
// Allow async processing
try await sleep(0.5)
await ble._test_drainFragmentPipeline()
// Should not deliver since one fragment is invalid and reassembly can't complete
#expect(capture.publicMessages.isEmpty)
@@ -207,10 +191,6 @@ extension FragmentationTests {
private let lock = NSLock()
private var _publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
private var _receivedMessages: [BitchatMessage] = []
private var publicMessageContinuation: CheckedContinuation<Void, Never>?
private var receivedMessageContinuation: CheckedContinuation<Void, Never>?
private var expectedPublicMessageCount: Int = 0
private var expectedReceivedMessageCount: Int = 0
private func withLock<T>(_ body: () -> T) -> T {
lock.lock()
@@ -227,139 +207,11 @@ extension FragmentationTests {
}
func didReceiveMessage(_ message: BitchatMessage) {
lock.lock()
_receivedMessages.append(message)
let count = _receivedMessages.count
let expected = expectedReceivedMessageCount
let continuation = receivedMessageContinuation
lock.unlock()
if count >= expected, let cont = continuation {
lock.lock()
receivedMessageContinuation = nil
lock.unlock()
cont.resume()
}
withLock { _receivedMessages.append(message) }
}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
lock.lock()
_publicMessages.append((peerID, nickname, content))
let count = _publicMessages.count
let expected = expectedPublicMessageCount
let continuation = publicMessageContinuation
lock.unlock()
if count >= expected, let cont = continuation {
lock.lock()
publicMessageContinuation = nil
lock.unlock()
cont.resume()
}
}
/// Waits for the specified number of public messages to be received
func waitForPublicMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
let isAlreadySatisfied = withLock { () -> Bool in
if _publicMessages.count >= count {
return true
}
expectedPublicMessageCount = count
return false
}
if isAlreadySatisfied {
return
}
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
// withCheckedContinuation itself is not cancellable, so hook
// group.cancelAll() to resume the parked continuation
// otherwise a timeout leaves the group awaiting this child
// forever and the test run hangs instead of failing.
await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
let shouldResumeImmediately = self.withLock {
// Recheck count after acquiring lock to avoid race condition
// where message arrives between initial check and continuation install
if self._publicMessages.count >= count {
return true
}
self.publicMessageContinuation = continuation
return false
}
if shouldResumeImmediately {
continuation.resume()
}
}
} onCancel: {
let continuation = self.withLock {
let parked = self.publicMessageContinuation
self.publicMessageContinuation = nil
return parked
}
continuation?.resume()
}
}
group.addTask {
try await Task.sleep(for: timeout)
throw CancellationError()
}
try await group.next()
group.cancelAll()
}
}
/// Waits for the specified number of received messages
func waitForReceivedMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
let isAlreadySatisfied = withLock { () -> Bool in
if _receivedMessages.count >= count {
return true
}
expectedReceivedMessageCount = count
return false
}
if isAlreadySatisfied {
return
}
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
// withCheckedContinuation itself is not cancellable, so hook
// group.cancelAll() to resume the parked continuation
// otherwise a timeout leaves the group awaiting this child
// forever and the test run hangs instead of failing.
await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
let shouldResumeImmediately = self.withLock {
// Recheck count after acquiring lock to avoid race condition
// where message arrives between initial check and continuation install
if self._receivedMessages.count >= count {
return true
}
self.receivedMessageContinuation = continuation
return false
}
if shouldResumeImmediately {
continuation.resume()
}
}
} onCancel: {
let continuation = self.withLock {
let parked = self.receivedMessageContinuation
self.receivedMessageContinuation = nil
return parked
}
continuation?.resume()
}
}
group.addTask {
try await Task.sleep(for: timeout)
throw CancellationError()
}
try await group.next()
group.cancelAll()
}
withLock { _publicMessages.append((peerID, nickname, content)) }
}
func didConnectToPeer(_ peerID: PeerID) {}
@@ -41,12 +41,17 @@ private final class StubLocationManaging: LocationStateManaging {
weak var delegate: CLLocationManagerDelegate?
var desiredAccuracy: CLLocationAccuracy = 0
var distanceFilter: CLLocationDistance = 0
var authorizationStatus: CLAuthorizationStatus = .denied
var authorizationStatus: CLAuthorizationStatus
private(set) var stopUpdatingLocationCallCount = 0
init(authorizationStatus: CLAuthorizationStatus = .denied) {
self.authorizationStatus = authorizationStatus
}
func requestWhenInUseAuthorization() {}
func requestLocation() {}
func startUpdatingLocation() {}
func stopUpdatingLocation() {}
func stopUpdatingLocation() { stopUpdatingLocationCallCount += 1 }
}
private final class StubLocationGeocoder: LocationStateGeocoding {
@@ -62,7 +67,11 @@ private final class StubLocationGeocoder: LocationStateGeocoding {
// MARK: - Helpers
@MainActor
private func makeLocationManager(storage: UserDefaults? = nil) -> LocationStateManager {
private func makeLocationManager(
storage: UserDefaults? = nil,
authorizationStatus: CLAuthorizationStatus = .denied,
shouldInitializeCoreLocation: Bool = false
) -> LocationStateManager {
let suiteName = "GeoChannelCoordinatorContextTests-\(UUID().uuidString)"
let defaults = storage ?? UserDefaults(suiteName: suiteName)!
if storage == nil {
@@ -70,9 +79,9 @@ private func makeLocationManager(storage: UserDefaults? = nil) -> LocationStateM
}
return LocationStateManager(
storage: defaults,
locationManager: StubLocationManaging(),
locationManager: StubLocationManaging(authorizationStatus: authorizationStatus),
geocoder: StubLocationGeocoder(),
shouldInitializeCoreLocation: false
shouldInitializeCoreLocation: shouldInitializeCoreLocation
)
}
@@ -172,14 +181,21 @@ struct GeoChannelCoordinatorContextTests {
@Test @MainActor
func buildingCellJoinsSamplingOnlyAfterNotesReveal() async {
TorManager.shared.setAppForeground(true)
let locationManager = makeLocationManager()
let locationManager = makeLocationManager(
authorizationStatus: .authorizedAlways,
shouldInitializeCoreLocation: true
)
#expect(await waitUntil { locationManager.permissionState == .authorized })
let context = MockGeoChannelContext()
let revealed = CurrentValueSubject<Bool, Never>(false)
let notesEnabled = CurrentValueSubject<Bool, Never>(true)
let coordinator = GeoChannelCoordinator(
locationManager: locationManager,
bookmarksStore: locationManager,
torManager: TorManager.shared,
notesRevealed: revealed.eraseToAnyPublisher(),
locationNotesEnabled: true,
locationNotesSettings: notesEnabled.eraseToAnyPublisher(),
context: context
)
defer { withExtendedLifetime(coordinator) {} }
@@ -205,13 +221,66 @@ struct GeoChannelCoordinatorContextTests {
revealed.send(true)
#expect(await waitUntil { context.beginSamplingCalls.last?.contains(building) == true })
#expect(context.beginSamplingCalls.last?.count == GeohashChannelLevel.allCases.count)
// The app-info kill switch must narrow the already-live sampling set
// immediately, without waiting for another location update.
notesEnabled.send(false)
#expect(await waitUntil {
context.beginSamplingCalls.last?.contains(building) == false &&
context.beginSamplingCalls.last?.count == GeohashChannelLevel.allCases.count - 1
})
}
@Test @MainActor
func permissionRevocationEndsCachedRegionalSampling_butBookmarksRemainEligible() async {
TorManager.shared.setAppForeground(true)
let locationManager = makeLocationManager(
authorizationStatus: .authorizedAlways,
shouldInitializeCoreLocation: true
)
#expect(await waitUntil { locationManager.permissionState == .authorized })
let context = MockGeoChannelContext()
let revealed = CurrentValueSubject<Bool, Never>(true)
let coordinator = GeoChannelCoordinator(
locationManager: locationManager,
bookmarksStore: locationManager,
torManager: TorManager.shared,
notesRevealed: revealed.eraseToAnyPublisher(),
locationNotesEnabled: true,
locationNotesSettings: Empty().eraseToAnyPublisher(),
context: context
)
defer { withExtendedLifetime(coordinator) {} }
locationManager.locationManager(
CLLocationManager(),
didUpdateLocations: [CLLocation(latitude: 21.2850, longitude: -157.8357)]
)
#expect(await waitUntil {
context.beginSamplingCalls.last?.count == GeohashChannelLevel.allCases.count
})
let cachedChannels = locationManager.availableChannels
let endCountBeforeRevocation = context.endSamplingCount
locationManager.locationManager(CLLocationManager(), didChangeAuthorization: .denied)
#expect(await waitUntil {
locationManager.permissionState == .denied &&
context.endSamplingCount > endCountBeforeRevocation
})
#expect(locationManager.availableChannels == cachedChannels)
// A bookmark is an explicit remote scope and does not derive from
// the now-revoked device location.
locationManager.addBookmark("u4pru")
#expect(await waitUntil { context.beginSamplingCalls.last == ["u4pru"] })
}
@Test @MainActor
func releasedContext_isHeldWeaklyAndSafelyIgnored() async {
let locationManager = makeLocationManager()
var context: MockGeoChannelContext? = MockGeoChannelContext()
weak var weakContext = context
let weakContext = { [weak context] in context }
let coordinator = GeoChannelCoordinator(
locationManager: locationManager,
bookmarksStore: locationManager,
@@ -223,7 +292,7 @@ struct GeoChannelCoordinatorContextTests {
// The coordinator must not keep the owner alive (it is owned by it).
context = nil
#expect(weakContext == nil)
#expect(weakContext() == nil)
// Events after the owner is gone are safely dropped.
locationManager.select(.location(GeohashChannel(level: .city, geohash: "u4pru")))
@@ -312,6 +312,30 @@ struct MessageDeduplicationServiceTests {
#expect(service.contentTimestamp(forKey: key) == now)
}
@Test func forgetContent_allowsAuthenticatedReplacementThroughNextBatch() {
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
let content = "bridge alias payload"
let timestamp = Date()
service.recordContent(content, timestamp: timestamp)
service.forgetContent(content, ifRecordedAt: timestamp)
#expect(service.contentTimestamp(for: content) == nil)
}
@Test func forgetContent_doesNotEraseNewerSameContentMarker() {
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
let content = "repeated payload"
let old = Date(timeIntervalSince1970: 1_000)
let newer = Date(timeIntervalSince1970: 2_000)
service.recordContent(content, timestamp: old)
service.recordContent(content, timestamp: newer)
service.forgetContent(content, ifRecordedAt: old)
#expect(service.contentTimestamp(for: content) == newer)
}
@Test func normalizedContentKey_consistentWithNormalizer() {
let service = MessageDeduplicationService(contentCapacity: 100, nostrEventCapacity: 100)
let content = "Hello World"
+11
View File
@@ -17,6 +17,7 @@ final class MockKeychain: KeychainManagerProtocol {
// BCH-01-009: Configurable error simulation for testing
var simulatedReadError: KeychainReadResult?
var simulatedSaveError: KeychainSaveResult?
var simulatedGenericReadError: KeychainReadResult?
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
storage[key] = keyData
@@ -82,6 +83,16 @@ final class MockKeychain: KeychainManagerProtocol {
serviceStorage[service]?[key]
}
func loadWithResult(key: String, service: String) -> KeychainReadResult {
if let simulatedGenericReadError {
return simulatedGenericReadError
}
if let data = serviceStorage[service]?[key] {
return .success(data)
}
return .itemNotFound
}
func delete(key: String, service: String) {
serviceStorage[service]?.removeValue(forKey: key)
}
+157
View File
@@ -91,6 +91,62 @@ final class NearbyNotesCounterTests: XCTestCase {
XCTAssertEqual(counter.noteCount, 0)
}
func test_permissionRevocation_releasesBuildingSubscriptionDespiteCachedChannels() async throws {
let relays = SubscriptionRecorder()
let locationManager = try await makeAuthorizedLocationManager()
let counter = NearbyNotesCounter(
locationManager: locationManager,
managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) },
releaseManager: { $0?.cancel() }
)
counter.activate()
counter.reveal()
XCTAssertEqual(relays.subscribeCount, 1)
locationManager.locationManager(CLLocationManager(), didChangeAuthorization: .denied)
let denied = await waitUntil { locationManager.permissionState == .denied }
XCTAssertTrue(denied)
let released = await waitUntil { relays.unsubscribeCount == 1 }
XCTAssertTrue(released)
XCTAssertTrue(
locationManager.availableChannels.contains { $0.level == .building },
"the privacy boundary must not depend on cached channels being cleared"
)
XCTAssertEqual(counter.noteCount, 0)
counter.deactivate()
XCTAssertEqual(relays.unsubscribeCount, 1, "revocation already released the manager")
}
func test_locationNotesKillSwitch_releasesAndCanReacquireBuildingSubscription() async throws {
let relays = SubscriptionRecorder()
let locationManager = try await makeAuthorizedLocationManager()
let counter = NearbyNotesCounter(
locationManager: locationManager,
managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) },
releaseManager: { $0?.cancel() }
)
counter.activate()
counter.reveal()
XCTAssertEqual(relays.subscribeCount, 1)
LocationNotesSettings.enabled = false
let released = await waitUntil { relays.unsubscribeCount == 1 }
XCTAssertTrue(released)
XCTAssertEqual(counter.noteCount, 0)
LocationNotesSettings.enabled = true
let reacquired = await waitUntil { relays.subscribeCount == 2 }
XCTAssertTrue(reacquired)
counter.deactivate()
XCTAssertEqual(relays.unsubscribeCount, 2)
}
func test_checkNotesHint_requiresAuthorizedLocationPermission() {
let relays = SubscriptionRecorder()
let counter = NearbyNotesCounter(
@@ -127,6 +183,107 @@ final class NearbyNotesCounterTests: XCTestCase {
XCTAssertFalse(NoticesView.revealsNearbyNotes(onSwitchingTo: .mesh, geoGeohash: "u4pruydq"))
}
func test_noticesGeoPresentation_disabledOverridesSelectedScopeAndHidesOnlyGeoComposer() {
XCTAssertEqual(
NoticesView.geoPresentationState(notesEnabled: false, geohash: "9q8yyk8y"),
.disabled,
"a selected remote channel must not leave a blank manager-less notes view"
)
XCTAssertNil(
NoticesView.composerGeohash(
tab: .geo,
notesEnabled: false,
geoGeohash: "9q8yyk8y"
),
"the dead geo composer must be hidden while the notes kill switch is off"
)
XCTAssertEqual(
NoticesView.composerGeohash(tab: .mesh, notesEnabled: false, geoGeohash: nil),
"",
"the location-notes setting must not disable mesh notices"
)
}
func test_noticesGeoSession_revocationEndsLiveRefreshAndReleasesDeviceScope() {
let relays = SubscriptionRecorder()
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: relays.dependencies)
var beginCount = 0
var endCount = 0
var releaseCount = 0
let next = NoticesView.reconcileGeoSession(
tab: .geo,
needsDeviceLocation: true,
permissionState: .denied,
notesEnabled: true,
geohash: "u4pruydq",
manager: manager,
ownsLiveRefresh: true,
beginLiveRefresh: { beginCount += 1 },
endLiveRefresh: { endCount += 1 },
acquire: { _ in XCTFail("revocation must not acquire"); return manager },
release: {
releaseCount += 1
$0?.cancel()
}
)
XCTAssertNil(next.manager)
XCTAssertFalse(next.ownsLiveRefresh)
XCTAssertEqual(beginCount, 0)
XCTAssertEqual(endCount, 1)
XCTAssertEqual(releaseCount, 1)
XCTAssertEqual(relays.unsubscribeCount, 1)
}
func test_noticesGeoSession_killSwitchClosesLocalScope_butDeniedRemoteScopeRemainsUsable() {
let relays = SubscriptionRecorder()
let localManager = LocationNotesManager(geohash: "u4pruydq", dependencies: relays.dependencies)
var endCount = 0
var releaseCount = 0
let disabled = NoticesView.reconcileGeoSession(
tab: .geo,
needsDeviceLocation: true,
permissionState: .authorized,
notesEnabled: false,
geohash: "u4pruydq",
manager: localManager,
ownsLiveRefresh: true,
beginLiveRefresh: {},
endLiveRefresh: { endCount += 1 },
acquire: { _ in XCTFail("disabled notes must not acquire"); return localManager },
release: {
releaseCount += 1
$0?.cancel()
}
)
XCTAssertNil(disabled.manager)
XCTAssertFalse(disabled.ownsLiveRefresh)
XCTAssertEqual(endCount, 1)
XCTAssertEqual(releaseCount, 1)
let remoteManager = LocationNotesManager(geohash: "9q8yyk8y", dependencies: relays.dependencies)
let remote = NoticesView.reconcileGeoSession(
tab: .geo,
needsDeviceLocation: false,
permissionState: .denied,
notesEnabled: true,
geohash: "9q8yyk8y",
manager: remoteManager,
ownsLiveRefresh: false,
beginLiveRefresh: {},
endLiveRefresh: { endCount += 1 },
acquire: { _ in XCTFail("matching remote manager should be reused"); return remoteManager },
release: { _ in XCTFail("device permission must not release an explicit remote scope") }
)
XCTAssertTrue(remote.manager === remoteManager)
XCTAssertFalse(remote.ownsLiveRefresh)
XCTAssertEqual(endCount, 1)
}
func test_pool_sharesOneManagerPerGeohash_andCancelsOnLastRelease() {
let relays = SubscriptionRecorder()
let pool = LocationNotesPool(
+1 -1
View File
@@ -70,7 +70,7 @@ struct NoiseEncryptionTests {
}
}
// TODO: Reuse
// Local error type for the keychain failure cases in this suite.
private struct KeychainTestError: Error, CustomStringConvertible {
let message: String
init(_ message: String) { self.message = message }
+470
View File
@@ -0,0 +1,470 @@
//
// PTTBurstPlayerTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import AVFoundation
import Foundation
@testable import bitchat
/// Thread-safe: the coordinator invokes it on its private serial queue.
private final class StubAudioSession: SessionApplying, @unchecked Sendable {
private let lock = NSLock()
private var _setCategoryError: Error?
var setCategoryError: Error? {
get { lock.withLock { _setCategoryError } }
set { lock.withLock { _setCategoryError = newValue } }
}
func setCategory(_ category: AudioSessionCoordinator.Category) throws {
try lock.withLock {
if let error = _setCategoryError {
_setCategoryError = nil
throw error
}
}
}
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {}
}
private struct StubSessionError: Error {}
private final class StubExclusivePlayback: ExclusivePlayback {
private(set) var pauseCount = 0
func pauseForExclusivity() {
pauseCount += 1
}
}
/// Blocks activation until released, so a test can land events inside the
/// window where the (off-main) session acquire is still in flight.
private final class GatedAudioSession: SessionApplying, @unchecked Sendable {
private let gate = DispatchSemaphore(value: 0)
private let lock = NSLock()
private var _categoryCallCount = 0
private var _activationCalls: [Bool] = []
/// Non-zero once the acquire has reached the session queue (setCategory
/// runs just before the gated setActive).
var categoryCallCount: Int { lock.withLock { _categoryCallCount } }
var activationCalls: [Bool] { lock.withLock { _activationCalls } }
func open() { gate.signal() }
func setCategory(_ category: AudioSessionCoordinator.Category) throws {
lock.withLock { _categoryCallCount += 1 }
}
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
if active { gate.wait() }
lock.withLock { _activationCalls.append(active) }
}
}
@MainActor
private final class MockPlaybackEngine: PTTPlaybackEngine {
private(set) var startCount = 0
private(set) var stopCount = 0
private(set) var scheduledBuffers: [AVAudioPCMBuffer] = []
private struct HeldCompletion {
let type: PTTPlaybackCompletionType
let callback: @Sendable (PTTPlaybackCompletionEvent) -> Void
}
private var heldCompletions: [HeldCompletion] = []
private(set) var requestedCompletionTypes: [PTTPlaybackCompletionType] = []
var startError: Error?
// No object -> the player registers no configuration-change observer.
var configChangeObject: AnyObject? { nil }
func start() throws {
if let error = startError { throw error }
startCount += 1
}
func play() {}
func stop() {
stopCount += 1
}
func schedule(
_ buffer: AVAudioPCMBuffer,
completionType: PTTPlaybackCompletionType,
completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void
) {
// Completions are held, not fired automatically: most tests exercise
// lifecycle, not drain-out. The mock models the important distinction
// between the node consuming bytes and audio actually playing out.
scheduledBuffers.append(buffer)
requestedCompletionTypes.append(completionType)
heldCompletions.append(HeldCompletion(type: completionType, callback: completionHandler))
}
/// Advances the node only to the point where it has consumed each
/// buffer. A `.dataPlayedBack` request must remain pending here.
func fireDataConsumedCallbacks() {
for completion in heldCompletions {
completion.callback(.dataConsumed)
}
}
/// Advances all scheduled audio through audible playback.
func fireDataPlayedBackCallbacks() {
let completions = heldCompletions
heldCompletions = []
for completion in completions {
completion.callback(.dataPlayedBack)
}
}
/// Models AVAudioPlayerNode flushing its callbacks because an external
/// engine reconfiguration stopped the node before MainActor rebuilt it.
func firePlaybackStoppedCallbacks() {
let completions = heldCompletions
heldCompletions = []
for completion in completions {
completion.callback(.playbackStopped)
}
}
/// Plays only the oldest scheduled buffer, leaving the rest as an
/// audible tail that an engine rebuild must preserve.
func fireNextDataPlayedBackCallback() {
guard let index = heldCompletions.firstIndex(where: { $0.type == .dataPlayedBack }) else { return }
let completion = heldCompletions.remove(at: index)
completion.callback(.dataPlayedBack)
}
}
@MainActor
struct PTTBurstPlayerTests {
private func makePlayer(
coordinator: AudioSessionCoordinator
) throws -> (player: PTTBurstPlayer, engines: () -> [MockPlaybackEngine]) {
final class EngineBox { var engines: [MockPlaybackEngine] = [] }
let box = EngineBox()
// Fresh exclusivity slot: parallel tests must not steal this player's
// app-wide playback slot mid-test (the async session acquire opens
// suspension windows the old synchronous start never had).
let player = try #require(PTTBurstPlayer(
coordinator: coordinator,
exclusivity: VoiceNotePlaybackCoordinator(),
makeEngine: {
let engine = MockPlaybackEngine()
box.engines.append(engine)
return engine
}
))
return (player, { box.engines })
}
/// Enough encoded audio to cross `TransportConfig.pttJitterBufferSeconds`
/// so playback starts without waiting for the deadline task.
private func encodeSineFrames(seconds: Double = 1.0) throws -> [Data] {
let encoder = try #require(PTTFrameEncoder())
let format = try #require(PTTAudioFormat.pcmFormat)
let totalFrames = AVAudioFrameCount(seconds * PTTAudioFormat.sampleRate)
let buffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: totalFrames))
buffer.frameLength = totalFrames
let channel = try #require(buffer.floatChannelData?[0])
for i in 0..<Int(totalFrames) {
channel[i] = sinf(2 * .pi * 440 * Float(i) / Float(PTTAudioFormat.sampleRate)) * 0.5
}
return encoder.encode(buffer)
}
/// The jitter-buffered start now acquires the session asynchronously
/// (its blocking IPC runs off the main actor), so tests await the
/// condition instead of asserting right after `enqueue`.
private func waitUntil(
_ condition: () -> Bool,
sourceLocation: SourceLocation = #_sourceLocation
) async {
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while !condition(), ContinuousClock.now < deadline {
await Task.yield()
try? await Task.sleep(nanoseconds: 1_000_000)
}
#expect(condition(), sourceLocation: sourceLocation)
}
// MARK: - Talk-over (bidirectional)
@Test func burstDrainWaitsForAudiblePlaybackNotDataConsumption() async throws {
let coordinator = AudioSessionCoordinator(session: StubAudioSession())
let (player, engines) = try makePlayer(coordinator: coordinator)
player.enqueue(try encodeSineFrames())
await waitUntil { player.isPlaying }
let engine = try #require(engines().first)
#expect(engine.requestedCompletionTypes.allSatisfy { $0 == .dataPlayedBack })
player.finishAfterDrain()
engine.fireDataConsumedCallbacks()
await Task.yield()
#expect(!player.stopped)
#expect(player.isPlaying)
engine.fireDataPlayedBackCallbacks()
await waitUntil { player.stopped }
#expect(!player.isPlaying)
}
@Test func olderPTTSessionAcquireCannotStealNewerPlaybackReservation() async throws {
let session = GatedAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let exclusivity = VoiceNotePlaybackCoordinator()
final class EngineBox { var engines: [MockPlaybackEngine] = [] }
let box = EngineBox()
let player = try #require(PTTBurstPlayer(
coordinator: coordinator,
exclusivity: exclusivity,
makeEngine: {
let engine = MockPlaybackEngine()
box.engines.append(engine)
return engine
}
))
player.enqueue(try encodeSineFrames())
await waitUntil { session.categoryCallCount == 1 }
// A user taps a voice note while the older inbound burst is blocked
// in audio-session activation. Its immediate play intent is newer.
let voiceNote = StubExclusivePlayback()
exclusivity.activate(voiceNote)
session.open()
await waitUntil { player.stopped }
#expect(box.engines.count == 1)
#expect(box.engines[0].startCount == 0)
#expect(voiceNote.pauseCount == 0)
#expect(!player.isPlaying)
}
@Test func categoryEscalationRestartsEngineAndKeepsStreaming() async throws {
let coordinator = AudioSessionCoordinator(session: StubAudioSession())
let (player, engines) = try makePlayer(coordinator: coordinator)
let frames = try encodeSineFrames()
player.enqueue(frames)
await waitUntil { player.isPlaying }
#expect(engines().count == 1)
#expect(engines()[0].startCount == 1)
#expect(!engines()[0].scheduledBuffers.isEmpty)
// Push-to-talk pressed while the burst plays: capture escalates the
// session category. The playback engine must restart under the new
// configuration, not die. (Escalation fan-out is delivered before
// acquire returns, so no waiting is needed here.)
let capture = try await coordinator.acquire(.capture) {}
#expect(engines().count == 2)
#expect(engines()[0].stopCount == 1)
#expect(engines()[1].startCount == 1)
#expect(player.isPlaying)
// Frames arriving after the restart keep playing on the new engine.
player.enqueue(frames)
#expect(!engines()[1].scheduledBuffers.isEmpty)
coordinator.release(capture)
player.stop()
#expect(!player.isPlaying)
}
@Test func categoryEscalationReplaysOnlyUnfinishedTailAfterBurstEnd() async throws {
let coordinator = AudioSessionCoordinator(session: StubAudioSession())
let (player, engines) = try makePlayer(coordinator: coordinator)
let frames = try encodeSineFrames()
player.enqueue(frames)
await waitUntil { player.isPlaying }
let originalEngine = engines()[0]
let originallyScheduled = originalEngine.scheduledBuffers.count
try #require(originallyScheduled > 1)
// One buffer has played, but deliberately do not yield for its
// MainActor completion task. The completion latch itself must keep
// the rebuild from replaying this already-completed prefix.
originalEngine.fireNextDataPlayedBackCallback()
player.finishAfterDrain()
// A real AVAudioPlayerNode also invokes requested callbacks when its
// engine is stopped by a configuration change. Those callbacks must
// leave the unheard tail pending for the fresh engine.
originalEngine.firePlaybackStoppedCallbacks()
// Capture joins after END while the remaining tail is still handed
// to the old engine. The fresh engine must replay that tail instead
// of looking empty and stopping immediately.
let capture = try await coordinator.acquire(.capture) {}
try #require(engines().count == 2)
let restartedEngine = engines()[1]
#expect(restartedEngine.scheduledBuffers.count == originallyScheduled - 1)
#expect(player.isPlaying)
#expect(!player.stopped)
// Only the fresh engine's audible completions may stop this finished
// burst; the stop callbacks above did not drain it.
#expect(player.isPlaying)
#expect(!player.stopped)
restartedEngine.fireDataPlayedBackCallbacks()
await waitUntil { player.stopped }
#expect(!player.isPlaying)
coordinator.release(capture)
}
@Test func realInterruptionStillStopsPlayback() async throws {
let coordinator = AudioSessionCoordinator(session: StubAudioSession())
let (player, engines) = try makePlayer(coordinator: coordinator)
let frames = try encodeSineFrames()
player.enqueue(frames)
await waitUntil { player.isPlaying }
// A system interruption (phone call) is not an escalation: stop.
await coordinator.handleInterruptionBegan()
#expect(!player.isPlaying)
#expect(engines().count == 1)
#expect(engines()[0].stopCount == 1)
// A stopped burst stays stopped.
let before = engines()[0].scheduledBuffers.count
player.enqueue(frames)
#expect(engines()[0].scheduledBuffers.count == before)
}
// MARK: - Burst END racing the async session acquire
/// Models production ownership (`ChatLiveVoiceCoordinator.finalize`): the
/// assembly the player's sole strong owner is discarded on burst END,
/// and only the parked draining reference keeps the player alive. The
/// audio must still play out and the session token must come back once
/// the drain finishes.
@Test func burstEndDuringSessionAcquireStillPlaysWithOnlyDrainOwner() async throws {
let session = GatedAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
final class EngineBox { var engines: [MockPlaybackEngine] = [] }
let box = EngineBox()
var owner: PTTBurstPlayer? = PTTBurstPlayer(
coordinator: coordinator,
exclusivity: VoiceNotePlaybackCoordinator(),
makeEngine: {
let engine = MockPlaybackEngine()
box.engines.append(engine)
return engine
}
)
try #require(owner != nil)
let weakPlayer = { [weak owner] in owner }
let frames = try encodeSineFrames()
owner?.enqueue(frames)
// END lands while activation is still blocked on the session queue.
// With nothing scheduled yet, the drain check must not mistake the
// not-yet-started burst for a played-out one and drop all its audio.
var draining: PTTBurstPlayer? = owner
owner?.onStopped = { draining = nil }
owner?.finishAfterDrain()
#expect(owner?.stopped == false)
owner = nil
session.open()
await waitUntil { weakPlayer()?.isPlaying == true }
#expect(box.engines.count == 1)
#expect(box.engines[0].startCount == 1)
#expect(!box.engines[0].scheduledBuffers.isEmpty)
// Play the tail out: the drain must stop the player, hand the token
// back (deactivation reaches the mock), and unpark the drain owner.
box.engines[0].fireDataPlayedBackCallbacks()
await waitUntil { session.activationCalls == [true, false] }
#expect(draining == nil)
#expect(weakPlayer() == nil)
}
/// Backstop: if every owner drops the player before it stopped, `deinit`
/// must hand the registered session token back the coordinator retains
/// tokens strongly, so a leaked one would keep the session active (and
/// pin any escalated category) for the app's lifetime.
@Test func ownerlessPlayerDeinitReleasesSessionToken() async throws {
let session = GatedAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
var player: PTTBurstPlayer? = PTTBurstPlayer(
coordinator: coordinator,
exclusivity: VoiceNotePlaybackCoordinator(),
makeEngine: { MockPlaybackEngine() }
)
try #require(player != nil)
let frames = try encodeSineFrames()
player?.enqueue(frames)
// Make sure the acquire is in flight (holding the player alive
// through its call frame) before the last external reference drops.
await waitUntil { session.categoryCallCount == 1 }
player?.finishAfterDrain()
player = nil
session.open()
// The acquire task keeps the player alive just long enough to start;
// when it deallocates, deinit must release the freshly stored token.
await waitUntil { session.activationCalls == [true, false] }
}
// MARK: - Session acquire failure
@Test func sessionAcquireFailureDoesNotStartUnregisteredPlayback() async throws {
let session = StubAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let (player, engines) = try makePlayer(coordinator: coordinator)
// Playing without a registered holder would leave the engine exposed
// to another holder's last-release deactivating the session under it.
session.setCategoryError = StubSessionError()
let frames = try encodeSineFrames()
player.enqueue(frames)
await waitUntil { player.stopped }
#expect(engines().count == 1)
#expect(engines()[0].startCount == 0)
#expect(!player.isPlaying)
// The failed start latched the player off; later frames are ignored.
player.enqueue(frames)
#expect(engines()[0].scheduledBuffers.isEmpty)
#expect(!player.isPlaying)
}
// MARK: - Engine start failure
@Test func engineStartFailureRebuildsOnceBeforeGivingUp() async throws {
let coordinator = AudioSessionCoordinator(session: StubAudioSession())
let (player, engines) = try makePlayer(coordinator: coordinator)
// A capture racing the start can reconfigure the session while the
// engine spins up (its escalation fan-out no-ops on a never-started
// player): the player must rebuild once against the settled
// configuration instead of latching off.
engines()[0].startError = StubSessionError()
let frames = try encodeSineFrames()
player.enqueue(frames)
await waitUntil { player.isPlaying }
#expect(engines().count == 2)
#expect(engines()[0].startCount == 0)
#expect(engines()[1].startCount == 1)
#expect(!engines()[1].scheduledBuffers.isEmpty)
player.stop()
}
}
@@ -791,6 +791,10 @@ private final class PerfPipelineFixture {
let identityManager = MockIdentityManager(keychain)
let transport = MockTransport()
let conversations = ConversationStore()
let locationSuite = "PerformanceBaselineTests.\(UUID().uuidString)"
let locationStorage = UserDefaults(suiteName: locationSuite) ?? .standard
locationStorage.removePersistentDomain(forName: locationSuite)
let locationManager = LocationChannelManager(storage: locationStorage)
self.conversations = conversations
self.viewModel = ChatViewModel(
@@ -798,7 +802,8 @@ private final class PerfPipelineFixture {
idBridge: idBridge,
identityManager: identityManager,
transport: transport,
conversations: conversations
conversations: conversations,
locationManager: locationManager
)
self.privateInbox = PrivateInboxModel(conversations: conversations)
self.publicChat = PublicChatModel(conversations: conversations)
@@ -186,7 +186,12 @@ struct CourierStoreBridgePublishTests {
// eligible again after the cooldown. `carriedCount` publishes
// asynchronously, so it is not asserted here.)
// Within cooldown: nothing to publish.
// Merely offering the envelope does not start the cooldown; a relay
// rejection/timeout must remain immediately retryable.
#expect(store.envelopesForBridgePublish(cooldown: 600).count == 1)
store.markBridgePublished(first[0])
// A confirmed publish starts the cooldown.
#expect(store.envelopesForBridgePublish(cooldown: 600).isEmpty)
// After cooldown: eligible again.
@@ -128,4 +128,18 @@ struct PublicMessagePipelineTests {
#expect(delegate.messages(in: .mesh).isEmpty)
#expect(delegate.recordedContentKeys.isEmpty)
}
@Test @MainActor
func removeMessage_discardsBridgeAliasBeforeBatchFlush() {
let pipeline = PublicMessagePipeline()
let delegate = TestPipelineDelegate()
pipeline.delegate = delegate
pipeline.enqueue(makeMessage(id: "bridge-event", content: "same radio payload", timestamp: Date()), to: .mesh)
pipeline.removeMessage(withID: "bridge-event")
pipeline.flushIfNeeded()
#expect(delegate.committed.isEmpty)
#expect(delegate.recordedContentKeys.isEmpty)
}
}
@@ -45,7 +45,7 @@ struct BLEAnnounceThrottleTests {
let now = Date(timeIntervalSince1970: 100)
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
throttle.shouldSend(force: false, now: now)
_ = throttle.shouldSend(force: false, now: now)
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
}
@@ -44,6 +44,33 @@ struct BLEOutboundFragmentTransferSchedulerTests {
}
}
@Test
func strictDirectTransferIsRejectedWithoutBeingQueuedWhenSlotsAreFull() {
var scheduler = BLEOutboundFragmentTransferScheduler()
let active = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "active")
let strict = makeRequest(
type: MessageType.fileTransfer.rawValue,
transferId: "strict",
requireDirectPeerLink: true
)
guard case .start = scheduler.submit(active, maxConcurrentTransfers: 1) else {
Issue.record("Expected active transfer to reserve the only slot")
return
}
let result = scheduler.submit(strict, maxConcurrentTransfers: 1)
if case let .rejectedStrict(request, transferId) = result {
#expect(request.requireDirectPeerLink)
#expect(transferId == "strict")
#expect(scheduler.activeCount == 1)
#expect(scheduler.pendingCount == 0)
} else {
Issue.record("Expected strict transfer to reject instead of entering the pending queue")
}
}
@Test
func submitQueuesDuplicateActiveTransferAtFront() {
var scheduler = BLEOutboundFragmentTransferScheduler()
@@ -240,7 +267,8 @@ struct BLEOutboundFragmentTransferSchedulerTests {
type: UInt8,
transferId: String?,
payload: String? = nil,
directedPeer: PeerID? = nil
directedPeer: PeerID? = nil,
requireDirectPeerLink: Bool = false
) -> BLEOutboundFragmentTransferRequest {
BLEOutboundFragmentTransferRequest(
packet: BitchatPacket(
@@ -255,7 +283,8 @@ struct BLEOutboundFragmentTransferSchedulerTests {
pad: false,
maxChunk: nil,
directedPeer: directedPeer,
transferId: transferId
transferId: transferId,
requireDirectPeerLink: requireDirectPeerLink
)
}
}
@@ -46,6 +46,33 @@ struct BLEOutboundLinkPlannerTests {
)
#expect(plan.fragmentChunkSize == BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: smallestLimit))
#expect(plan.selectedLinks.peripheralIDs == Set(["p1"]))
#expect(plan.selectedLinks.centralIDs == Set(["c1"]))
#expect(!plan.shouldSpoolDirectedPacket)
}
@Test
func oversizedDirectedCourierDoesNotUseUnrelatedPeersMTUAsHandoffSuccess() {
let recipient = PeerID(str: "1122334455667788")
let unrelated = PeerID(str: "8877665544332211")
let packet = makePacket(type: .courierEnvelope, recipient: recipient)
let plan = BLEOutboundLinkPlanner.plan(
packet: packet,
dataCount: 512,
peripheralIDs: ["unrelated-link"],
peripheralWriteLimits: [64],
centralIDs: [],
centralNotifyLimits: [],
ingressRecord: nil,
excludedLinks: [],
peripheralPeerBindings: ["unrelated-link": unrelated],
directedOnlyPeer: recipient,
requireDirectPeerLink: true
)
#expect(plan.directedPeerHint == recipient)
#expect(plan.fragmentChunkSize == nil)
#expect(plan.selectedLinks.peripheralIDs.isEmpty)
#expect(plan.selectedLinks.centralIDs.isEmpty)
#expect(!plan.shouldSpoolDirectedPacket)
@@ -93,6 +120,28 @@ struct BLEOutboundLinkPlannerTests {
#expect(plan.shouldSpoolDirectedPacket)
}
@Test
func bridgeCourierPacketDoesNotTurnProcessLocalSpoolIntoHandoffSuccess() {
let recipient = PeerID(str: "1122334455667788")
let packet = makePacket(type: .courierEnvelope, recipient: recipient)
let plan = BLEOutboundLinkPlanner.plan(
packet: packet,
dataCount: 32,
peripheralIDs: [],
peripheralWriteLimits: [],
centralIDs: [],
centralNotifyLimits: [],
ingressRecord: nil,
excludedLinks: [],
directedOnlyPeer: recipient
)
#expect(plan.selectedLinks.peripheralIDs.isEmpty)
#expect(plan.selectedLinks.centralIDs.isEmpty)
#expect(!plan.shouldSpoolDirectedPacket)
}
@Test
func publicBroadcastDoesNotSpoolWhenNoLinksAreAvailable() {
let packet = makePacket(type: .message)
@@ -67,4 +67,19 @@ struct BLEOutboundNotificationBufferTests {
#expect(buffer.isEmpty)
}
@Test
func unsubscribeRemovesTargetSpecificCiphertextOnlyForThatCentral() {
var buffer = BLEOutboundNotificationBuffer<String>()
_ = buffer.enqueue(data: Data([1]), targets: ["gone"], capCount: 4)
_ = buffer.enqueue(data: Data([2]), targets: ["gone", "live"], capCount: 4)
_ = buffer.enqueue(data: Data([3]), targets: nil, capCount: 4)
buffer.removeTarget { $0 == "gone" }
let remaining = buffer.takeAll()
#expect(remaining.map(\.data) == [Data([2]), Data([3])])
#expect(remaining[0].targets == ["live"])
#expect(remaining[1].targets == nil)
}
}
@@ -72,4 +72,38 @@ struct BLEOutboundWriteBufferTests {
#expect(buffer.peripheralIDs.isEmpty)
}
@Test
func acceptanceReportsWhenNewLowPriorityWriteIsTrimmed() {
var buffer = BLEOutboundWriteBuffer()
let peerID = "peer-1"
_ = buffer.enqueue(
data: Data(repeating: 0x01, count: 8),
for: peerID,
priority: .high,
capBytes: 8
)
let attempt = buffer.enqueueReportingAcceptance(
data: Data(repeating: 0x02, count: 8),
for: peerID,
priority: .low,
capBytes: 8
)
#expect(!attempt.accepted)
#expect(buffer.takeAll(for: peerID).compactMap(\.data.first) == [0x01])
}
@Test
func disconnectDiscardRemovesOnlyThatPeripheralQueue() {
var buffer = BLEOutboundWriteBuffer()
_ = buffer.enqueue(data: Data([1]), for: "gone", priority: .high, capBytes: 100)
_ = buffer.enqueue(data: Data([2]), for: "live", priority: .high, capBytes: 100)
buffer.discardAll(for: "gone")
#expect(buffer.takeAll(for: "gone").isEmpty)
#expect(buffer.takeAll(for: "live").map(\.data) == [Data([2])])
}
}
@@ -25,6 +25,9 @@ struct BridgeCourierServiceTests {
var held: [CourierEnvelope] = []
var sealResult: CourierEnvelope?
var deliverResult = true
var openResult = true
/// nil leaves the simulated relay confirmation in flight.
var automaticPublishResult: Bool? = true
private(set) var publishedEvents: [NostrEvent] = []
private(set) var openedSubscriptions: [[String]] = []
@@ -33,14 +36,28 @@ struct BridgeCourierServiceTests {
private(set) var delivered: [(envelope: CourierEnvelope, peer: PeerID)] = []
private(set) var sealRequests: [(content: String, messageID: String, key: Data)] = []
private(set) var heldCooldowns: [TimeInterval] = []
private(set) var markedHeldEnvelopes: [CourierEnvelope] = []
private(set) var scheduledTimers: [(delay: TimeInterval, fire: @MainActor () -> Void)] = []
private(set) var pendingPublishCompletions: [@MainActor (Bool) -> Void] = []
let service: BridgeCourierService
init(dedupStore: BridgeDropDedupStore? = nil) {
service = BridgeCourierService(dedupStore: dedupStore)
init(now: @escaping () -> Date = Date.init, dedupStore: BridgeDropDedupStore? = nil) {
service = BridgeCourierService(now: now, dedupStore: dedupStore)
service.bridgeEnabled = { [weak self] in self?.bridgeOn ?? false }
service.relaysConnected = { [weak self] in self?.relaysConnected ?? false }
service.publishEvent = { [weak self] event in self?.publishedEvents.append(event) }
service.publishEvent = { [weak self] event, completion in
guard let self else {
completion(false)
return
}
self.publishedEvents.append(event)
if let result = self.automaticPublishResult {
completion(result)
} else {
self.pendingPublishCompletions.append(completion)
}
}
service.openSubscription = { [weak self] tags in self?.openedSubscriptions.append(tags) }
service.closeSubscription = { [weak self] in self?.closedSubscriptions += 1 }
service.myNoiseKey = { [weak self] in self?.myKey }
@@ -49,7 +66,10 @@ struct BridgeCourierServiceTests {
self?.sealRequests.append((content, messageID, key))
return self?.sealResult
}
service.openEnvelope = { [weak self] envelope in self?.openedEnvelopes.append(envelope) }
service.openEnvelope = { [weak self] envelope in
self?.openedEnvelopes.append(envelope)
return self?.openResult ?? false
}
service.deliverToPeer = { [weak self] envelope, peer in
self?.delivered.append((envelope, peer))
return self?.deliverResult ?? false
@@ -58,7 +78,17 @@ struct BridgeCourierServiceTests {
self?.heldCooldowns.append(cooldown)
return self?.held ?? []
}
service.scheduleTimer = { _, _ in } // timers driven manually
service.markHeldEnvelopePublished = { [weak self] envelope in
self?.markedHeldEnvelopes.append(envelope)
}
service.scheduleTimer = { [weak self] delay, fire in
self?.scheduledTimers.append((delay, fire))
}
}
func resolveNextPublish(_ succeeded: Bool) {
guard !pendingPublishCompletions.isEmpty else { return }
pendingPublishCompletions.removeFirst()(succeeded)
}
static func randomKey() -> Data {
@@ -121,6 +151,115 @@ struct BridgeCourierServiceTests {
#expect(fixture.sealRequests.isEmpty)
}
@Test func missingRelayPublisherDoesNotConsumeDurableDedupSlot() {
let fixture = Fixture()
let key = Fixture.randomKey()
fixture.sealResult = makeEnvelope(recipientKey: key)
fixture.service.publishEvent = nil
let messageID = UUID().uuidString
var results: [Bool] = []
fixture.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key) { results.append($0) }
fixture.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key) { results.append($0) }
#expect(fixture.sealRequests.count == 2)
#expect(fixture.publishedEvents.isEmpty)
#expect(results == [false, false])
}
@Test func relayRejectionDoesNotPersistDedupAndRetryCanSucceed() {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
defer { try? FileManager.default.removeItem(at: fileURL) }
let key = Fixture.randomKey()
let messageID = UUID().uuidString
let failed = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
failed.sealResult = makeEnvelope(recipientKey: key)
failed.automaticPublishResult = false
var failedResults: [Bool] = []
failed.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key) {
failedResults.append($0)
}
failed.service.flushDedupSnapshot()
#expect(failedResults == [false])
#expect(failed.publishedEvents.count == 1)
// A relaunch over the failed attempt must be allowed to send again.
let retry = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
retry.sealResult = makeEnvelope(recipientKey: key)
var retryResults: [Bool] = []
retry.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key) {
retryResults.append($0)
}
retry.service.flushDedupSnapshot()
#expect(retryResults == [true])
#expect(retry.publishedEvents.count == 1)
// Only confirmed relay acceptance consumes durable dedup.
let confirmed = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
confirmed.sealResult = makeEnvelope(recipientKey: key)
confirmed.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key)
#expect(confirmed.publishedEvents.isEmpty)
#expect(confirmed.sealRequests.isEmpty)
}
@Test func panicWipeInvalidatesInFlightPublishCompletion() throws {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
defer { try? FileManager.default.removeItem(at: fileURL) }
let key = Fixture.randomKey()
let messageID = UUID().uuidString
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
fixture.sealResult = makeEnvelope(recipientKey: key)
fixture.automaticPublishResult = nil
var results: [Bool] = []
fixture.service.depositDrop(content: "in flight", messageID: messageID, recipientNoiseKey: key) {
results.append($0)
}
let staleCompletion = try #require(fixture.pendingPublishCompletions.first)
fixture.service.wipe()
#expect(results == [false])
// The pre-wipe relay completion cannot resurrect durable dedup or
// complete the caller a second time.
staleCompletion(true)
fixture.service.flushDedupSnapshot()
#expect(results == [false])
let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
relaunched.sealResult = makeEnvelope(recipientKey: key)
relaunched.service.depositDrop(content: "retry", messageID: messageID, recipientNoiseKey: key)
#expect(relaunched.publishedEvents.count == 1)
}
@Test func bridgeDisableCancelsPendingAndInFlightPublishes() throws {
let fixture = Fixture()
let key = Fixture.randomKey()
fixture.sealResult = makeEnvelope(recipientKey: key)
fixture.automaticPublishResult = nil
var results: [Bool] = []
fixture.service.depositDrop(content: "in flight", messageID: "in-flight", recipientNoiseKey: key) {
results.append($0)
}
let staleCompletion = try #require(fixture.pendingPublishCompletions.first)
fixture.relaysConnected = false
fixture.service.depositDrop(content: "pending", messageID: "pending", recipientNoiseKey: key) {
results.append($0)
}
#expect(fixture.service.pendingDrops.count == 1)
fixture.bridgeOn = false
fixture.service.refresh()
#expect(results == [false, false])
#expect(fixture.service.pendingDrops.isEmpty)
staleCompletion(true)
#expect(results == [false, false])
}
@Test func depositQueuesWithoutRelaysAndFlushesOnReconnect() {
let fixture = Fixture()
fixture.relaysConnected = false
@@ -148,15 +287,18 @@ struct BridgeCourierServiceTests {
fixture.sealResult = makeEnvelope(recipientKey: key)
let firstID = UUID().uuidString
#expect(fixture.service.depositDrop(content: "0", messageID: firstID, recipientNoiseKey: key))
var firstResults: [Bool] = []
fixture.service.depositDrop(content: "0", messageID: firstID, recipientNoiseKey: key) { firstResults.append($0) }
// Fill past capacity so the first drop is evicted.
for i in 1...BridgeCourierService.Limits.maxPendingDrops {
fixture.service.depositDrop(content: "\(i)", messageID: UUID().uuidString, recipientNoiseKey: key)
}
#expect(fixture.service.pendingDrops.count == BridgeCourierService.Limits.maxPendingDrops)
#expect(firstResults == [false])
// The evicted first drop is deposit-able again (slot released).
#expect(fixture.service.depositDrop(content: "0-retry", messageID: firstID, recipientNoiseKey: key))
fixture.service.depositDrop(content: "0-retry", messageID: firstID, recipientNoiseKey: key)
#expect(fixture.service.pendingDrops.last?.dedupKey == firstID)
}
@Test func oversizeDropConsumesSlotInsteadOfChurning() {
@@ -170,13 +312,45 @@ struct BridgeCourierServiceTests {
ciphertext: Data(repeating: 7, count: BridgeCourierService.Limits.maxDropEnvelopeBytes + 1)
)
let messageID = UUID().uuidString
var results: [Bool] = []
#expect(!fixture.service.depositDrop(content: "big", messageID: messageID, recipientNoiseKey: key))
fixture.service.depositDrop(content: "big", messageID: messageID, recipientNoiseKey: key) { results.append($0) }
#expect(fixture.publishedEvents.isEmpty)
// The retry sweep must not seal the same payload again.
#expect(!fixture.service.depositDrop(content: "big", messageID: messageID, recipientNoiseKey: key))
fixture.service.depositDrop(content: "big", messageID: messageID, recipientNoiseKey: key) { results.append($0) }
#expect(fixture.sealRequests.count == 1)
#expect(results == [false, false])
}
@Test func rejectedOversizeDropKeysExpireAndStayBounded() {
var date = Date(timeIntervalSince1970: 1_750_000_000)
let fixture = Fixture(now: { date })
let key = Fixture.randomKey()
fixture.sealResult = makeEnvelope(
recipientKey: key,
ciphertext: Data(repeating: 7, count: BridgeCourierService.Limits.maxDropEnvelopeBytes + 1)
)
let firstID = "oversize-0"
fixture.service.depositDrop(content: "big", messageID: firstID, recipientNoiseKey: key)
for index in 1...BridgeCourierService.Limits.maxTrackedIDs {
date = date.addingTimeInterval(1)
fixture.service.depositDrop(content: "big", messageID: "oversize-\(index)", recipientNoiseKey: key)
}
let afterCapacityFill = fixture.sealRequests.count
date = date.addingTimeInterval(1)
fixture.service.depositDrop(content: "big", messageID: firstID, recipientNoiseKey: key)
#expect(fixture.sealRequests.count == afterCapacityFill + 1)
let newestID = "oversize-\(BridgeCourierService.Limits.maxTrackedIDs)"
let beforeExpiry = fixture.sealRequests.count
fixture.service.depositDrop(content: "big", messageID: newestID, recipientNoiseKey: key)
#expect(fixture.sealRequests.count == beforeExpiry)
date = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + 1)
fixture.service.depositDrop(content: "big", messageID: newestID, recipientNoiseKey: key)
#expect(fixture.sealRequests.count == beforeExpiry + 1)
}
@Test func publishedDropDedupSurvivesRelaunch() throws {
@@ -192,8 +366,10 @@ struct BridgeCourierServiceTests {
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
#expect(fixture.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey))
var publishResults: [Bool] = []
fixture.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey) { publishResults.append($0) }
#expect(fixture.publishedEvents.count == 1)
#expect(publishResults == [true])
// Persistence is coalesced; a real launch flushes within a second or
// on backgrounding tests flush explicitly.
fixture.service.flushDedupSnapshot()
@@ -202,9 +378,11 @@ struct BridgeCourierServiceTests {
// publish the same message ID again (before even re-sealing it).
let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
relaunched.sealResult = makeEnvelope(recipientKey: recipientKey)
#expect(!relaunched.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey))
var relaunchResults: [Bool] = []
relaunched.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey) { relaunchResults.append($0) }
#expect(relaunched.publishedEvents.isEmpty)
#expect(relaunched.sealRequests.isEmpty)
#expect(relaunchResults == [false])
}
@Test func seenDropEventDedupSurvivesRelaunch() throws {
@@ -245,7 +423,7 @@ struct BridgeCourierServiceTests {
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
fixture.relaysConnected = false
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
#expect(fixture.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey))
fixture.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey)
#expect(fixture.publishedEvents.isEmpty)
// Even a flush while the drop is still pending must exclude its key.
fixture.service.flushDedupSnapshot()
@@ -253,7 +431,7 @@ struct BridgeCourierServiceTests {
// "App killed before relays connected": pendingDrops were memory-only.
let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
relaunched.sealResult = makeEnvelope(recipientKey: recipientKey)
#expect(relaunched.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey))
relaunched.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey)
#expect(relaunched.publishedEvents.count == 1)
}
@@ -269,7 +447,7 @@ struct BridgeCourierServiceTests {
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
fixture.relaysConnected = false
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
#expect(fixture.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey))
fixture.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey)
fixture.relaysConnected = true
fixture.service.flushPendingDrops()
#expect(fixture.publishedEvents.count == 1)
@@ -277,8 +455,10 @@ struct BridgeCourierServiceTests {
let relaunched = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
relaunched.sealResult = makeEnvelope(recipientKey: recipientKey)
#expect(!relaunched.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey))
var relaunchResults: [Bool] = []
relaunched.service.depositDrop(content: "later", messageID: messageID, recipientNoiseKey: recipientKey) { relaunchResults.append($0) }
#expect(relaunched.publishedEvents.isEmpty)
#expect(relaunchResults == [false])
}
@Test func failedGatewayHandoffReleasesSeenSlot() throws {
@@ -307,6 +487,30 @@ struct BridgeCourierServiceTests {
#expect(fixture.delivered.count == 2)
}
@Test func staleWatchSetDeliveryIsNotConsumedBeforePeerBecomesCurrent() throws {
let fixture = Fixture()
let peerKey = Fixture.randomKey()
let peer = PeerID(str: "aabbccdd00112233")
let event = try makeDropEvent(for: makeEnvelope(recipientKey: peerKey))
// A callback from the previous relay subscription can land after its
// peer was removed from the bounded watch set. Ignore it without
// poisoning the persistent event-ID dedup record.
fixture.service.refresh()
fixture.service.handleDropEvent(event)
#expect(fixture.delivered.isEmpty)
fixture.localPeers = [(peer, peerKey)]
fixture.service.refresh()
fixture.service.handleDropEvent(event)
#expect(fixture.delivered.count == 1)
// Once the current peer's physical handoff succeeds, normal durable
// dedup applies.
fixture.service.handleDropEvent(event)
#expect(fixture.delivered.count == 1)
}
@Test func distinctDropsUseDistinctThrowawayKeys() {
let fixture = Fixture()
let keyA = Fixture.randomKey()
@@ -328,6 +532,50 @@ struct BridgeCourierServiceTests {
#expect(fixture.publishedEvents.count == 1)
#expect(fixture.heldCooldowns == [BridgeCourierService.Limits.heldEnvelopePublishCooldown])
#expect(fixture.markedHeldEnvelopes == fixture.held)
}
@Test func rejectedHeldPublishDoesNotStartCooldown() {
let fixture = Fixture()
fixture.automaticPublishResult = false
fixture.held = [makeEnvelope(recipientKey: Fixture.randomKey())]
fixture.service.publishHeldEnvelopes()
#expect(fixture.publishedEvents.count == 1)
#expect(fixture.markedHeldEnvelopes.isEmpty)
}
@Test func heldPublishIsSingleFlightAndRetryableAfterRejection() {
let fixture = Fixture()
fixture.automaticPublishResult = nil
fixture.held = [makeEnvelope(recipientKey: Fixture.randomKey())]
fixture.service.publishHeldEnvelopes()
fixture.service.publishHeldEnvelopes()
#expect(fixture.publishedEvents.count == 1)
fixture.resolveNextPublish(false)
fixture.service.publishHeldEnvelopes()
#expect(fixture.publishedEvents.count == 2)
#expect(fixture.markedHeldEnvelopes.isEmpty)
fixture.resolveNextPublish(true)
#expect(fixture.markedHeldEnvelopes == fixture.held)
}
@Test func bridgeDisableInvalidatesHeldPublishOperation() throws {
let fixture = Fixture()
fixture.automaticPublishResult = nil
fixture.held = [makeEnvelope(recipientKey: Fixture.randomKey())]
fixture.service.publishHeldEnvelopes()
let staleCompletion = try #require(fixture.pendingPublishCompletions.first)
fixture.bridgeOn = false
fixture.service.refresh()
staleCompletion(true)
#expect(fixture.markedHeldEnvelopes.isEmpty)
}
// MARK: - Subscription management
@@ -364,6 +612,32 @@ struct BridgeCourierServiceTests {
#expect(fixture.closedSubscriptions == 1)
}
@Test func announceDebounceSchedulesTrailingRefreshForPeersLearnedInsideWindow() throws {
var date = Date(timeIntervalSince1970: 1_750_000_000)
let fixture = Fixture(now: { date })
// Leading edge opens the own-tag subscription immediately.
fixture.service.refreshAfterVerifiedAnnounce()
#expect(fixture.openedSubscriptions.count == 1)
// A second peer learned inside the debounce window must not wait for
// the 30-minute periodic timer.
date = date.addingTimeInterval(10)
fixture.localPeers = [(PeerID(str: "aabbccdd00112233"), Fixture.randomKey())]
fixture.service.refreshAfterVerifiedAnnounce()
fixture.service.refreshAfterVerifiedAnnounce() // coalesces, not a second timer
let trailingTimers = fixture.scheduledTimers.filter { $0.delay < 100 }
#expect(trailingTimers.count == 1)
let trailing = try #require(trailingTimers.first)
#expect(trailing.delay == 50)
date = date.addingTimeInterval(50)
trailing.fire()
#expect(fixture.openedSubscriptions.count == 2)
#expect(fixture.openedSubscriptions.last?.count == 6)
}
// MARK: - Inbound drops
@Test func dropForUsIsOpened() throws {
@@ -378,6 +652,21 @@ struct BridgeCourierServiceTests {
#expect(fixture.delivered.isEmpty)
}
@Test func transientOwnDropOpenFailureRemainsRetryable() throws {
let fixture = Fixture()
let myKey = try #require(fixture.myKey)
fixture.service.refresh()
let event = try makeDropEvent(for: makeEnvelope(recipientKey: myKey))
fixture.openResult = false
fixture.service.handleDropEvent(event)
fixture.openResult = true
fixture.service.handleDropEvent(event)
fixture.service.handleDropEvent(event)
#expect(fixture.openedEnvelopes.count == 2)
}
@Test func duplicateDropEventOpensOnce() throws {
let fixture = Fixture()
let myKey = try #require(fixture.myKey)
@@ -63,7 +63,7 @@ struct BridgeDropDedupStoreTests {
lifetime: 3600,
entries: [
"stale": now.addingTimeInterval(-7200),
"fresh": now.addingTimeInterval(-60),
"fresh": now.addingTimeInterval(-60)
],
now: now
)
+197 -33
View File
@@ -14,7 +14,7 @@ import Testing
@Suite("Mesh bridge policy")
@MainActor
struct BridgeServiceTests {
private static let cell = "u4pruy"
nonisolated private static let cell = "u4pruy"
/// Closure-injected harness around `BridgeService` recording every side
/// effect, with a controllable clock, location, and connectivity.
@@ -30,6 +30,7 @@ struct BridgeServiceTests {
var bridgePeers: [PeerID] = []
var sendSucceeds = true
var locallySeenMessageIDs: Set<String> = []
var injectedPresenceOverride: ((String) -> Bool)?
var nickname = "tester"
private(set) var published: [(event: NostrEvent, cell: String)] = []
@@ -41,6 +42,7 @@ struct BridgeServiceTests {
}
private(set) var broadcasts: [Data] = []
private(set) var injected: [BridgeService.InboundBridgeMessage] = []
private(set) var removedInjectedMessageIDs: [String] = []
private(set) var uplinkSends: [(payload: Data, peer: PeerID)] = []
private(set) var openedSubscriptions: [[String]] = []
private(set) var closedSubscriptions = 0
@@ -54,13 +56,20 @@ struct BridgeServiceTests {
let defaults: UserDefaults
let service: BridgeService
init(enabled: Bool = true) {
init(
enabled: Bool = true,
verifyEventSignature: @escaping (NostrEvent) -> Bool = { $0.isValidSignature() }
) {
let suite = "BridgeServiceTests-\(UUID().uuidString)"
defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
identity = try! NostrIdentity.generate()
let clock = clock
service = BridgeService(defaults: defaults) { clock.now }
service = BridgeService(
defaults: defaults,
now: { clock.now },
verifyEventSignature: verifyEventSignature
)
service.publishToRelays = { [weak self] event, cell in
self?.published.append((event, cell))
}
@@ -86,6 +95,14 @@ struct BridgeServiceTests {
service.injectInbound = { [weak self] message in
self?.injected.append(message)
}
service.removeInjectedInbound = { [weak self] messageID in
self?.removedInjectedMessageIDs.append(messageID)
}
service.isInjectedInboundPresent = { [weak self] messageID in
guard let self else { return false }
return self.injectedPresenceOverride?(messageID)
?? self.injected.contains { $0.messageID == messageID }
}
service.isMessageSeenLocally = { [weak self] id in
self?.locallySeenMessageIDs.contains(id) ?? false
}
@@ -117,8 +134,8 @@ struct BridgeServiceTests {
// MARK: Event helpers
private static let remoteMeshSenderID = "feedfacecafef00d"
private static let remoteMeshTimestampMs: UInt64 = 1_750_000_000_000
nonisolated private static let remoteMeshSenderID = "feedfacecafef00d"
nonisolated private static let remoteMeshTimestampMs: UInt64 = 1_750_000_000_000
private func makeRemoteEvent(
cell: String = BridgeServiceTests.cell,
@@ -233,7 +250,7 @@ struct BridgeServiceTests {
"m",
MeshMessageIdentity.stableID(senderIDHex: sender.id, timestampMs: timestampMs, content: "hello hill"),
sender.id,
String(timestampMs),
String(timestampMs)
]))
#expect(published.event.tags.contains(["n", "tester"]))
}
@@ -263,7 +280,7 @@ struct BridgeServiceTests {
let timestampMs = MeshMessageIdentity.millisecondTimestamp(timestamp)
#expect(oldParserKeys == [
MeshMessageIdentity.stableID(senderIDHex: sender.id, timestampMs: timestampMs, content: "first"),
MeshMessageIdentity.stableID(senderIDHex: sender.id, timestampMs: timestampMs, content: "second"),
MeshMessageIdentity.stableID(senderIDHex: sender.id, timestampMs: timestampMs, content: "second")
])
}
@@ -340,6 +357,8 @@ struct BridgeServiceTests {
#expect(fixture.injected.count == 1)
#expect(fixture.injected.first?.content == event.content)
#expect(fixture.injected.first?.messageID == event.id)
#expect(fixture.injected.first?.senderNickname == "remote#\(event.pubkey.suffix(4))")
#expect(fixture.service.bridgedPeerCount == 1)
// Serving duty: after the jitter holdoff, the remote event rides out
// as a fromBridge broadcast one switch, no gateway toggle.
@@ -401,18 +420,118 @@ struct BridgeServiceTests {
fixture.service.handleRendezvousEvent(event)
// The island already heard this over radio: no duplicate render, no
// wasted airtime but the sender still counts as a (local)
// participant, never a bridged one.
// The island already heard this over radio: no duplicate render or
// wasted airtime. The public hint cannot attribute the Nostr signer,
// so it does not mutate participant state either.
#expect(fixture.injected.isEmpty)
#expect(fixture.broadcasts.isEmpty)
#expect(fixture.service.bridgedPeerCount == 0)
}
@Test func radioConfirmedSenderStaysLocalForLaterEvents() throws {
// Sticky local attribution keyed on the derived stable ID: one
// radio-confirmed message marks the pubkey as an islander, so their
// later events never inflate the bridged count.
@Test func bridgeFirstThenAuthenticatedRadioReplacesAliasesAndCancelsDownlink() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
let content = "bridge arrived first"
let radioMessageID = stableID(content: content)
let event = try makeRemoteEvent(content: content)
fixture.service.handleRendezvousEvent(event)
#expect(fixture.injected.map(\.messageID) == [event.id])
#expect(fixture.service.bridgedPeerCount == 1)
// This entry point is called only after the BLE packet signature has
// authenticated. The radio row must win; the public m-tag hint is not
// trusted enough to suppress it.
fixture.service.handleAuthenticatedRadioMessage(messageID: radioMessageID)
fixture.fireScheduledTimers()
#expect(fixture.removedInjectedMessageIDs == [event.id])
#expect(fixture.broadcasts.isEmpty)
#expect(fixture.service.bridgedPeerCount == 1)
// A different signer copying the same public mesh coordinates after
// radio authentication cannot put a bridge alias back into the row.
fixture.service.handleRendezvousEvent(try makeRemoteEvent(content: content))
#expect(fixture.injected.count == 1)
}
@Test func disablingBridgeDoesNotForgetAliasNeededByLaterRadioCopy() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
let content = "radio arrives after opt-out"
let event = try makeRemoteEvent(content: content)
fixture.service.handleRendezvousEvent(event)
fixture.service.setEnabled(false)
fixture.service.handleAuthenticatedRadioMessage(messageID: stableID(content: content))
#expect(fixture.removedInjectedMessageIDs == [event.id])
}
@Test func aliasPruningUsesExactRowLivenessWithoutDeletingHistory() throws {
let fixture = Fixture(enabled: true, verifyEventSignature: { _ in true })
fixture.service.refreshRendezvous()
func event(index: Int) -> NostrEvent {
let senderID = String(format: "%016llx", UInt64(index + 1))
let content = "bridge overflow \(index)"
let timestampMs = UInt64(1_750_000_000_000) + UInt64(index)
var event = NostrEvent(
pubkey: String(format: "%064llx", UInt64(index + 1)),
createdAt: Date(),
kind: .ephemeralEvent,
tags: [
["r", Self.cell],
["n", "remote"],
["m", "unused", senderID, String(timestampMs)]
],
content: content
)
event.id = String(format: "%064llx", UInt64(index + 10_000))
event.sig = String(repeating: "0", count: 128)
return event
}
let oldest = event(index: 0)
fixture.service.handleRendezvousEvent(oldest)
for index in 1...BridgeService.Limits.maxTrackedEventIDs {
if index.isMultiple(of: 500) { fixture.advance(61) }
fixture.service.handleRendezvousEvent(event(index: index))
}
// The loop/dedup caches are intentionally smaller, but valid ingress
// through that boundary must not delete a still-visible bridge row.
#expect(fixture.removedInjectedMessageIDs.isEmpty)
for index in (BridgeService.Limits.maxTrackedEventIDs + 1)..<BridgeService.Limits.maxTrackedRadioAliases {
if index.isMultiple(of: 500) { fixture.advance(61) }
fixture.service.handleRendezvousEvent(event(index: index))
}
// Timestamp-order trimming need not match arrival order. Model the
// store having evicted a middle-arrival row, then exceed alias capacity.
let noLongerVisibleID = event(index: 700).id
fixture.injectedPresenceOverride = { $0 != noLongerVisibleID }
fixture.service.handleRendezvousEvent(event(index: BridgeService.Limits.maxTrackedRadioAliases))
// Pruning discarded only proof for a row already absent. It never
// invokes active row deletion, and the still-visible oldest row keeps
// its radio-replacement proof despite out-of-order timestamps.
#expect(fixture.removedInjectedMessageIDs.isEmpty)
fixture.service.handleAuthenticatedRadioMessage(
messageID: stableID(
content: oldest.content,
meshSenderID: "0000000000000001",
meshTimestampMs: 1_750_000_000_000
)
)
#expect(fixture.removedInjectedMessageIDs == [oldest.id])
}
@Test func copiedRadioHintCannotMakeSignerStickyLocal() throws {
// A remote signer can copy public radio coordinates and content. That
// suppresses only the duplicate row; it cannot mark the signing key as
// local and hide the signer's later bridge traffic from the people UI.
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
let identity = try NostrIdentity.generate()
@@ -427,33 +546,34 @@ struct BridgeServiceTests {
meshTimestampMs: Self.remoteMeshTimestampMs + 1,
identity: identity
))
#expect(fixture.service.bridgedPeerCount == 0)
#expect(fixture.service.bridgedPeerCount == 1)
}
@Test func spoofedOriginCoordinatesCannotSuppressTheGenuineMessage() throws {
// Attack: sign an event claiming a victim's origin coordinates in the
// `m` tag to pre-poison the dedup key. The key is recomputed from the
// event's own content, so the spoof lands under a different ID and the
// genuine message still renders.
// Attack: copy the victim's exact content + mesh sender + timestamp,
// then sign under another Nostr key and arrive first. Public mesh
// coordinates are only a radio-copy hint; signed event IDs own bridge
// dedup, so the attacker cannot reserve the genuine event's slot.
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
let spoof = try makeRemoteEvent(content: "impostor payload")
let genuine = try makeRemoteEvent(content: "the real message")
let spoof = try makeRemoteEvent(content: "the exact victim message")
let genuine = try makeRemoteEvent(content: "the exact victim message")
fixture.service.handleRendezvousEvent(spoof)
fixture.service.handleRendezvousEvent(genuine)
#expect(fixture.injected.count == 2)
#expect(fixture.injected.map(\.messageID) == [
stableID(content: "impostor payload"),
stableID(content: "the real message"),
#expect(fixture.injected.map(\.messageID) == [spoof.id, genuine.id])
#expect(fixture.injected.map(\.senderNickname) == [
"remote#\(spoof.pubkey.suffix(4))",
"remote#\(genuine.pubkey.suffix(4))"
])
}
@Test func forgedStableIDElementIsNeverTrusted() throws {
// Element 1 of the `m` tag exists only for old parsers. An event
// claiming the genuine message's stable ID there over different
// content must still key on its own derived ID, so it cannot
// content must still key on its signed event ID, so it cannot
// pre-poison the genuine message's dedup slot.
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
@@ -465,7 +585,7 @@ struct BridgeServiceTests {
kind: .ephemeralEvent,
tags: [
["r", Self.cell],
["m", genuineID, Self.remoteMeshSenderID, String(Self.remoteMeshTimestampMs)],
["m", genuineID, Self.remoteMeshSenderID, String(Self.remoteMeshTimestampMs)]
],
content: "impostor payload"
).sign(with: identity.schnorrSigningKey())
@@ -474,15 +594,12 @@ struct BridgeServiceTests {
fixture.service.handleRendezvousEvent(forged)
fixture.service.handleRendezvousEvent(genuine)
#expect(fixture.injected.map(\.messageID) == [
stableID(content: "impostor payload"),
genuineID,
])
#expect(fixture.injected.map(\.messageID) == [forged.id, genuine.id])
}
@Test func oldFormatMeshTagFallsBackToEventID() throws {
// A 2-element `m` tag from an old sender carries an ID no other
// device can recompute; the event ID keeps today's behavior.
@Test func oldFormatMeshTagAlsoUsesAuthenticatedEventID() throws {
// A 2-element `m` tag from an old sender is just as unauthenticated as
// the current coordinates; the signed event ID remains authoritative.
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
let identity = try NostrIdentity.generate()
@@ -522,6 +639,53 @@ struct BridgeServiceTests {
#expect(fixture.service.bridgedPeerCount == 1)
}
@Test func participantStateIsCappedUnderRotatingKeyFlood() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
for _ in 0..<(BridgeService.Limits.maxParticipants + 20) {
fixture.service.handleRendezvousEvent(try makePresenceEvent())
}
#expect(fixture.service.bridgedPeerCount == BridgeService.Limits.maxParticipants)
#expect(fixture.service.bridgedParticipants.count == BridgeService.Limits.maxParticipants)
}
@Test func relayIngressRateLimitsOneSigningKey() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
let identity = try NostrIdentity.generate()
for index in 0..<(BridgeService.Limits.inboundEventsPerMinutePerSigner + 10) {
fixture.service.handleRendezvousEvent(try makeRemoteEvent(
content: "rate \(index)",
meshTimestampMs: Self.remoteMeshTimestampMs + UInt64(index),
identity: identity
))
}
#expect(fixture.injected.count == BridgeService.Limits.inboundEventsPerMinutePerSigner)
}
@Test func invalidIngressCannotTriggerUnboundedSignatureVerification() throws {
var verificationCount = 0
let fixture = Fixture(enabled: true) { _ in
verificationCount += 1
return false
}
fixture.service.refreshRendezvous()
let event = try makeRemoteEvent()
for _ in 0..<(BridgeService.Limits.signatureVerificationAttemptsPerMinute + 20) {
fixture.service.handleRendezvousEvent(event)
}
#expect(verificationCount == BridgeService.Limits.signatureVerificationAttemptsPerMinute)
fixture.advance(61)
fixture.service.handleRendezvousEvent(event)
#expect(verificationCount == BridgeService.Limits.signatureVerificationAttemptsPerMinute + 1)
}
@Test func staleParticipantsAgeOutOfTheCount() throws {
let fixture = Fixture(enabled: true)
fixture.service.refreshRendezvous()
@@ -14,7 +14,7 @@ import Testing
@Suite("Gateway mode policy")
@MainActor
struct GatewayServiceTests {
private static let geohash = "u4pruy"
nonisolated private static let geohash = "u4pruy"
/// Closure-injected harness around `GatewayService` recording every
/// side effect, with a controllable clock and relay connectivity.
@@ -86,6 +86,46 @@ final class LocationStateManagerTests: XCTestCase {
XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterMeters)
}
func test_permissionRevocation_endsLiveRefreshWithoutDiscardingExplicitState() async {
let storage = makeStorage()
let locationManager = MockLocationManager(authorizationStatus: .authorizedAlways)
let manager = LocationStateManager(
storage: storage,
locationManager: locationManager,
geocoder: MockLocationGeocoder(),
shouldInitializeCoreLocation: true
)
let authorized = await waitUntil { manager.permissionState == .authorized }
XCTAssertTrue(authorized)
manager.locationManager(
CLLocationManager(),
didUpdateLocations: [CLLocation(latitude: 21.2850, longitude: -157.8357)]
)
let channelsLoaded = await waitUntil { !manager.availableChannels.isEmpty }
XCTAssertTrue(channelsLoaded)
let cachedChannels = manager.availableChannels
manager.addBookmark("u4pru")
manager.markTeleported(for: "9q8yy", true)
manager.select(.location(GeohashChannel(level: .city, geohash: "9q8yy")))
let teleported = await waitUntil { manager.teleported }
XCTAssertTrue(teleported)
manager.beginLiveRefresh()
XCTAssertEqual(locationManager.startUpdatingLocationCallCount, 1)
manager.locationManager(CLLocationManager(), didChangeAuthorization: .restricted)
let restricted = await waitUntil { manager.permissionState == .restricted }
XCTAssertTrue(restricted)
XCTAssertEqual(locationManager.stopUpdatingLocationCallCount, 1)
XCTAssertEqual(locationManager.desiredAccuracy, kCLLocationAccuracyHundredMeters)
XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterMeters)
XCTAssertEqual(manager.availableChannels, cachedChannels, "cached display state can remain")
XCTAssertEqual(manager.bookmarks, ["u4pru"])
XCTAssertTrue(manager.teleported, "an explicit remote selection survives device revocation")
}
func test_didUpdateLocations_computesChannelsAndReverseGeocodesFriendlyNames() async {
let geocoder = MockLocationGeocoder()
geocoder.enqueue(
@@ -68,6 +68,145 @@ struct MessageOutboxStoreTests {
#expect(other.load().isEmpty)
}
@Test func permanentlyMissingDeviceKeyDiscardsOrphanAndNewSavesSurviveRelaunch() {
let fileURL = makeTempURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "0000000000000001")
MessageOutboxStore(keychain: keychain, fileURL: fileURL)
.save([peerID: [makeMessage("orphaned")]])
#expect(FileManager.default.fileExists(atPath: fileURL.path))
// Models a device restore: Application Support brought the sealed
// file across, but its AfterFirstUnlockThisDeviceOnly key cannot.
keychain.deleteAll(service: "chat.bitchat.outbox")
let restored = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
#expect(restored.load().isEmpty)
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
restored.save([peerID: [makeMessage("fresh")]])
let relaunched = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load()
#expect(relaunched[peerID]?.map(\.messageID) == ["fresh"])
}
@Test func temporarilyLockedKeyDoesNotDiscardDurableSnapshot() {
let fileURL = makeTempURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "0000000000000001")
MessageOutboxStore(keychain: keychain, fileURL: fileURL)
.save([peerID: [makeMessage("durable")]])
let durableBytes = try? Data(contentsOf: fileURL)
keychain.simulatedGenericReadError = .deviceLocked
let restored = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
#expect(restored.load().isEmpty)
#expect((try? Data(contentsOf: fileURL)) == durableBytes)
keychain.simulatedGenericReadError = nil
let recovered = restored.retryDeferredLoad()
#expect(recovered?[peerID]?.map(\.messageID) == ["durable"])
}
@Test @MainActor
func panicWipeInvalidatesQueuedRecoveryCallback() async {
let fileURL = makeTempURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "0000000000000001")
MessageOutboxStore(keychain: keychain, fileURL: fileURL)
.save([peerID: [makeMessage("durable")]])
var protectedDataUnavailable = true
let restored = MessageOutboxStore(
keychain: keychain,
fileURL: fileURL,
readData: { url in
if protectedDataUnavailable {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
}
return try Data(contentsOf: url)
}
)
#expect(restored.load().isEmpty)
var deliveredRecoveries: [MessageOutboxStore.Snapshot] = []
restored.setRecoveryHandler { deliveredRecoveries.append($0) }
protectedDataUnavailable = false
restored.retryDeferredLoad() // queues the handler onto MainActor
restored.wipe() // invalidates it before the queued Task runs
await Task.yield()
#expect(deliveredRecoveries.isEmpty)
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
}
@Test @MainActor
func wipeBetweenRecoveryUnlockAndNotificationDropsRecoveredSnapshot() async {
let fileURL = makeTempURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "0000000000000001")
MessageOutboxStore(keychain: keychain, fileURL: fileURL)
.save([peerID: [makeMessage("durable")]])
var protectedDataUnavailable = true
var gapAction: (() -> Void)?
let restored = MessageOutboxStore(
keychain: keychain,
fileURL: fileURL,
readData: { url in
if protectedDataUnavailable {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
}
return try Data(contentsOf: url)
},
beforeRecoveryNotification: { gapAction?() }
)
#expect(restored.load().isEmpty)
var deliveredRecoveries: [MessageOutboxStore.Snapshot] = []
restored.setRecoveryHandler { deliveredRecoveries.append($0) }
gapAction = { restored.wipe() }
protectedDataUnavailable = false
restored.retryDeferredLoad()
await Task.yield()
#expect(deliveredRecoveries.isEmpty)
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
}
@Test func deferredRemovalTombstoneFiltersUnseenDurableMessage() {
let fileURL = makeTempURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "0000000000000001")
MessageOutboxStore(keychain: keychain, fileURL: fileURL)
.save([peerID: [makeMessage("durable")]])
var protectedDataUnavailable = true
let restored = MessageOutboxStore(
keychain: keychain,
fileURL: fileURL,
readData: { url in
if protectedDataUnavailable {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
}
return try Data(contentsOf: url)
}
)
#expect(restored.load().isEmpty)
restored.recordRemoval(messageID: "durable")
restored.save([:])
protectedDataUnavailable = false
let recovered = restored.retryDeferredLoad()
#expect(recovered?.isEmpty == true)
#expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty)
}
@Test func wipeRemovesFileAndKey() {
let fileURL = makeTempURL()
let keychain = MockKeychain()
@@ -89,4 +228,69 @@ struct MessageOutboxStoreTests {
store.save([peerID: []])
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
}
@Test func protectedDataReadFailureDefersWriteAndMergesOnRecovery() {
let fileURL = makeTempURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "0000000000000001")
let seed = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
seed.save([peerID: [makeMessage("durable")]])
let durableBytes = try? Data(contentsOf: fileURL)
var protectedDataUnavailable = true
let restored = MessageOutboxStore(
keychain: keychain,
fileURL: fileURL,
readData: { url in
if protectedDataUnavailable {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
}
return try Data(contentsOf: url)
}
)
#expect(restored.load().isEmpty)
restored.save([peerID: [makeMessage("during-wake")]])
// The unreadable durable snapshot was not replaced by the partial
// in-memory outbox from the locked restoration.
#expect((try? Data(contentsOf: fileURL)) == durableBytes)
protectedDataUnavailable = false
let recovered = restored.retryDeferredLoad()
#expect(Set(recovered?[peerID]?.map(\.messageID) ?? []) == ["durable", "during-wake"])
let relaunched = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load()
#expect(Set(relaunched[peerID]?.map(\.messageID) ?? []) == ["durable", "during-wake"])
}
@Test func lockedWakeRemovalDoesNotResurrectPendingMessageOnRecovery() {
let fileURL = makeTempURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "0000000000000001")
MessageOutboxStore(keychain: keychain, fileURL: fileURL)
.save([peerID: [makeMessage("durable")]])
var protectedDataUnavailable = true
let restored = MessageOutboxStore(
keychain: keychain,
fileURL: fileURL,
readData: { url in
if protectedDataUnavailable {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
}
return try Data(contentsOf: url)
}
)
#expect(restored.load().isEmpty)
restored.save([peerID: [makeMessage("wake")]])
// A later delivery ack produces the complete empty in-memory view.
restored.save([:])
protectedDataUnavailable = false
let recovered = restored.retryDeferredLoad()
#expect(recovered?[peerID]?.map(\.messageID) == ["durable"])
}
}
@@ -493,6 +493,40 @@ struct MessageRouterTests {
#expect(transport.sentCourierMessages.isEmpty)
}
@Test @MainActor
func bridgeDepositCarriesOnlyAfterConfirmedSendAndRetriesFailure() async {
let recipient = PeerID(str: "00000000000000ba")
let recipientKey = Data(repeating: 0xBA, count: 32)
let transport = MockTransport()
let router = MessageRouter(
transports: [transport],
courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey)
)
var completions: [@MainActor (Bool) -> Void] = []
router.bridgeCourierDeposit = { _, _, _, completion in
completions.append(completion)
}
var carried: [String] = []
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "bridge-ack")
#expect(completions.count == 1)
#expect(carried.isEmpty)
// Sweeps while the WebSocket write is pending must not duplicate it.
router.retryBridgeCourierDeposits()
#expect(completions.count == 1)
completions.removeFirst()(false)
#expect(carried.isEmpty)
// A failed socket write releases the in-flight slot for a real retry.
router.retryBridgeCourierDeposits()
#expect(completions.count == 1)
completions.removeFirst()(true)
#expect(carried == ["bridge-ack"])
}
// MARK: - Outbox persistence
@Test @MainActor
@@ -547,6 +581,295 @@ struct MessageRouterTests {
router2.flushOutbox(for: peerID)
#expect(transport2.sentPrivateMessages.isEmpty)
}
@Test @MainActor
func protectedDataRecoveryMergesDurableAndLockedWakeMessagesIntoRouter() async {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("router-protected-data-\(UUID().uuidString).sealed")
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "00000000000000df")
let durable = MessageOutboxStore.QueuedMessage(
content: "Before reboot",
nickname: "Peer",
messageID: "durable",
timestamp: Date()
)
MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]])
var protectedDataUnavailable = true
let restoredStore = MessageOutboxStore(
keychain: keychain,
fileURL: fileURL,
readData: { url in
if protectedDataUnavailable {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
}
return try Data(contentsOf: url)
}
)
let transport = MockTransport()
let router = MessageRouter(transports: [transport], outboxStore: restoredStore)
router.sendPrivate("During wake", to: peerID, recipientNickname: "Peer", messageID: "wake")
transport.reachablePeers.insert(peerID)
protectedDataUnavailable = false
restoredStore.retryDeferredLoad()
// Recovery is delivered to the main actor without blocking the
// protected-data notification callback.
await Task.yield()
await Task.yield()
#expect(Set(transport.sentPrivateMessages.map(\.messageID)) == ["durable", "wake"])
}
@Test @MainActor
func ackWhileColdLoadIsLockedDoesNotResurrectUnseenDurableMessage() async {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("router-locked-ack-\(UUID().uuidString).sealed")
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "00000000000000e0")
let durable = MessageOutboxStore.QueuedMessage(
content: "Already delivered",
nickname: "Peer",
messageID: "acked-while-locked",
timestamp: Date()
)
MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]])
var protectedDataUnavailable = true
let restoredStore = MessageOutboxStore(
keychain: keychain,
fileURL: fileURL,
readData: { url in
if protectedDataUnavailable {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
}
return try Data(contentsOf: url)
}
)
let transport = MockTransport()
transport.reachablePeers.insert(peerID)
let router = MessageRouter(transports: [transport], outboxStore: restoredStore)
// The router's locked cold-load view is empty, but the ack still has
// to suppress the durable message hidden on disk.
router.markDelivered("acked-while-locked")
protectedDataUnavailable = false
restoredStore.retryDeferredLoad()
await Task.yield()
await Task.yield()
#expect(transport.sentPrivateMessages.isEmpty)
#expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty)
}
@Test @MainActor
func ackAfterRecoveryCaptureBeforeMainActorMergeCannotResurrectMessage() async {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("router-recovery-gap-ack-\(UUID().uuidString).sealed")
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "00000000000000e1")
let durable = MessageOutboxStore.QueuedMessage(
content: "Captured then acked",
nickname: "Peer",
messageID: "recovery-gap-ack",
timestamp: Date()
)
MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]])
var protectedDataUnavailable = true
let restoredStore = MessageOutboxStore(
keychain: keychain,
fileURL: fileURL,
readData: { url in
if protectedDataUnavailable {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
}
return try Data(contentsOf: url)
}
)
let transport = MockTransport()
transport.reachablePeers.insert(peerID)
let router = MessageRouter(transports: [transport], outboxStore: restoredStore)
protectedDataUnavailable = false
restoredStore.retryDeferredLoad() // captures recovery and queues MainActor merge
router.markDelivered("recovery-gap-ack") // runs before that queued Task
await Task.yield()
await Task.yield()
#expect(transport.sentPrivateMessages.isEmpty)
#expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty)
}
@Test @MainActor
func enqueueAfterRecoveryCapturePreservesUnseenDurableAndNewMessages() async {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("router-recovery-gap-enqueue-\(UUID().uuidString).sealed")
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "00000000000000e2")
let durable = MessageOutboxStore.QueuedMessage(
content: "Before unlock",
nickname: "Peer",
messageID: "recovery-gap-durable",
timestamp: Date()
)
MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]])
var protectedDataUnavailable = true
let restoredStore = MessageOutboxStore(
keychain: keychain,
fileURL: fileURL,
readData: { url in
if protectedDataUnavailable {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
}
return try Data(contentsOf: url)
}
)
let transport = MockTransport()
let router = MessageRouter(transports: [transport], outboxStore: restoredStore)
protectedDataUnavailable = false
restoredStore.retryDeferredLoad() // queues merge of the durable message
router.sendPrivate("During gap", to: peerID, recipientNickname: "Peer", messageID: "recovery-gap-new")
transport.reachablePeers.insert(peerID)
await Task.yield()
await Task.yield()
#expect(Set(transport.sentPrivateMessages.map(\.messageID)) == ["recovery-gap-durable", "recovery-gap-new"])
let relaunched = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load()
#expect(Set(relaunched[peerID]?.map(\.messageID) ?? []) == ["recovery-gap-durable", "recovery-gap-new"])
}
@Test @MainActor
func removingKnownWakeMessageInRecoveryGapPreservesOnlyUnseenDurableState() async {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("router-recovery-gap-known-removal-\(UUID().uuidString).sealed")
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "00000000000000e3")
let durable = MessageOutboxStore.QueuedMessage(
content: "Unseen durable",
nickname: "Peer",
messageID: "recovery-gap-unseen",
timestamp: Date()
)
MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]])
var protectedDataUnavailable = true
let restoredStore = MessageOutboxStore(
keychain: keychain,
fileURL: fileURL,
readData: { url in
if protectedDataUnavailable {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
}
return try Data(contentsOf: url)
}
)
let transport = MockTransport()
let router = MessageRouter(transports: [transport], outboxStore: restoredStore)
router.sendPrivate("Known wake", to: peerID, recipientNickname: "Peer", messageID: "recovery-gap-known")
protectedDataUnavailable = false
restoredStore.retryDeferredLoad() // captures unseen durable + known wake
// Secure direct flush removes the wake message before recovery's
// MainActor merge. It must remain removed, while the unseen durable
// message still arrives through the pending recovery claim.
transport.connectedPeers.insert(peerID)
transport.securePeers = [peerID]
router.flushOutbox(for: peerID)
await Task.yield()
await Task.yield()
let sentIDs = transport.sentPrivateMessages.map(\.messageID)
#expect(sentIDs.filter { $0 == "recovery-gap-known" }.count == 1)
#expect(sentIDs.filter { $0 == "recovery-gap-unseen" }.count == 1)
}
@Test @MainActor
func failedRecoveryWriteStillPreservesUnseenDurableStateAcrossGapRemoval() async {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("router-recovery-write-failure-\(UUID().uuidString).sealed")
defer { try? FileManager.default.removeItem(at: fileURL) }
let keychain = MockKeychain()
let peerID = PeerID(str: "00000000000000e4")
let durable = MessageOutboxStore.QueuedMessage(
content: "Unseen before unlock",
nickname: "Peer",
messageID: "recovery-write-failure-unseen",
timestamp: Date()
)
MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([peerID: [durable]])
var protectedDataUnavailable = true
var failWrites = false
var injectedFailureCount = 0
let restoredStore = MessageOutboxStore(
keychain: keychain,
fileURL: fileURL,
readData: { url in
if protectedDataUnavailable {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
}
return try Data(contentsOf: url)
},
writeData: { data, url, options in
if failWrites {
injectedFailureCount += 1
throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteNoPermissionError)
}
try data.write(to: url, options: options)
}
)
let transport = MockTransport()
let router = MessageRouter(transports: [transport], outboxStore: restoredStore)
router.sendPrivate(
"Known wake",
to: peerID,
recipientNickname: "Peer",
messageID: "recovery-write-failure-known"
)
// The first post-unlock save reads durable D and knows router state W,
// but its D+W write fails. A later retry must retain the original
// unseen-D classification instead of mistaking the cached union for
// a fully router-known snapshot.
protectedDataUnavailable = false
failWrites = true
router.sendPrivate(
"Known wake",
to: peerID,
recipientNickname: "Peer",
messageID: "recovery-write-failure-known"
)
failWrites = false
#expect(injectedFailureCount == 1)
restoredStore.retryDeferredLoad() // persists D+W and queues recovery
transport.connectedPeers.insert(peerID)
transport.securePeers = [peerID]
router.flushOutbox(for: peerID) // removes W before queued callback
// The gap save may remove W, but it must leave unseen D durable until
// MessageRouter receives the pending recovery callback.
let gapSnapshot = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load()
#expect(gapSnapshot[peerID]?.map(\.messageID) == ["recovery-write-failure-unseen"])
await Task.yield()
await Task.yield()
let sentIDs = transport.sentPrivateMessages.map(\.messageID)
#expect(sentIDs.filter { $0 == "recovery-write-failure-known" }.count == 1)
#expect(sentIDs.filter { $0 == "recovery-write-failure-unseen" }.count == 1)
}
}
/// Mutable wall clock injected into `MessageRouter` so TTL expiry is testable
@@ -345,6 +345,121 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent, 0)
}
func test_sendEventImmediately_allRejectsFailThenOKRetrySucceeds() async throws {
let relays = [
"wss://confirmed-reject-one.example",
"wss://confirmed-reject-two.example"
]
let context = makeContext(permission: .denied)
context.manager.ensureConnections(to: relays)
let connected = await waitUntil {
relays.allSatisfy { relay in
context.manager.relays.first(where: { $0.url == relay })?.isConnected == true
}
}
XCTAssertTrue(connected)
let event = try makeSignedEvent(content: "confirmed reject then retry")
var results: [Bool] = []
var completionsWereOnMain: [Bool] = []
context.manager.sendEventImmediately(event, to: relays) {
results.append($0)
completionsWereOnMain.append(Thread.isMainThread)
}
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertTrue(results.isEmpty, "socket writes alone must not confirm durability")
for relay in relays {
try context.sessionFactory.latestConnection(for: relay)?.emitOK(
eventID: event.id,
success: false,
reason: "rejected"
)
}
let failedCompleted = await waitUntil { results.count == 1 }
XCTAssertTrue(failedCompleted)
XCTAssertEqual(results, [false])
XCTAssertEqual(completionsWereOnMain, [true])
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
// The explicit rejection leaves the same event retryable.
try? await Task.sleep(nanoseconds: 20_000_000)
context.manager.sendEventImmediately(event, to: relays) {
results.append($0)
completionsWereOnMain.append(Thread.isMainThread)
}
try context.sessionFactory.latestConnection(for: relays[0])?.emitOK(
eventID: event.id,
success: true,
reason: "accepted"
)
let successfulCompleted = await waitUntil { results.count == 2 }
XCTAssertTrue(successfulCompleted)
XCTAssertEqual(results, [false, true])
XCTAssertEqual(completionsWereOnMain, [true, true])
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
}
func test_sendEventImmediately_mixedRelayOKUsesAcceptedResultOnce() async throws {
let relays = ["wss://confirmed-mixed-one.example", "wss://confirmed-mixed-two.example"]
let context = makeContext(permission: .denied)
context.manager.ensureConnections(to: relays)
let connected = await waitUntil {
relays.allSatisfy { relay in
context.manager.relays.first(where: { $0.url == relay })?.isConnected == true
}
}
XCTAssertTrue(connected)
let event = try makeSignedEvent(content: "mixed confirmation")
var results: [Bool] = []
context.manager.sendEventImmediately(event, to: relays) { results.append($0) }
try context.sessionFactory.latestConnection(for: relays[0])?.emitOK(
eventID: event.id,
success: false,
reason: "policy"
)
try context.sessionFactory.latestConnection(for: relays[1])?.emitOK(
eventID: event.id,
success: true,
reason: "accepted"
)
let completed = await waitUntil { results.count == 1 }
XCTAssertTrue(completed)
XCTAssertEqual(results, [true])
}
func test_sendEventImmediately_timeoutFailsAndIgnoresLateWriteAndOK() async throws {
let relay = "wss://confirmed-timeout.example"
let context = makeContext(permission: .denied)
context.manager.ensureConnections(to: [relay])
let connected = await waitUntil {
context.manager.relays.first(where: { $0.url == relay })?.isConnected == true
}
XCTAssertTrue(connected)
let connection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relay))
connection.deferSendCompletions = true
let event = try makeSignedEvent(content: "confirmation timeout")
var results: [Bool] = []
context.manager.sendEventImmediately(event, to: [relay]) { results.append($0) }
XCTAssertTrue(results.isEmpty)
XCTAssertEqual(
context.scheduler.scheduled.first?.delay,
TransportConfig.nostrConfirmedSendAckTimeoutSeconds
)
context.scheduler.runNext()
let timedOut = await waitUntil { results.count == 1 }
XCTAssertTrue(timedOut)
XCTAssertEqual(results, [false])
connection.flushDeferredSendCompletions()
try connection.emitOK(eventID: event.id, success: true, reason: "late")
try? await Task.sleep(nanoseconds: 30_000_000)
XCTAssertEqual(results, [false])
}
func test_sendEvent_queueIsPrunedWhenDefaultRelaysAreRevoked() async throws {
let context = makeContext(
permission: .authorized,
@@ -1158,6 +1273,38 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertEqual(firstConnection.cancelCallCount, 1)
}
func test_staleSocketFailureCannotFailConfirmedSendOnReplacementConnection() async throws {
let relayURL = "wss://retry-stale-confirmation.example"
let context = makeContext(permission: .denied)
context.manager.ensureConnections(to: [relayURL])
let initiallyConnected = await waitUntil {
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(initiallyConnected)
let oldConnection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
context.manager.retryConnection(to: relayURL)
let replaced = await waitUntil {
guard let current = context.sessionFactory.latestConnection(for: relayURL) else { return false }
return current !== oldConnection &&
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(replaced)
let currentConnection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
let event = try makeSignedEvent(content: "replacement confirmation")
var results: [Bool] = []
context.manager.sendEventImmediately(event, to: [relayURL]) { results.append($0) }
oldConnection.fail(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut))
try? await Task.sleep(nanoseconds: 30_000_000)
XCTAssertTrue(results.isEmpty)
XCTAssertTrue(context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true)
try currentConnection.emitOK(eventID: event.id, success: true, reason: "accepted")
let confirmed = await waitUntil { results == [true] }
XCTAssertTrue(confirmed)
}
func test_retryConnection_whenTorReadinessFailsDoesNotReconnect() async {
let relayURL = "wss://retry-tor.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: true)
@@ -1643,7 +1790,7 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
private final class MockRelayConnection: NostrRelayConnectionProtocol {
private let pingError: Error?
private let sendError: Error?
var sendError: Error?
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
private(set) var resumeCallCount = 0
private(set) var cancelCallCount = 0
@@ -1687,7 +1834,9 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
func flushDeferredSendCompletions() {
let pending = deferredSendCompletions
deferredSendCompletions = []
pending.forEach { $0(sendError) }
pending.forEach {
$0(sendError)
}
}
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
+1 -1
View File
@@ -646,7 +646,7 @@ struct ViewSmokeTests {
playback.stop()
VoiceNotePlaybackCoordinator.shared.activate(playback)
VoiceNotePlaybackCoordinator.shared.deactivate(playback)
await VoiceRecorder.shared.cancelRecording()
await VoiceRecorder.shared.cancelRecording(owner: VoiceRecorder.RecordingOwner())
#expect(bins.count == 16)
#expect(WaveformCache.shared.cachedWaveform(for: waveformProbeURL)?.count == 16)
+207
View File
@@ -0,0 +1,207 @@
//
// VoiceCaptureSessionTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Testing
@testable import bitchat
@MainActor
private final class StubPTTCapture: PTTCapturing {
var onFrames: (([Data]) -> Void)?
var stopResult: (url: URL?, encodedFrames: Int)
var startError: Error?
private(set) var startCount = 0
private(set) var cancelCount = 0
init(
stopResult: (url: URL?, encodedFrames: Int),
startError: Error? = nil
) {
self.stopResult = stopResult
self.startError = startError
}
func start(outputURL: URL) async throws {
startCount += 1
if let startError {
throw startError
}
}
func stop() -> (url: URL?, encodedFrames: Int) {
stopResult
}
func cancel() {
cancelCount += 1
}
}
private final class CaptureLeaseSession: SessionApplying, @unchecked Sendable {
private let lock = NSLock()
private var _activationCalls: [Bool] = []
var activationCalls: [Bool] { lock.withLock { _activationCalls } }
func setCategory(_ category: AudioSessionCoordinator.Category) throws {}
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
lock.withLock { _activationCalls.append(active) }
}
}
@MainActor
private final class GatedVoiceCaptureSession: VoiceCaptureSession {
let isLive = false
private let startError: Error?
private(set) var finishStarted = false
private(set) var cancelCount = 0
private var finishContinuation: CheckedContinuation<URL?, Never>?
init(startError: Error? = nil) {
self.startError = startError
}
func requestPermission() async -> Bool { true }
func start() async throws {
if let startError { throw startError }
}
func finish() async -> URL? {
finishStarted = true
return await withCheckedContinuation { continuation in
finishContinuation = continuation
}
}
func cancel() async {
cancelCount += 1
}
func resolveFinish(with url: URL?) {
let continuation = finishContinuation
finishContinuation = nil
continuation?.resume(returning: url)
}
}
@MainActor
struct VoiceCaptureSessionTests {
private func waitUntil(
_ condition: () -> Bool,
sourceLocation: SourceLocation = #_sourceLocation
) async {
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while !condition(), ContinuousClock.now < deadline {
await Task.yield()
try? await Task.sleep(nanoseconds: 1_000_000)
}
#expect(condition(), sourceLocation: sourceLocation)
}
private func isRecording(_ state: VoiceRecordingViewModel.State) -> Bool {
if case .recording = state { return true }
return false
}
@Test func staleCaptureCallbackCannotInvalidateNewGeneration() {
let generations = PTTCaptureGeneration()
let old = generations.begin()
generations.invalidate()
let current = generations.begin()
#expect(!generations.invalidate(ifCurrent: old))
#expect(generations.isCurrent(current))
#expect(generations.invalidate(ifCurrent: current))
#expect(!generations.isCurrent(current))
}
@Test func coordinatorCancellationIsNotReportedAsAStartedCapture() async {
let capture = StubPTTCapture(
stopResult: (nil, 0),
startError: CancellationError()
)
let session = PTTLiveVoiceSession(
sendPacket: { _ in },
capture: capture
)
await #expect(throws: CancellationError.self) {
try await session.start()
}
}
@Test func interruptedShortCaptureIsCanceledEvenAfterLongHold() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("ptt-interrupted-test-\(UUID().uuidString).m4a")
_ = FileManager.default.createFile(atPath: url.path, contents: Data([0x00]))
defer { try? FileManager.default.removeItem(at: url) }
let capture = StubPTTCapture(stopResult: (url, 1))
var sentPackets: [Data] = []
var now = Date()
let session = PTTLiveVoiceSession(
sendPacket: { sentPackets.append($0) },
capture: capture,
now: { now },
burstID: Data(repeating: 0xA5, count: 8)
)
try await session.start()
now = now.addingTimeInterval(2)
let result = await session.finish()
#expect(result == nil)
#expect(!FileManager.default.fileExists(atPath: url.path))
let packet = try #require(sentPackets.last.flatMap(VoiceBurstPacket.decode))
guard case .canceled = packet.kind else {
Issue.record("Expected a canceled control packet for a subsecond interrupted capture")
return
}
}
@Test func droppingCaptureLeaseReturnsItsCoordinatorToken() async throws {
let rawSession = CaptureLeaseSession()
let coordinator = AudioSessionCoordinator(session: rawSession)
let token = try await coordinator.acquire(.capture) {}
var lease: PTTCaptureSessionLease? = PTTCaptureSessionLease(coordinator: coordinator)
lease?.install(token)
lease = nil
await coordinator.drain()
#expect(rawSession.activationCalls == [true, false])
}
@Test func rejectedNewHoldAndStaleFinalizeLeaveNewerGenerationIdle() async {
let oldSession = GatedVoiceCaptureSession()
let newSession = GatedVoiceCaptureSession(
startError: VoiceRecorder.RecorderError.recordingInProgress
)
var sessions: [GatedVoiceCaptureSession] = [oldSession, newSession]
let viewModel = VoiceRecordingViewModel()
viewModel.sessionProvider = { sessions.removeFirst() }
viewModel.start(shouldShow: true)
await waitUntil { self.isRecording(viewModel.state) }
viewModel.finish(completion: { _ in })
await waitUntil { oldSession.finishStarted }
viewModel.start(shouldShow: true)
await waitUntil { newSession.cancelCount == 1 && viewModel.state == .idle }
#expect(viewModel.state == .idle)
// The older finalize now fails after the newer press has completed.
// It must not replace the newer generation's idle state with an alert.
oldSession.resolveFinish(with: nil)
for _ in 0..<20 {
await Task.yield()
}
#expect(viewModel.state == .idle)
}
}
@@ -0,0 +1,145 @@
//
// VoiceNotePlaybackControllerTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import AVFoundation
import Foundation
@testable import bitchat
/// Thread-safe: the coordinator invokes it on its private serial queue (the
/// blocking session IPC runs off the main thread) while the test reads from
/// the main actor.
private final class RecordingAudioSession: SessionApplying, @unchecked Sendable {
private let lock = NSLock()
private var _activationCalls: [Bool] = []
private var _categoryCallCount = 0
private var _categoryError: Error?
var activationCalls: [Bool] { lock.withLock { _activationCalls } }
var categoryCallCount: Int { lock.withLock { _categoryCallCount } }
var categoryError: Error? {
get { lock.withLock { _categoryError } }
set { lock.withLock { _categoryError = newValue } }
}
func setCategory(_ category: AudioSessionCoordinator.Category) throws {
try lock.withLock {
_categoryCallCount += 1
if let error = _categoryError {
_categoryError = nil
throw error
}
}
}
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
lock.withLock { _activationCalls.append(active) }
}
}
private struct PlaybackSessionError: Error {}
@MainActor
struct VoiceNotePlaybackControllerTests {
/// A short silent PCM file `AVAudioPlayer` can open on the test host.
private func makeTempVoiceNote(seconds: Double = 0.2) throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("voice-note-test-\(UUID().uuidString).caf")
let format = try #require(AVAudioFormat(standardFormatWithSampleRate: 16_000, channels: 1))
let file = try AVAudioFile(forWriting: url, settings: format.settings)
let frames = AVAudioFrameCount(seconds * 16_000)
let buffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frames))
buffer.frameLength = frames
try file.write(from: buffer)
return url
}
private func waitUntil(
_ condition: () -> Bool,
sourceLocation: SourceLocation = #_sourceLocation
) async {
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while !condition(), ContinuousClock.now < deadline {
await Task.yield()
try? await Task.sleep(nanoseconds: 1_000_000)
}
#expect(condition(), sourceLocation: sourceLocation)
}
@Test func seekWhilePausedDoesNotAcquireSession() throws {
let session = RecordingAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let url = try makeTempVoiceNote()
defer { try? FileManager.default.removeItem(at: url) }
let controller = VoiceNotePlaybackController(
url: url,
sessionCoordinator: coordinator,
exclusivity: VoiceNotePlaybackCoordinator()
)
controller.seek(to: 0.5)
// The scrub position moved (the player is real and ready)...
#expect(controller.progress > 0.25)
// ...but nothing is audible, so the session must not be held: an
// acquired-while-paused token on a discarded row would pin the
// session (and any escalated category) forever.
#expect(session.activationCalls.isEmpty)
#expect(!controller.isPlaying)
}
@Test func deinitReleasesSessionAndStopsPlayback() async throws {
let session = RecordingAudioSession()
let coordinator = AudioSessionCoordinator(session: session)
let url = try makeTempVoiceNote()
defer { try? FileManager.default.removeItem(at: url) }
// Fresh exclusivity slot: a parallel test's play() must not pause
// this controller while its session acquire is in flight.
var controller: VoiceNotePlaybackController? = VoiceNotePlaybackController(
url: url,
sessionCoordinator: coordinator,
exclusivity: VoiceNotePlaybackCoordinator()
)
controller?.play()
// The session acquire is asynchronous now (its blocking IPC runs off
// the main thread), so await the activation instead of asserting
// right after play().
await waitUntil { session.activationCalls == [true] }
// Navigating away discards the row's @StateObject mid-playback:
// deinit must release the session hold (a fire-and-forget hop onto
// the coordinator's queue).
controller = nil
await waitUntil { session.activationCalls == [true, false] }
}
@Test func activationFailureDoesNotStartUnregisteredPlayback() async throws {
let session = RecordingAudioSession()
session.categoryError = PlaybackSessionError()
let coordinator = AudioSessionCoordinator(session: session)
let url = try makeTempVoiceNote(seconds: 30)
defer { try? FileManager.default.removeItem(at: url) }
let controller = VoiceNotePlaybackController(
url: url,
sessionCoordinator: coordinator,
exclusivity: VoiceNotePlaybackCoordinator()
)
controller.play()
await waitUntil {
session.categoryCallCount == 1 && !controller.isPlaybackStartPending
}
#expect(session.activationCalls.isEmpty)
#expect(!controller.isPlaying)
#expect(controller.currentTime == 0)
}
}
+402
View File
@@ -0,0 +1,402 @@
//
// VoiceRecorderTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Testing
@testable import bitchat
private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendable {
private let lock = NSLock()
private let activationGate = DispatchSemaphore(value: 0)
private let shouldGateFirstActivation: Bool
private var gatedFirstActivation = false
private var _activationCalls: [Bool] = []
private var _activationBegan = false
init(gateFirstActivation: Bool = false) {
self.shouldGateFirstActivation = gateFirstActivation
}
var activationCalls: [Bool] { lock.withLock { _activationCalls } }
var activationBegan: Bool { lock.withLock { _activationBegan } }
func setCategory(_ category: AudioSessionCoordinator.Category) throws {}
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
let shouldWait = lock.withLock { () -> Bool in
_activationCalls.append(active)
guard active, shouldGateFirstActivation, !gatedFirstActivation else { return false }
gatedFirstActivation = true
_activationBegan = true
return true
}
if shouldWait {
activationGate.wait()
}
}
func resumeActivation() {
activationGate.signal()
}
}
private final class TestVoiceAudioRecorder: VoiceAudioRecording {
let prepareResult: Bool
let recordResult: Bool
private let lock = NSLock()
private var _isRecording = false
private var _isMeteringEnabled = false
private var _prepareCallCount = 0
private var _recordedDurations: [TimeInterval] = []
private var _stopCallCount = 0
init(prepareResult: Bool, recordResult: Bool) {
self.prepareResult = prepareResult
self.recordResult = recordResult
}
var isRecording: Bool { lock.withLock { _isRecording } }
var isMeteringEnabled: Bool {
get { lock.withLock { _isMeteringEnabled } }
set { lock.withLock { _isMeteringEnabled = newValue } }
}
var prepareCallCount: Int { lock.withLock { _prepareCallCount } }
var recordedDurations: [TimeInterval] { lock.withLock { _recordedDurations } }
var stopCallCount: Int { lock.withLock { _stopCallCount } }
func prepareToRecord() -> Bool {
lock.withLock { _prepareCallCount += 1 }
return prepareResult
}
func record(forDuration duration: TimeInterval) -> Bool {
lock.withLock {
_recordedDurations.append(duration)
if recordResult {
_isRecording = true
}
}
return recordResult
}
func stop() {
lock.withLock {
_stopCallCount += 1
_isRecording = false
}
}
/// Models `record(forDuration:)` reaching its duration cap before the
/// caller invokes `VoiceRecorder.stopRecording(owner:)`.
func simulateAutomaticStop() {
lock.withLock { _isRecording = false }
}
}
private final class TestVoiceAudioRecorderFactory: VoiceAudioRecorderCreating {
struct Plan {
let prepareResult: Bool
let recordResult: Bool
static let success = Plan(prepareResult: true, recordResult: true)
}
private let lock = NSLock()
private var plans: [Plan]
private var _recorders: [TestVoiceAudioRecorder] = []
private var _urls: [URL] = []
init(plans: [Plan]) {
self.plans = plans
}
var recorders: [TestVoiceAudioRecorder] { lock.withLock { _recorders } }
var urls: [URL] { lock.withLock { _urls } }
func makeRecorder(url: URL) throws -> any VoiceAudioRecording {
let plan = lock.withLock { plans.isEmpty ? .success : plans.removeFirst() }
// AVAudioRecorder creates its output during initialization. A real
// byte on disk lets the tests distinguish preserve from delete.
try Data([0x01]).write(to: url)
let recorder = TestVoiceAudioRecorder(
prepareResult: plan.prepareResult,
recordResult: plan.recordResult
)
lock.withLock {
_recorders.append(recorder)
_urls.append(url)
}
return recorder
}
}
/// One-shot async gate that proves `VoiceRecorder.stopRecording` has reached
/// its actor-reentrant padding boundary, then holds it there until the test has
/// exercised a competing owner. Unlike `Task.yield()` plus a short real sleep,
/// this remains deterministic when the full test suite saturates the executor.
private final class VoiceRecorderPaddingGate: @unchecked Sendable {
private let lock = NSLock()
private var _entered = false
private var isOpen = false
private var openWaiters: [CheckedContinuation<Void, Never>] = []
var entered: Bool { lock.withLock { _entered } }
func wait() async {
await withCheckedContinuation { continuation in
let resumeImmediately = lock.withLock { () -> Bool in
_entered = true
guard !isOpen else { return true }
openWaiters.append(continuation)
return false
}
if resumeImmediately {
continuation.resume()
}
}
}
func open() {
let waiters = lock.withLock { () -> [CheckedContinuation<Void, Never>] in
isOpen = true
defer { openWaiters.removeAll() }
return openWaiters
}
waiters.forEach { $0.resume() }
}
}
@MainActor
struct VoiceRecorderTests {
private func makeTemporaryDirectory() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("voice-recorder-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
private func waitUntil(
_ condition: () -> Bool,
sourceLocation: SourceLocation = #_sourceLocation
) async {
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while !condition(), ContinuousClock.now < deadline {
await Task.yield()
try? await Task.sleep(nanoseconds: 1_000_000)
}
#expect(condition(), sourceLocation: sourceLocation)
}
@Test func cancelWhileSessionAcquireIsInFlightNeverCreatesARecorder() async throws {
let directory = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let session = VoiceRecorderTestSession(gateFirstActivation: true)
let coordinator = AudioSessionCoordinator(session: session)
let factory = TestVoiceAudioRecorderFactory(plans: [.success])
let voiceRecorder = VoiceRecorder(
sessionCoordinator: coordinator,
recorderFactory: factory,
permissionGranted: { true },
paddingInterval: 0,
outputDirectory: directory
)
let owner = VoiceRecorder.RecordingOwner()
let startTask = Task { try await voiceRecorder.startRecording(owner: owner) }
await waitUntil { session.activationBegan }
await voiceRecorder.cancelRecording(owner: owner)
session.resumeActivation()
do {
_ = try await startTask.value
Issue.record("The canceled session acquire unexpectedly started recording")
} catch {
#expect(error is CancellationError)
}
await coordinator.drain()
#expect(factory.recorders.isEmpty)
#expect(session.activationCalls == [true, false])
}
@Test func prepareFailureCleansUpAndAllowsTheNextRecording() async throws {
try await verifyFailedStart(
firstPlan: .init(prepareResult: false, recordResult: true),
expectedPrepareCalls: 1,
expectedRecordCalls: 0
)
}
@Test func recordFailureCleansUpAndAllowsTheNextRecording() async throws {
try await verifyFailedStart(
firstPlan: .init(prepareResult: true, recordResult: false),
expectedPrepareCalls: 1,
expectedRecordCalls: 1
)
}
@Test func automaticStopReturnsAndPreservesFileThenNextRecordingWorks() async throws {
let directory = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let session = VoiceRecorderTestSession()
let coordinator = AudioSessionCoordinator(session: session)
let factory = TestVoiceAudioRecorderFactory(plans: [.success, .success])
let voiceRecorder = VoiceRecorder(
sessionCoordinator: coordinator,
recorderFactory: factory,
permissionGranted: { true },
paddingInterval: 0,
outputDirectory: directory
)
let firstOwner = VoiceRecorder.RecordingOwner()
let firstURL = try await voiceRecorder.startRecording(owner: firstOwner)
let firstRecorder = try #require(factory.recorders.first)
#expect(firstRecorder.recordedDurations == [120])
firstRecorder.simulateAutomaticStop()
let finishedURL = await voiceRecorder.stopRecording(owner: firstOwner)
await coordinator.drain()
#expect(finishedURL == firstURL)
#expect(firstRecorder.stopCallCount == 0)
#expect(FileManager.default.fileExists(atPath: firstURL.path))
#expect(session.activationCalls == [true, false])
let secondOwner = VoiceRecorder.RecordingOwner()
let secondURL = try await voiceRecorder.startRecording(owner: secondOwner)
#expect(secondURL != firstURL)
#expect(FileManager.default.fileExists(atPath: firstURL.path))
#expect(factory.recorders.count == 2)
let secondRecorder = try #require(factory.recorders.last)
#expect(await voiceRecorder.stopRecording(owner: secondOwner) == secondURL)
await coordinator.drain()
#expect(secondRecorder.stopCallCount == 1)
#expect(session.activationCalls == [true, false, true, false])
#expect(FileManager.default.fileExists(atPath: firstURL.path))
#expect(FileManager.default.fileExists(atPath: secondURL.path))
}
@Test func rejectedNewHoldCancelCannotDeleteHoldFinishingDuringPadding() async throws {
let directory = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let session = VoiceRecorderTestSession()
let coordinator = AudioSessionCoordinator(session: session)
let factory = TestVoiceAudioRecorderFactory(plans: [.success, .success])
let paddingGate = VoiceRecorderPaddingGate()
let voiceRecorder = VoiceRecorder(
sessionCoordinator: coordinator,
recorderFactory: factory,
permissionGranted: { true },
paddingInterval: 0.05,
outputDirectory: directory,
testingHooks: .init(waitForStopPadding: { _ in await paddingGate.wait() })
)
let finishingHold = VoiceNoteCaptureSession(recorder: voiceRecorder)
let rejectedHold = VoiceNoteCaptureSession(recorder: voiceRecorder)
try await finishingHold.start()
let firstURL = try #require(factory.urls.first)
let finishTask = Task { await finishingHold.finish() }
await waitUntil { paddingGate.entered }
await #expect(throws: VoiceRecorder.RecorderError.recordingInProgress) {
try await rejectedHold.start()
}
// This is the view-model error path that used to globally cancel the
// shared recorder and delete `firstURL` during the padding sleep.
await rejectedHold.cancel()
paddingGate.open()
#expect(await finishTask.value == firstURL)
await coordinator.drain()
#expect(FileManager.default.fileExists(atPath: firstURL.path))
#expect(factory.recorders[0].stopCallCount == 1)
#expect(session.activationCalls == [true, false])
}
@Test func stalePreviousHoldCancelCannotStopNewRecording() async throws {
let directory = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let session = VoiceRecorderTestSession()
let coordinator = AudioSessionCoordinator(session: session)
let factory = TestVoiceAudioRecorderFactory(plans: [.success, .success])
let voiceRecorder = VoiceRecorder(
sessionCoordinator: coordinator,
recorderFactory: factory,
permissionGranted: { true },
paddingInterval: 0,
outputDirectory: directory
)
let previousHold = VoiceNoteCaptureSession(recorder: voiceRecorder)
let currentHold = VoiceNoteCaptureSession(recorder: voiceRecorder)
try await previousHold.start()
let firstURL = try #require(factory.urls.first)
#expect(await previousHold.finish() == firstURL)
try await currentHold.start()
let secondURL = try #require(factory.urls.last)
let secondRecorder = try #require(factory.recorders.last)
await previousHold.cancel()
#expect(secondRecorder.isRecording)
#expect(FileManager.default.fileExists(atPath: secondURL.path))
#expect(await currentHold.finish() == secondURL)
await coordinator.drain()
#expect(secondRecorder.stopCallCount == 1)
#expect(FileManager.default.fileExists(atPath: firstURL.path))
#expect(FileManager.default.fileExists(atPath: secondURL.path))
}
private func verifyFailedStart(
firstPlan: TestVoiceAudioRecorderFactory.Plan,
expectedPrepareCalls: Int,
expectedRecordCalls: Int
) async throws {
let directory = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let session = VoiceRecorderTestSession()
let coordinator = AudioSessionCoordinator(session: session)
let factory = TestVoiceAudioRecorderFactory(plans: [firstPlan, .success])
let voiceRecorder = VoiceRecorder(
sessionCoordinator: coordinator,
recorderFactory: factory,
permissionGranted: { true },
paddingInterval: 0,
outputDirectory: directory
)
let failedOwner = VoiceRecorder.RecordingOwner()
await #expect(throws: VoiceRecorder.RecorderError.failedToStartRecording) {
try await voiceRecorder.startRecording(owner: failedOwner)
}
await coordinator.drain()
let failedRecorder = try #require(factory.recorders.first)
let failedURL = try #require(factory.urls.first)
#expect(failedRecorder.prepareCallCount == expectedPrepareCalls)
#expect(failedRecorder.recordedDurations.count == expectedRecordCalls)
#expect(!FileManager.default.fileExists(atPath: failedURL.path))
#expect(session.activationCalls == [true, false])
let nextOwner = VoiceRecorder.RecordingOwner()
let nextURL = try await voiceRecorder.startRecording(owner: nextOwner)
#expect(FileManager.default.fileExists(atPath: nextURL.path))
#expect(await voiceRecorder.stopRecording(owner: nextOwner) == nextURL)
await coordinator.drain()
#expect(session.activationCalls == [true, false, true, false])
}
}