mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 03:45:20 +00:00
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:
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user