import XCTest @testable import Rosetta /// Tests for race condition fixes in CallKit + custom UI interaction. /// Covers: finishCall re-entrancy, double-accept prevention, phase guards, /// CallKit foreground suppression, and UUID lifecycle. @MainActor final class CallRaceConditionTests: XCTestCase { private let ownKey = "02-own-race-test" private let peerKey = "02-peer-race-test" override func setUp() { super.setUp() CallManager.shared.resetForSessionEnd() CallManager.shared.bindAccount(publicKey: ownKey) } override func tearDown() { CallManager.shared.resetForSessionEnd() super.tearDown() } // MARK: - Helper private func simulateIncomingCall() { let packet = PacketSignalPeer( src: peerKey, dst: ownKey, sharedPublic: "", signalType: .call, roomId: "" ) CallManager.shared.testHandleSignalPacket(packet) } // MARK: - finishCall Re-Entrancy Guard func testFinishCallReEntrancyGuardResetsAfterCompletion() { simulateIncomingCall() XCTAssertEqual(CallManager.shared.uiState.phase, .incoming) XCTAssertFalse(CallManager.shared.isFinishingCall) CallManager.shared.endCall() XCTAssertEqual(CallManager.shared.uiState.phase, .idle) XCTAssertFalse(CallManager.shared.isFinishingCall, "isFinishingCall must reset after finishCall completes (defer block)") } func testFinishCallNoOpWhenAlreadyIdle() { XCTAssertEqual(CallManager.shared.uiState.phase, .idle) // Should be a no-op — no crash, no state change CallManager.shared.finishCall(reason: "test", notifyPeer: false) XCTAssertEqual(CallManager.shared.uiState.phase, .idle) XCTAssertFalse(CallManager.shared.isFinishingCall) } func testFinishCallCleansUpPhaseToIdle() { _ = CallManager.shared.startOutgoingCall( toPublicKey: peerKey, title: "Peer", username: "peer" ) XCTAssertEqual(CallManager.shared.uiState.phase, .outgoing) CallManager.shared.finishCall(reason: nil, notifyPeer: false) XCTAssertEqual(CallManager.shared.uiState.phase, .idle) XCTAssertFalse(CallManager.shared.isFinishingCall) } // MARK: - Double-Accept Prevention func testDoubleAcceptReturnsNotIncomingOnSecondCall() { simulateIncomingCall() XCTAssertEqual(CallManager.shared.uiState.phase, .incoming) // First accept succeeds let result1 = CallManager.shared.acceptIncomingCall() XCTAssertEqual(result1, .started) XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange) // Second accept fails — phase is no longer .incoming let result2 = CallManager.shared.acceptIncomingCall() XCTAssertEqual(result2, .notIncoming, "Second accept must fail — phase already changed to keyExchange") } func testAcceptAfterDeclineReturnsNotIncoming() { simulateIncomingCall() XCTAssertEqual(CallManager.shared.uiState.phase, .incoming) CallManager.shared.declineIncomingCall() XCTAssertEqual(CallManager.shared.uiState.phase, .idle) let result = CallManager.shared.acceptIncomingCall() XCTAssertEqual(result, .notIncoming, "Accept after decline must fail") } func testAcceptAfterEndCallReturnsNotIncoming() { simulateIncomingCall() XCTAssertEqual(CallManager.shared.uiState.phase, .incoming) CallManager.shared.endCall() XCTAssertEqual(CallManager.shared.uiState.phase, .idle) let result = CallManager.shared.acceptIncomingCall() XCTAssertEqual(result, .notIncoming, "Accept after endCall must fail") } func testAcceptWithNoAccountReturnsAccountNotBound() { CallManager.shared.bindAccount(publicKey: "") simulateIncomingCall() // Incoming signal was rejected because ownPublicKey is empty, // so phase stays idle. let result = CallManager.shared.acceptIncomingCall() XCTAssertEqual(result, .notIncoming) } // MARK: - Phase State Machine Guards func testIncomingCallWhileBusySendsBusySignal() { // Start outgoing call first _ = CallManager.shared.startOutgoingCall( toPublicKey: peerKey, title: "Peer", username: "peer" ) XCTAssertEqual(CallManager.shared.uiState.phase, .outgoing) // Second incoming call from different peer let packet = PacketSignalPeer( src: "02-another-peer", dst: ownKey, sharedPublic: "", signalType: .call, roomId: "" ) CallManager.shared.testHandleSignalPacket(packet) // Phase should still be outgoing (not replaced by incoming) XCTAssertEqual(CallManager.shared.uiState.phase, .outgoing, "Existing call must not be replaced by new incoming") XCTAssertEqual(CallManager.shared.uiState.peerPublicKey, peerKey, "Peer key must remain from original call") } func testEndCallBecauseBusySkipsAttachment() { _ = CallManager.shared.startOutgoingCall( toPublicKey: peerKey, title: "Peer", username: "peer" ) XCTAssertEqual(CallManager.shared.uiState.phase, .outgoing) let busyPacket = PacketSignalPeer( src: "", dst: "", sharedPublic: "", signalType: .endCallBecauseBusy, roomId: "" ) CallManager.shared.testHandleSignalPacket(busyPacket) XCTAssertEqual(CallManager.shared.uiState.phase, .idle) // skipAttachment=true prevents flooding chat with "Cancelled Call" bubbles } func testEndCallFromPeerTransitionsToIdle() { simulateIncomingCall() XCTAssertEqual(CallManager.shared.uiState.phase, .incoming) let endPacket = PacketSignalPeer( src: "", dst: "", sharedPublic: "", signalType: .endCall, roomId: "" ) CallManager.shared.testHandleSignalPacket(endPacket) XCTAssertEqual(CallManager.shared.uiState.phase, .idle) } func testPeerDisconnectedTransitionsToIdle() { simulateIncomingCall() _ = CallManager.shared.acceptIncomingCall() XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange) let disconnectPacket = PacketSignalPeer( src: "", dst: "", sharedPublic: "", signalType: .endCallBecausePeerDisconnected, roomId: "" ) CallManager.shared.testHandleSignalPacket(disconnectPacket) XCTAssertEqual(CallManager.shared.uiState.phase, .idle) } // MARK: - WebRTC Phase Guard func testSetCallActiveOnlyFromWebRtcExchange() { // Phase is idle — setCallActiveIfNeeded should be no-op CallManager.shared.setCallActiveIfNeeded() XCTAssertEqual(CallManager.shared.uiState.phase, .idle, "setCallActive must not activate from idle") // Phase is incoming — should also be no-op simulateIncomingCall() CallManager.shared.setCallActiveIfNeeded() XCTAssertEqual(CallManager.shared.uiState.phase, .incoming, "setCallActive must not activate from incoming") } func testSetCallActiveFromWebRtcExchangeSucceeds() { CallManager.shared.testSetUiState( CallUiState( phase: .webRtcExchange, peerPublicKey: peerKey, statusText: "Connecting..." ) ) CallManager.shared.setCallActiveIfNeeded() XCTAssertEqual(CallManager.shared.uiState.phase, .active) } // MARK: - CallKit UUID Lifecycle func testCallKitUUIDClearedAfterFinishCall() { simulateIncomingCall() CallManager.shared.endCall() XCTAssertEqual(CallManager.shared.uiState.phase, .idle) // CallKit UUID should be cleared after finishCall XCTAssertNil(CallKitManager.shared.currentCallUUID, "CallKit UUID must be cleared after call ends") } func testCallKitUUIDSetForIncoming() { // CallKit is always reported for incoming calls (foreground and background). // Telegram parity: CallKit compact banner in foreground, full screen on lock screen. simulateIncomingCall() XCTAssertEqual(CallManager.shared.uiState.phase, .incoming) XCTAssertNotNil( CallKitManager.shared.currentCallUUID, "CallKit must be reported for all incoming calls" ) } // MARK: - UI State Visibility func testOverlayNotVisibleDuringIncoming() { // Telegram parity: CallKit handles incoming UI. // Custom overlay only appears after accept (phase != .incoming). simulateIncomingCall() XCTAssertTrue(CallManager.shared.uiState.isVisible) // isFullScreenVisible is true but MainTabView guards with phase != .incoming XCTAssertEqual(CallManager.shared.uiState.phase, .incoming) } func testOverlayVisibleAfterAccept() { simulateIncomingCall() _ = CallManager.shared.acceptIncomingCall() XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange) XCTAssertTrue(CallManager.shared.uiState.isFullScreenVisible) XCTAssertFalse(CallManager.shared.uiState.isMinimized) } func testOverlayHiddenAfterDecline() { simulateIncomingCall() CallManager.shared.declineIncomingCall() XCTAssertFalse(CallManager.shared.uiState.isVisible) XCTAssertFalse(CallManager.shared.uiState.isFullScreenVisible) } func testMinimizedBarVisibleAfterMinimize() { simulateIncomingCall() CallManager.shared.minimizeCall() XCTAssertTrue(CallManager.shared.uiState.isVisible) XCTAssertTrue(CallManager.shared.uiState.isMinimized) XCTAssertFalse(CallManager.shared.uiState.isFullScreenVisible) } func testExpandAfterMinimize() { simulateIncomingCall() CallManager.shared.minimizeCall() XCTAssertTrue(CallManager.shared.uiState.isMinimized) CallManager.shared.expandCall() XCTAssertFalse(CallManager.shared.uiState.isMinimized) XCTAssertTrue(CallManager.shared.uiState.isFullScreenVisible) } // MARK: - Rapid State Transitions func testRapidStartEndDoesNotCrash() { for _ in 0..<10 { _ = CallManager.shared.startOutgoingCall( toPublicKey: peerKey, title: "Peer", username: "peer" ) CallManager.shared.endCall() } XCTAssertEqual(CallManager.shared.uiState.phase, .idle) XCTAssertFalse(CallManager.shared.isFinishingCall) } func testRapidIncomingDeclineDoesNotCrash() { for _ in 0..<10 { simulateIncomingCall() CallManager.shared.declineIncomingCall() } XCTAssertEqual(CallManager.shared.uiState.phase, .idle) XCTAssertFalse(CallManager.shared.isFinishingCall) } func testRapidAcceptDeclineCycle() { // Incoming → Accept → End → Incoming → Decline → repeat for i in 0..<5 { simulateIncomingCall() if i % 2 == 0 { _ = CallManager.shared.acceptIncomingCall() CallManager.shared.endCall() } else { CallManager.shared.declineIncomingCall() } } XCTAssertEqual(CallManager.shared.uiState.phase, .idle) XCTAssertFalse(CallManager.shared.isFinishingCall) } }