Files
mobile-ios/RosettaTests/CallRaceConditionTests.swift

343 lines
12 KiB
Swift

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)
}
}