mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 07:25:18 +00:00
Harden panic recovery and service shutdown
This commit is contained in:
@@ -572,6 +572,26 @@ struct BLEServiceCoreTests {
|
||||
#expect(ble.myPeerID == PeerID(str: newFingerprint.prefix(16)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func panicSuspension_dropsLateOutboundWorkUntilCommit() async {
|
||||
let ble = makeService()
|
||||
let outbound = OutboundPacketTap()
|
||||
ble._test_onOutboundPacket = outbound.record
|
||||
let packet = makePublicPacket(
|
||||
content: "late callback",
|
||||
sender: ble.myPeerID,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
)
|
||||
|
||||
ble.suspendForPanicReset()
|
||||
ble.sendPacket(packet)
|
||||
#expect(outbound.count(ofType: .message) == 0)
|
||||
|
||||
ble.completePanicReset(restartServices: false)
|
||||
ble.sendPacket(packet)
|
||||
#expect(outbound.count(ofType: .message) == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
|
||||
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
||||
|
||||
@@ -16,9 +16,12 @@ import BitFoundation
|
||||
/// Creates a ChatViewModel with mock dependencies for testing
|
||||
@MainActor
|
||||
private func makeTestableViewModel(
|
||||
panicMediaWipe: (() throws -> Void)? = nil
|
||||
keychain injectedKeychain: MockKeychain? = nil,
|
||||
panicMediaWipe: (() throws -> Void)? = nil,
|
||||
panicRecoveryOperations: PanicRecoveryOperations? = nil,
|
||||
panicNetworkLifecycle: PanicNetworkLifecycle = .noop
|
||||
) -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||
let keychain = MockKeychain()
|
||||
let keychain = injectedKeychain ?? MockKeychain()
|
||||
let keychainHelper = MockKeychainHelper()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
@@ -29,7 +32,9 @@ private func makeTestableViewModel(
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport,
|
||||
panicMediaWipe: panicMediaWipe
|
||||
panicMediaWipe: panicMediaWipe,
|
||||
panicRecoveryOperations: panicRecoveryOperations,
|
||||
panicNetworkLifecycle: panicNetworkLifecycle
|
||||
)
|
||||
|
||||
return (viewModel, transport)
|
||||
@@ -1122,15 +1127,156 @@ struct ChatViewModelPanicTests {
|
||||
@Test @MainActor
|
||||
func panicClearAllData_finishesMediaWipeBeforeReturning() {
|
||||
var wipeFinished = false
|
||||
let (viewModel, _) = makeTestableViewModel {
|
||||
let (viewModel, _) = makeTestableViewModel(panicMediaWipe: {
|
||||
wipeFinished = true
|
||||
}
|
||||
})
|
||||
|
||||
viewModel.panicClearAllData()
|
||||
|
||||
#expect(wipeFinished)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func panicClearAllData_stopsNetworkBeforeWipeAndRestartsAfterCommit() {
|
||||
var events: [String] = []
|
||||
let lifecycle = PanicNetworkLifecycle(
|
||||
stop: { events.append("stop") },
|
||||
restart: { events.append("restart") }
|
||||
)
|
||||
let (viewModel, _) = makeTestableViewModel(
|
||||
panicMediaWipe: { events.append("wipe") },
|
||||
panicNetworkLifecycle: lifecycle
|
||||
)
|
||||
|
||||
let completed = viewModel.panicClearAllData()
|
||||
|
||||
#expect(completed)
|
||||
#expect(events == ["stop", "wipe", "restart"])
|
||||
#expect(viewModel.networkActivationAllowed)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func panicKeychainFailureKeepsRecoveryPendingAndServicesStopped() {
|
||||
let keychain = MockKeychain()
|
||||
keychain.simulatedDeleteAllResult = false
|
||||
var events: [String] = []
|
||||
let operations = PanicRecoveryOperations(
|
||||
isPending: { false },
|
||||
begin: {
|
||||
events.append("begin")
|
||||
return PanicRecoveryIntent(
|
||||
fileMarkerEstablished: true,
|
||||
externalMarkerEstablished: false
|
||||
)
|
||||
},
|
||||
wipeMedia: { _ in events.append("wipe") },
|
||||
complete: { events.append("complete") }
|
||||
)
|
||||
let lifecycle = PanicNetworkLifecycle(
|
||||
stop: { events.append("stop") },
|
||||
restart: { events.append("restart") }
|
||||
)
|
||||
let (viewModel, transport) = makeTestableViewModel(
|
||||
keychain: keychain,
|
||||
panicRecoveryOperations: operations,
|
||||
panicNetworkLifecycle: lifecycle
|
||||
)
|
||||
let startsBeforePanic = transport.startServicesCallCount
|
||||
|
||||
let completed = viewModel.panicClearAllData()
|
||||
|
||||
#expect(!completed)
|
||||
#expect(events == ["stop", "begin", "wipe"])
|
||||
#expect(keychain.deleteAllCallCount == 1)
|
||||
#expect(transport.startServicesCallCount == startsBeforePanic)
|
||||
#expect(!viewModel.networkActivationAllowed)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func pendingPanicRecoveryCompletesBeforeTransportBootstrap() {
|
||||
var events: [String] = []
|
||||
let operations = PanicRecoveryOperations(
|
||||
isPending: {
|
||||
events.append("read")
|
||||
return true
|
||||
},
|
||||
begin: {
|
||||
events.append("begin")
|
||||
return PanicRecoveryIntent(
|
||||
fileMarkerEstablished: true,
|
||||
externalMarkerEstablished: false
|
||||
)
|
||||
},
|
||||
wipeMedia: { _ in events.append("wipe") },
|
||||
complete: { events.append("complete") }
|
||||
)
|
||||
|
||||
let (viewModel, transport) = makeTestableViewModel(
|
||||
panicRecoveryOperations: operations
|
||||
)
|
||||
|
||||
#expect(events == ["read", "begin", "wipe", "complete"])
|
||||
#expect(transport.emergencyDisconnectCallCount == 1)
|
||||
#expect(transport.startServicesCallCount == 1)
|
||||
#expect(viewModel.networkActivationAllowed)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func failedStartupRecoveryLeavesTransportAndNetworkBlocked() {
|
||||
enum WipeFailure: Error { case failed }
|
||||
var completedMarker = false
|
||||
let operations = PanicRecoveryOperations(
|
||||
isPending: { true },
|
||||
begin: {
|
||||
PanicRecoveryIntent(
|
||||
fileMarkerEstablished: true,
|
||||
externalMarkerEstablished: false
|
||||
)
|
||||
},
|
||||
wipeMedia: { _ in throw WipeFailure.failed },
|
||||
complete: { completedMarker = true }
|
||||
)
|
||||
|
||||
let (viewModel, transport) = makeTestableViewModel(
|
||||
panicRecoveryOperations: operations
|
||||
)
|
||||
|
||||
#expect(!completedMarker)
|
||||
#expect(transport.emergencyDisconnectCallCount == 1)
|
||||
#expect(transport.startServicesCallCount == 0)
|
||||
#expect(!viewModel.networkActivationAllowed)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func failedStartupKeychainRecoveryLeavesIntentAndTransportBlocked() {
|
||||
let keychain = MockKeychain()
|
||||
keychain.simulatedDeleteAllResult = false
|
||||
var events: [String] = []
|
||||
let operations = PanicRecoveryOperations(
|
||||
isPending: { true },
|
||||
begin: {
|
||||
events.append("begin")
|
||||
return PanicRecoveryIntent(
|
||||
fileMarkerEstablished: true,
|
||||
externalMarkerEstablished: true
|
||||
)
|
||||
},
|
||||
wipeMedia: { _ in events.append("wipe") },
|
||||
complete: { events.append("complete") }
|
||||
)
|
||||
|
||||
let (viewModel, transport) = makeTestableViewModel(
|
||||
keychain: keychain,
|
||||
panicRecoveryOperations: operations
|
||||
)
|
||||
|
||||
#expect(events == ["begin", "wipe"])
|
||||
#expect(keychain.deleteAllCallCount == 1)
|
||||
#expect(transport.emergencyDisconnectCallCount == 1)
|
||||
#expect(transport.startServicesCallCount == 0)
|
||||
#expect(!viewModel.networkActivationAllowed)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func panicClearAllData_delegatesToTransport() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
|
||||
@@ -18,6 +18,8 @@ final class MockKeychain: KeychainManagerProtocol {
|
||||
var simulatedReadError: KeychainReadResult?
|
||||
var simulatedSaveError: KeychainSaveResult?
|
||||
var simulatedGenericReadError: KeychainReadResult?
|
||||
var simulatedDeleteAllResult = true
|
||||
private(set) var deleteAllCallCount = 0
|
||||
|
||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||
storage[key] = keyData
|
||||
@@ -34,6 +36,8 @@ final class MockKeychain: KeychainManagerProtocol {
|
||||
}
|
||||
|
||||
func deleteAllKeychainData() -> Bool {
|
||||
deleteAllCallCount += 1
|
||||
guard simulatedDeleteAllResult else { return false }
|
||||
storage.removeAll()
|
||||
serviceStorage.removeAll()
|
||||
return true
|
||||
|
||||
@@ -410,6 +410,92 @@ struct BLEFileTransferHandlerTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
|
||||
enum MarkerFailure: Error { case unavailable }
|
||||
|
||||
let base = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(
|
||||
"panic-marker-failure-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: base) }
|
||||
let secret = base
|
||||
.appendingPathComponent("files/images/outgoing", isDirectory: true)
|
||||
.appendingPathComponent("secret.jpg")
|
||||
try FileManager.default.createDirectory(
|
||||
at: secret.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try Data("secret".utf8).write(to: secret)
|
||||
let store = BLEIncomingFileStore(
|
||||
baseDirectory: base,
|
||||
panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable }
|
||||
)
|
||||
|
||||
do {
|
||||
try store.panicWipe(hasDurablePendingMarker: false)
|
||||
Issue.record("Expected the missing durable marker to fail closed")
|
||||
} catch {
|
||||
// The marker error is reported only after the deletion attempt.
|
||||
}
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: secret.path))
|
||||
#expect(
|
||||
FileManager.default.fileExists(
|
||||
atPath: secret.deletingLastPathComponent().path
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func externalMarkerAllowsDeletionToCommitWhenFileMarkerFails() throws {
|
||||
enum MarkerFailure: Error { case unavailable }
|
||||
|
||||
let base = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(
|
||||
"panic-external-marker-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: base) }
|
||||
let secret = base
|
||||
.appendingPathComponent("files/voicenotes/incoming", isDirectory: true)
|
||||
.appendingPathComponent("secret.m4a")
|
||||
try FileManager.default.createDirectory(
|
||||
at: secret.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try Data("secret".utf8).write(to: secret)
|
||||
let store = BLEIncomingFileStore(
|
||||
baseDirectory: base,
|
||||
panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable }
|
||||
)
|
||||
|
||||
try store.panicWipe(hasDurablePendingMarker: true)
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: secret.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func panicRecoveryMarkerPersistsUntilExplicitCommit() throws {
|
||||
let base = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(
|
||||
"panic-recovery-marker-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: base) }
|
||||
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||
|
||||
try store.markPanicRecoveryPending()
|
||||
#expect(try store.isPanicRecoveryPending())
|
||||
try store.panicWipe(hasDurablePendingMarker: true)
|
||||
#expect(try store.isPanicRecoveryPending())
|
||||
|
||||
try store.completePanicRecovery()
|
||||
|
||||
#expect(try !store.isPanicRecoveryPending())
|
||||
}
|
||||
|
||||
private func expectNoSideEffects(_ recorder: Recorder) {
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
|
||||
@@ -76,6 +76,7 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
||||
burstMaxDelay: 0
|
||||
)
|
||||
|
||||
service.start()
|
||||
service.performHeartbeat()
|
||||
|
||||
let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 }
|
||||
@@ -83,7 +84,7 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
||||
XCTAssertEqual(Set(sentGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
||||
XCTAssertEqual(Set(lookedUpGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
||||
XCTAssertEqual(sleptNanoseconds.count, 3)
|
||||
XCTAssertEqual(scheduler.intervals, [17])
|
||||
XCTAssertEqual(scheduler.intervals, [17, 17])
|
||||
}
|
||||
|
||||
func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async {
|
||||
@@ -97,11 +98,12 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
||||
loopMaxInterval: 21
|
||||
)
|
||||
|
||||
service.start()
|
||||
service.performHeartbeat()
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertEqual(sendCount, 0)
|
||||
XCTAssertEqual(scheduler.intervals, [21])
|
||||
XCTAssertEqual(scheduler.intervals, [21, 21])
|
||||
}
|
||||
|
||||
func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async {
|
||||
@@ -115,11 +117,45 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
||||
loopMaxInterval: 22
|
||||
)
|
||||
|
||||
service.start()
|
||||
service.performHeartbeat()
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertEqual(sendCount, 0)
|
||||
XCTAssertEqual(scheduler.intervals, [22])
|
||||
XCTAssertEqual(scheduler.intervals, [22, 22])
|
||||
}
|
||||
|
||||
func test_stopForPanic_cancelsTimerAndSuppressesDelayedBroadcast() async throws {
|
||||
let identity = try NostrIdentity.generate()
|
||||
let scheduler = MockGeohashPresenceScheduler()
|
||||
var sleeperContinuation: CheckedContinuation<Void, Never>?
|
||||
var sendCount = 0
|
||||
let service = makeService(
|
||||
scheduler: scheduler,
|
||||
deriveIdentity: { _ in identity },
|
||||
relaySender: { _, _ in sendCount += 1 },
|
||||
sleeper: { _ in
|
||||
await withCheckedContinuation { continuation in
|
||||
sleeperContinuation = continuation
|
||||
}
|
||||
},
|
||||
burstMinDelay: 1,
|
||||
burstMaxDelay: 1
|
||||
)
|
||||
|
||||
service.start()
|
||||
service.performHeartbeat()
|
||||
let delayStarted = await waitUntil {
|
||||
sleeperContinuation != nil
|
||||
}
|
||||
XCTAssertTrue(delayStarted)
|
||||
|
||||
service.stopForPanic()
|
||||
sleeperContinuation?.resume()
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertEqual(sendCount, 0)
|
||||
XCTAssertEqual(scheduler.timers.first?.invalidateCallCount, 1)
|
||||
}
|
||||
|
||||
func test_broadcastPresence_skipsSendWhenNoRelaysAreAvailable() async throws {
|
||||
|
||||
@@ -91,6 +91,53 @@ final class NetworkActivationServiceTests: XCTestCase {
|
||||
XCTAssertGreaterThanOrEqual(context.relayController.connectCallCount, 1)
|
||||
}
|
||||
|
||||
func test_stopForPanic_synchronouslyStopsAndIgnoresPublisherUpdates() async {
|
||||
let context = makeService(permission: .authorized, favorites: [])
|
||||
|
||||
context.service.start()
|
||||
context.service.stopForPanic()
|
||||
let connectCountAfterStop = context.relayController.connectCallCount
|
||||
let startCountAfterStop = context.torController.startIfNeededCallCount
|
||||
|
||||
context.favoritesSubject.send([Data([0x01])])
|
||||
context.reachability.set(false)
|
||||
context.reachability.set(true)
|
||||
try? await Task.sleep(nanoseconds: 30_000_000)
|
||||
|
||||
XCTAssertFalse(context.service.activationAllowed)
|
||||
XCTAssertEqual(context.reachability.stopCallCount, 1)
|
||||
XCTAssertEqual(context.torController.autoStartAllowedValues.last, false)
|
||||
XCTAssertEqual(context.proxyController.proxyModes.last, false)
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
context.torController.shutdownCompletelyCallCount,
|
||||
1
|
||||
)
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
context.relayController.disconnectCallCount,
|
||||
1
|
||||
)
|
||||
XCTAssertEqual(
|
||||
context.relayController.connectCallCount,
|
||||
connectCountAfterStop
|
||||
)
|
||||
XCTAssertEqual(
|
||||
context.torController.startIfNeededCallCount,
|
||||
startCountAfterStop
|
||||
)
|
||||
}
|
||||
|
||||
func test_start_afterPanicStop_reestablishesSubscriptions() {
|
||||
let context = makeService(permission: .authorized, favorites: [])
|
||||
|
||||
context.service.start()
|
||||
context.service.stopForPanic()
|
||||
context.service.start()
|
||||
|
||||
XCTAssertTrue(context.service.activationAllowed)
|
||||
XCTAssertEqual(context.reachability.startCallCount, 2)
|
||||
XCTAssertEqual(context.relayController.connectCallCount, 2)
|
||||
}
|
||||
|
||||
private func makeService(
|
||||
permission: LocationChannelManager.PermissionState,
|
||||
favorites: Set<Data>
|
||||
@@ -104,6 +151,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
||||
let torController = MockNetworkActivationTorController()
|
||||
let relayController = MockNetworkActivationRelayController()
|
||||
let proxyController = MockNetworkActivationProxyController()
|
||||
let reachability = MockNetworkActivationReachability()
|
||||
let notificationCenter = NotificationCenter()
|
||||
let service = NetworkActivationService(
|
||||
storage: storage,
|
||||
@@ -111,7 +159,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
||||
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
||||
permissionProvider: { permissionSubject.value },
|
||||
mutualFavoritesProvider: { favoritesSubject.value },
|
||||
reachabilityMonitor: AlwaysReachableMonitor(),
|
||||
reachabilityMonitor: reachability,
|
||||
torController: torController,
|
||||
relayController: relayController,
|
||||
proxyController: proxyController,
|
||||
@@ -121,6 +169,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
||||
service: service,
|
||||
storage: storage,
|
||||
favoritesSubject: favoritesSubject,
|
||||
reachability: reachability,
|
||||
torController: torController,
|
||||
relayController: relayController,
|
||||
proxyController: proxyController,
|
||||
@@ -148,12 +197,38 @@ private struct NetworkActivationTestContext {
|
||||
let service: NetworkActivationService
|
||||
let storage: UserDefaults
|
||||
let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
|
||||
let reachability: MockNetworkActivationReachability
|
||||
let torController: MockNetworkActivationTorController
|
||||
let relayController: MockNetworkActivationRelayController
|
||||
let proxyController: MockNetworkActivationProxyController
|
||||
let notificationCenter: NotificationCenter
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockNetworkActivationReachability:
|
||||
NetworkReachabilityMonitoring {
|
||||
private let subject = CurrentValueSubject<Bool, Never>(true)
|
||||
private(set) var startCallCount = 0
|
||||
private(set) var stopCallCount = 0
|
||||
|
||||
var isReachable: Bool { subject.value }
|
||||
var reachabilityPublisher: AnyPublisher<Bool, Never> {
|
||||
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func start() {
|
||||
startCallCount += 1
|
||||
}
|
||||
|
||||
func stop() {
|
||||
stopCallCount += 1
|
||||
}
|
||||
|
||||
func set(_ reachable: Bool) {
|
||||
subject.send(reachable)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockNetworkActivationTorController: NetworkActivationTorControlling {
|
||||
private(set) var autoStartAllowedValues: [Bool] = []
|
||||
|
||||
@@ -213,6 +213,7 @@ private final class ControllableReachabilityMonitor: NetworkReachabilityMonitori
|
||||
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
||||
}
|
||||
func start() { startCalled = true }
|
||||
func stop() { startCalled = false }
|
||||
func set(_ reachable: Bool) { subject.send(reachable) }
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession {
|
||||
private let startError: Error?
|
||||
private(set) var finishStarted = false
|
||||
private(set) var cancelCount = 0
|
||||
private(set) var panicCancelCount = 0
|
||||
private var finishContinuation: CheckedContinuation<URL?, Never>?
|
||||
|
||||
init(startError: Error? = nil) {
|
||||
@@ -83,6 +84,10 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession {
|
||||
cancelCount += 1
|
||||
}
|
||||
|
||||
func panicCancelSynchronously() {
|
||||
panicCancelCount += 1
|
||||
}
|
||||
|
||||
func resolveFinish(with url: URL?) {
|
||||
let continuation = finishContinuation
|
||||
finishContinuation = nil
|
||||
@@ -204,4 +209,57 @@ struct VoiceCaptureSessionTests {
|
||||
}
|
||||
#expect(viewModel.state == .idle)
|
||||
}
|
||||
|
||||
@Test func panicSynchronouslyCancelsActiveCaptureAndResetsUI() async {
|
||||
let session = GatedVoiceCaptureSession()
|
||||
let viewModel = VoiceRecordingViewModel()
|
||||
viewModel.sessionProvider = { session }
|
||||
|
||||
viewModel.start(shouldShow: true)
|
||||
await waitUntil { self.isRecording(viewModel.state) }
|
||||
|
||||
viewModel.panicWipe()
|
||||
|
||||
#expect(session.panicCancelCount == 1)
|
||||
#expect(viewModel.state == .idle)
|
||||
#expect(!viewModel.isLiveStreaming)
|
||||
}
|
||||
|
||||
@Test func panicInvalidatesARecordingAlreadyFinalizing() async throws {
|
||||
let session = GatedVoiceCaptureSession()
|
||||
let viewModel = VoiceRecordingViewModel()
|
||||
viewModel.sessionProvider = { session }
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("voice-panic-\(UUID().uuidString).m4a")
|
||||
try Data([0x01]).write(to: url)
|
||||
var delivered = false
|
||||
|
||||
viewModel.start(shouldShow: true)
|
||||
await waitUntil { self.isRecording(viewModel.state) }
|
||||
viewModel.finish { _ in delivered = true }
|
||||
await waitUntil { session.finishStarted }
|
||||
|
||||
viewModel.panicWipe()
|
||||
session.resolveFinish(with: url)
|
||||
await waitUntil {
|
||||
!FileManager.default.fileExists(atPath: url.path)
|
||||
}
|
||||
|
||||
#expect(!delivered)
|
||||
#expect(viewModel.state == .idle)
|
||||
}
|
||||
|
||||
@Test func liveSessionPanicStopsCaptureWithoutSendingControl() {
|
||||
let capture = StubPTTCapture(stopResult: (nil, 0))
|
||||
var sentPackets: [Data] = []
|
||||
let session = PTTLiveVoiceSession(
|
||||
sendPacket: { sentPackets.append($0) },
|
||||
capture: capture
|
||||
)
|
||||
|
||||
session.panicCancelSynchronously()
|
||||
|
||||
#expect(capture.cancelCount == 1)
|
||||
#expect(sentPackets.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +360,35 @@ struct VoiceRecorderTests {
|
||||
#expect(FileManager.default.fileExists(atPath: secondURL.path))
|
||||
}
|
||||
|
||||
@Test func classicSessionPanicStopsRecorderAndDeletesFileBeforeReturning() async throws {
|
||||
let directory = try makeTemporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let rawSession = VoiceRecorderTestSession()
|
||||
let coordinator = AudioSessionCoordinator(session: rawSession)
|
||||
let factory = TestVoiceAudioRecorderFactory(plans: [.success])
|
||||
let voiceRecorder = VoiceRecorder(
|
||||
sessionCoordinator: coordinator,
|
||||
recorderFactory: factory,
|
||||
permissionGranted: { true },
|
||||
paddingInterval: 0,
|
||||
outputDirectory: directory
|
||||
)
|
||||
let capture = VoiceNoteCaptureSession(recorder: voiceRecorder)
|
||||
|
||||
try await capture.start()
|
||||
let url = try #require(factory.urls.first)
|
||||
let recorder = try #require(factory.recorders.first)
|
||||
|
||||
capture.panicCancelSynchronously()
|
||||
|
||||
#expect(recorder.stopCallCount == 1)
|
||||
#expect(!recorder.isRecording)
|
||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||
await coordinator.drain()
|
||||
#expect(rawSession.activationCalls == [true, false])
|
||||
}
|
||||
|
||||
private func verifyFailedStart(
|
||||
firstPlan: TestVoiceAudioRecorderFactory.Plan,
|
||||
expectedPrepareCalls: Int,
|
||||
|
||||
Reference in New Issue
Block a user