Push-уведомления: Telegram-parity in-app баннер, threadIdentifier группировка и letter-avatar в NSE
This commit is contained in:
342
RosettaTests/CallRaceConditionTests.swift
Normal file
342
RosettaTests/CallRaceConditionTests.swift
Normal file
@@ -0,0 +1,342 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
158
RosettaTests/ForegroundNotificationTests.swift
Normal file
158
RosettaTests/ForegroundNotificationTests.swift
Normal file
@@ -0,0 +1,158 @@
|
||||
import Testing
|
||||
import UserNotifications
|
||||
@testable import Rosetta
|
||||
|
||||
// MARK: - Foreground Notification Suppression Tests
|
||||
|
||||
/// Tests for the in-app notification banner suppression logic (Telegram parity).
|
||||
/// System banners are always suppressed (`willPresent` returns `[]`).
|
||||
/// `InAppNotificationManager.shouldSuppress()` decides whether the
|
||||
/// custom in-app banner should be shown or hidden.
|
||||
struct ForegroundNotificationTests {
|
||||
|
||||
private func clearActiveDialogs() {
|
||||
for key in MessageRepository.shared.activeDialogKeys {
|
||||
MessageRepository.shared.setDialogActive(key, isActive: false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - System Banner Always Suppressed
|
||||
|
||||
@Test("System banner always suppressed — foregroundPresentationOptions returns []")
|
||||
func systemBannerAlwaysSuppressed() {
|
||||
clearActiveDialogs()
|
||||
let userInfo: [AnyHashable: Any] = ["dialog": "02aaa", "title": "Alice"]
|
||||
let options = AppDelegate.foregroundPresentationOptions(for: userInfo)
|
||||
#expect(options == [])
|
||||
}
|
||||
|
||||
@Test("System banner suppressed even for inactive chats")
|
||||
func systemBannerSuppressedInactive() {
|
||||
clearActiveDialogs()
|
||||
let userInfo: [AnyHashable: Any] = ["dialog": "02bbb"]
|
||||
#expect(AppDelegate.foregroundPresentationOptions(for: userInfo) == [])
|
||||
}
|
||||
|
||||
// MARK: - In-App Banner: Active Chat → Suppress
|
||||
|
||||
@Test("Active chat is suppressed by shouldSuppress")
|
||||
func activeChatSuppressed() {
|
||||
clearActiveDialogs()
|
||||
MessageRepository.shared.setDialogActive("02aaa", isActive: true)
|
||||
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02aaa") == true)
|
||||
|
||||
MessageRepository.shared.setDialogActive("02aaa", isActive: false)
|
||||
}
|
||||
|
||||
@Test("Inactive chat is NOT suppressed")
|
||||
func inactiveChatNotSuppressed() {
|
||||
clearActiveDialogs()
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02bbb") == false)
|
||||
}
|
||||
|
||||
@Test("Only active chat suppressed, other chats shown")
|
||||
func onlyActiveSuppressed() {
|
||||
clearActiveDialogs()
|
||||
MessageRepository.shared.setDialogActive("02aaa", isActive: true)
|
||||
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02aaa") == true)
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02bbb") == false)
|
||||
|
||||
MessageRepository.shared.setDialogActive("02aaa", isActive: false)
|
||||
}
|
||||
|
||||
// MARK: - Dialog Deactivation
|
||||
|
||||
@Test("After closing chat, notifications from it are no longer suppressed")
|
||||
func deactivatedChatNotSuppressed() {
|
||||
clearActiveDialogs()
|
||||
MessageRepository.shared.setDialogActive("02aaa", isActive: true)
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02aaa") == true)
|
||||
|
||||
MessageRepository.shared.setDialogActive("02aaa", isActive: false)
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02aaa") == false)
|
||||
}
|
||||
|
||||
// MARK: - Empty / Missing Sender Key
|
||||
|
||||
@Test("Empty sender key is suppressed (can't navigate to empty chat)")
|
||||
func emptySenderKeySuppressed() {
|
||||
clearActiveDialogs()
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "") == true)
|
||||
}
|
||||
|
||||
// MARK: - Multiple Active Dialogs
|
||||
|
||||
@Test("Multiple active dialogs — each independently suppressed")
|
||||
func multipleActiveDialogs() {
|
||||
clearActiveDialogs()
|
||||
MessageRepository.shared.setDialogActive("02aaa", isActive: true)
|
||||
MessageRepository.shared.setDialogActive("02bbb", isActive: true)
|
||||
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02aaa") == true)
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02bbb") == true)
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02ccc") == false)
|
||||
|
||||
MessageRepository.shared.setDialogActive("02aaa", isActive: false)
|
||||
MessageRepository.shared.setDialogActive("02bbb", isActive: false)
|
||||
}
|
||||
|
||||
// MARK: - Muted Chat Suppression
|
||||
|
||||
@Test("Muted chat is suppressed")
|
||||
func mutedChatSuppressed() {
|
||||
clearActiveDialogs()
|
||||
// Set up muted chat in App Group
|
||||
let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
|
||||
let originalMuted = shared?.stringArray(forKey: "muted_chats_keys")
|
||||
shared?.set(["02muted"], forKey: "muted_chats_keys")
|
||||
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02muted") == true)
|
||||
|
||||
// Restore
|
||||
shared?.set(originalMuted, forKey: "muted_chats_keys")
|
||||
}
|
||||
|
||||
@Test("Non-muted chat is NOT suppressed")
|
||||
func nonMutedChatNotSuppressed() {
|
||||
clearActiveDialogs()
|
||||
let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
|
||||
let originalMuted = shared?.stringArray(forKey: "muted_chats_keys")
|
||||
shared?.set(["02other"], forKey: "muted_chats_keys")
|
||||
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02notmuted") == false)
|
||||
|
||||
shared?.set(originalMuted, forKey: "muted_chats_keys")
|
||||
}
|
||||
|
||||
// MARK: - Sender Key Extraction (AppDelegate)
|
||||
|
||||
@Test("extractSenderKey reads 'dialog' field first")
|
||||
func extractSenderKeyDialog() {
|
||||
let userInfo: [AnyHashable: Any] = ["dialog": "02aaa", "sender_public_key": "02bbb"]
|
||||
let key = AppDelegate.extractSenderKey(from: userInfo)
|
||||
#expect(key == "02aaa")
|
||||
}
|
||||
|
||||
@Test("extractSenderKey falls back to 'sender_public_key'")
|
||||
func extractSenderKeyFallback() {
|
||||
let userInfo: [AnyHashable: Any] = ["sender_public_key": "02ccc"]
|
||||
let key = AppDelegate.extractSenderKey(from: userInfo)
|
||||
#expect(key == "02ccc")
|
||||
}
|
||||
|
||||
@Test("extractSenderKey falls back to 'fromPublicKey'")
|
||||
func extractSenderKeyFromPublicKey() {
|
||||
let userInfo: [AnyHashable: Any] = ["fromPublicKey": "02ddd"]
|
||||
let key = AppDelegate.extractSenderKey(from: userInfo)
|
||||
#expect(key == "02ddd")
|
||||
}
|
||||
|
||||
@Test("extractSenderKey returns empty for missing keys")
|
||||
func extractSenderKeyEmpty() {
|
||||
let userInfo: [AnyHashable: Any] = ["type": "personal_message"]
|
||||
let key = AppDelegate.extractSenderKey(from: userInfo)
|
||||
#expect(key == "")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user