Пуш-нотификации, кросс-платформенный аудит + 65 новых тестов (badge, dedup, Desktop-suppression, payload parity)

This commit is contained in:
2026-04-07 18:38:35 +05:00
parent ff8eca710d
commit d84c867bd3
6 changed files with 906 additions and 16 deletions

View File

@@ -21,6 +21,7 @@
F0B1C2D3E4F5061728394A41 /* PendingChatRouteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A41 /* PendingChatRouteTests.swift */; };
F0B1C2D3E4F5061728394A42 /* PushNotificationPacketTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A42 /* PushNotificationPacketTests.swift */; };
F0B1C2D3E4F5061728394A43 /* ForegroundNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A43 /* ForegroundNotificationTests.swift */; };
F0B1C2D3E4F5061728394A44 /* PushNotificationAuditTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A44 /* PushNotificationAuditTests.swift */; };
CC5AD9236E3B3BA95A0C29EC /* DBTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D3AF08B754B66DE17AF486D /* DBTestSupport.swift */; };
D0BD72A9646880B604F1AC3C /* RosettaNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D60B2E657D691F256B5B7FD4 /* SchemaParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4769EEC8ABADB3AA98D3A5 /* SchemaParityTests.swift */; };
@@ -116,6 +117,7 @@
F0A1B2C3D4E5F60718293A41 /* PendingChatRouteTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PendingChatRouteTests.swift; sourceTree = "<group>"; };
F0A1B2C3D4E5F60718293A42 /* PushNotificationPacketTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushNotificationPacketTests.swift; sourceTree = "<group>"; };
F0A1B2C3D4E5F60718293A43 /* ForegroundNotificationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ForegroundNotificationTests.swift; sourceTree = "<group>"; };
F0A1B2C3D4E5F60718293A44 /* PushNotificationAuditTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushNotificationAuditTests.swift; sourceTree = "<group>"; };
LA00000022F8D22220092AD05 /* RosettaLiveActivityWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RosettaLiveActivityWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
LA000000E2F8D22220092AD05 /* CallLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallLiveActivity.swift; sourceTree = "<group>"; };
LA000000F2F8D22220092AD05 /* CallActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallActivityAttributes.swift; sourceTree = "<group>"; };
@@ -182,6 +184,7 @@
DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.swift */,
EAF1B2C3D4E5F60718293A4B /* MessageDecodeHardeningTests.swift */,
F0A1B2C3D4E5F60718293A41 /* PendingChatRouteTests.swift */,
F0A1B2C3D4E5F60718293A44 /* PushNotificationAuditTests.swift */,
F0A1B2C3D4E5F60718293A42 /* PushNotificationPacketTests.swift */,
7F4769EEC8ABADB3AA98D3A5 /* SchemaParityTests.swift */,
2B3C4D5E6F708192A3B4C5D6 /* SearchParityTests.swift */,
@@ -436,6 +439,7 @@
EC5DFA298C697AE235323240 /* MigrationHarnessTests.swift in Sources */,
C8E2D3F45B6A78901BCDEF12 /* MessageDecodeHardeningTests.swift in Sources */,
F0B1C2D3E4F5061728394A41 /* PendingChatRouteTests.swift in Sources */,
F0B1C2D3E4F5061728394A44 /* PushNotificationAuditTests.swift in Sources */,
F0B1C2D3E4F5061728394A42 /* PushNotificationPacketTests.swift in Sources */,
D60B2E657D691F256B5B7FD4 /* SchemaParityTests.swift in Sources */,
4D5E6F708192A3B4C5D6E7F8 /* SearchParityTests.swift in Sources */,

View File

@@ -12,12 +12,12 @@
<key>RosettaLiveActivityWidget.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
<integer>1</integer>
</dict>
<key>RosettaNotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
<integer>2</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>

View File

@@ -255,6 +255,14 @@ struct ChatDetailView: View {
pendingGroupInviteTitle = parsed.title
}
}
cellActions.onGroupInviteOpen = { dialogKey in
let title = DialogRepository.shared.dialogs[dialogKey]?.opponentTitle ?? "Group"
let route = ChatRoute(groupDialogKey: dialogKey, title: title)
NotificationCenter.default.post(
name: .openChatFromNotification,
object: route
)
}
// Capture first unread incoming message BEFORE marking as read.
if firstUnreadMessageId == nil {
firstUnreadMessageId = messages.first(where: {

View File

@@ -450,9 +450,10 @@ struct ImageGalleryViewer: View {
// MARK: - GalleryPageModifier
/// Applies hero transition frame/offset ONLY for the initial page.
/// Non-hero pages have NO explicit frame they fill the TabView page naturally,
/// which fixes the "tiny image" bug caused by explicit frame fighting with TabView sizing.
/// Applies hero transition frame/offset for the initial page.
/// Uses a SINGLE view branch (no if/else) to preserve SwiftUI structural identity
/// across the hero expanded transition. This prevents GeometryReader inside
/// ZoomableImagePage from receiving stale/incorrect proposed sizes during the swap.
private struct GalleryPageModifier: ViewModifier {
let heroActive: Bool
let sourceFrame: CGRect
@@ -460,16 +461,17 @@ private struct GalleryPageModifier: ViewModifier {
let dragOffset: CGSize
func body(content: Content) -> some View {
if heroActive {
content
.frame(width: sourceFrame.width, height: sourceFrame.height)
.frame(
width: heroActive ? sourceFrame.width : fullSize.width,
height: heroActive ? sourceFrame.height : fullSize.height
)
.clipped()
.offset(x: sourceFrame.minX, y: sourceFrame.minY)
.offset(
x: heroActive ? sourceFrame.minX : 0,
y: heroActive ? sourceFrame.minY : 0
)
.offset(dragOffset)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
} else {
content
.offset(dragOffset)
}
}
}

View File

@@ -43,6 +43,7 @@ struct ZoomableImagePage: View {
.position(x: viewSize.width / 2, y: viewSize.height / 2)
}
}
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture(count: 2) {
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {

View File

@@ -0,0 +1,875 @@
import Testing
import Foundation
@testable import Rosetta
// MARK: - Push Notification Badge Management Tests
/// Tests for App Group badge count (NSE writes, AppDelegate reads/decrements).
/// Badge key: `app_badge_count` in `group.com.rosetta.dev`.
@MainActor
struct PushNotificationBadgeTests {
private static let appGroupID = "group.com.rosetta.dev"
private static let badgeKey = "app_badge_count"
private var shared: UserDefaults? { UserDefaults(suiteName: Self.appGroupID) }
private func resetBadge() {
shared?.set(0, forKey: Self.badgeKey)
}
// MARK: - Badge Increment (NSE behavior simulation)
@Test("Badge increments from 0 to 1 on first message")
func badgeIncrementFromZero() {
resetBadge()
let current = shared?.integer(forKey: Self.badgeKey) ?? 0
let newBadge = current + 1
shared?.set(newBadge, forKey: Self.badgeKey)
#expect(shared?.integer(forKey: Self.badgeKey) == 1)
}
@Test("Badge increments cumulatively for multiple messages")
func badgeIncrementMultiple() {
resetBadge()
for i in 1...5 {
let current = shared?.integer(forKey: Self.badgeKey) ?? 0
shared?.set(current + 1, forKey: Self.badgeKey)
#expect(shared?.integer(forKey: Self.badgeKey) == i)
}
}
@Test("Badge decrement on read push never goes below 0")
func badgeDecrementFloor() {
resetBadge()
shared?.set(2, forKey: Self.badgeKey)
// Simulate clearing 5 notifications (more than badge count)
let current = shared?.integer(forKey: Self.badgeKey) ?? 0
let newBadge = max(current - 5, 0)
shared?.set(newBadge, forKey: Self.badgeKey)
#expect(shared?.integer(forKey: Self.badgeKey) == 0)
}
@Test("Badge decrement from 3 by 2 cleared notifications = 1")
func badgeDecrementPartial() {
resetBadge()
shared?.set(3, forKey: Self.badgeKey)
let current = shared?.integer(forKey: Self.badgeKey) ?? 0
let newBadge = max(current - 2, 0)
shared?.set(newBadge, forKey: Self.badgeKey)
#expect(shared?.integer(forKey: Self.badgeKey) == 1)
}
@Test("Badge not incremented for muted chats")
func badgeNotIncrementedForMuted() {
resetBadge()
let mutedKeys = ["02muted_sender"]
shared?.set(mutedKeys, forKey: "muted_chats_keys")
let senderKey = "02muted_sender"
let isMuted = mutedKeys.contains(senderKey)
#expect(isMuted == true)
// NSE skips badge increment for muted badge stays 0
if !isMuted {
let current = shared?.integer(forKey: Self.badgeKey) ?? 0
shared?.set(current + 1, forKey: Self.badgeKey)
}
#expect(shared?.integer(forKey: Self.badgeKey) == 0)
// Cleanup
shared?.removeObject(forKey: "muted_chats_keys")
}
}
// MARK: - NSE Sender Dedup Window Tests
/// Tests for 10-second sender dedup window (Android parity).
/// Uses in-memory dictionaries to avoid UserDefaults parallel test interference.
struct PushNotificationSenderDedupTests {
private static let dedupWindow: TimeInterval = 10
@Test("First notification from sender is NOT a duplicate")
func firstNotificationNotDuplicate() {
let senderKey = "02first_sender"
let timestamps: [String: Double] = [:]
let isDuplicate = timestamps[senderKey].map { Date().timeIntervalSince1970 - $0 < Self.dedupWindow } ?? false
#expect(isDuplicate == false)
}
@Test("Second notification within 10s IS a duplicate")
func secondWithinWindowIsDuplicate() {
let senderKey = "02dup_sender"
let now = Date().timeIntervalSince1970
// Simulate first notification recorded
let timestamps: [String: Double] = [senderKey: now]
// Check second notification (same sender, within window)
let isDuplicate = timestamps[senderKey].map { now - $0 < Self.dedupWindow } ?? false
#expect(isDuplicate == true)
}
@Test("Notification after 10s is NOT a duplicate")
func afterWindowNotDuplicate() {
let senderKey = "02old_sender"
let now = Date().timeIntervalSince1970
// First notification 11 seconds ago
let timestamps: [String: Double] = [senderKey: now - 11]
let isDuplicate = timestamps[senderKey].map { now - $0 < Self.dedupWindow } ?? false
#expect(isDuplicate == false)
}
@Test("Different senders are independent (no cross-dedup)")
func differentSendersIndependent() {
let now = Date().timeIntervalSince1970
let timestamps: [String: Double] = ["02sender_a": now]
let isDupA = timestamps["02sender_a"].map { now - $0 < Self.dedupWindow } ?? false
let isDupB = timestamps["02sender_b"].map { now - $0 < Self.dedupWindow } ?? false
#expect(isDupA == true)
#expect(isDupB == false)
}
@Test("Empty sender key uses __no_sender__ dedup key")
func emptySenderKeyDedup() {
let now = Date().timeIntervalSince1970
let dedupKey = "__no_sender__"
let timestamps: [String: Double] = [dedupKey: now]
let isDuplicate = timestamps[dedupKey].map { now - $0 < Self.dedupWindow } ?? false
#expect(isDuplicate == true)
}
@Test("Stale entries (>120s) are evicted on write")
func staleEntriesEvicted() {
let now = Date().timeIntervalSince1970
var timestamps: [String: Double] = [
"02stale": now - 200, // > 120s should be evicted
"02recent": now - 5 // < 120s should be kept
]
// Simulate NSE eviction logic
timestamps = timestamps.filter { now - $0.value < 120 }
#expect(timestamps["02stale"] == nil)
#expect(timestamps["02recent"] != nil)
}
}
// MARK: - NSE Message ID Dedup Tests
/// Tests for messageId-based dedup (unique message delivery tracking).
/// Uses in-memory arrays to simulate NSE dedup logic.
struct PushNotificationMessageIdDedupTests {
private static let maxProcessedIds = 100
@Test("New messageId is NOT a duplicate")
func newMessageIdNotDuplicate() {
let processedIds: [String] = []
#expect(processedIds.contains("msg_new_123") == false)
}
@Test("Already-processed messageId IS a duplicate")
func processedMessageIdIsDuplicate() {
let processedIds = ["msg_abc", "msg_def"]
#expect(processedIds.contains("msg_abc") == true)
#expect(processedIds.contains("msg_def") == true)
#expect(processedIds.contains("msg_xyz") == false)
}
@Test("Processed IDs capped at 100 (oldest evicted)")
func processedIdsCapped() {
var ids = (0..<105).map { "msg_\($0)" }
// Simulate NSE eviction: keep only last 100
if ids.count > Self.maxProcessedIds {
ids = Array(ids.suffix(Self.maxProcessedIds))
}
#expect(ids.count == 100)
// Oldest 5 should be evicted
#expect(ids.contains("msg_0") == false)
#expect(ids.contains("msg_4") == false)
// Newest should remain
#expect(ids.contains("msg_104") == true)
#expect(ids.contains("msg_5") == true)
}
@Test("Duplicate messageId does NOT increment badge")
func duplicateMessageIdNoBadgeIncrement() {
let processedIds = ["msg_dup_1"]
var badgeCount = 0
let isMessageIdDuplicate = processedIds.contains("msg_dup_1")
#expect(isMessageIdDuplicate == true)
// NSE: if duplicate, badge stays unchanged
if !isMessageIdDuplicate {
badgeCount += 1
}
#expect(badgeCount == 0)
}
}
// MARK: - Desktop-Active Suppression Tests
/// Tests for 30-second Desktop-active suppression window.
/// When Desktop reads a dialog, iOS NSE suppresses message pushes for 30s.
/// Uses in-memory dictionaries to avoid parallel test interference.
struct PushNotificationDesktopSuppressionTests {
private static let recentlyReadWindow: TimeInterval = 30
@Test("No recent read — message NOT suppressed")
func noRecentReadNotSuppressed() {
let senderKey = "02alice"
let recentlyRead: [String: Double] = [:]
let shouldSuppress: Bool
if let lastReadTime = recentlyRead[senderKey] {
shouldSuppress = Date().timeIntervalSince1970 - lastReadTime < Self.recentlyReadWindow
} else {
shouldSuppress = false
}
#expect(shouldSuppress == false)
}
@Test("Desktop read 5s ago — message IS suppressed")
func recentDesktopReadSuppresses() {
let senderKey = "02alice"
let now = Date().timeIntervalSince1970
let recentlyRead: [String: Double] = [senderKey: now - 5]
let shouldSuppress: Bool
if let lastReadTime = recentlyRead[senderKey] {
shouldSuppress = now - lastReadTime < Self.recentlyReadWindow
} else {
shouldSuppress = false
}
#expect(shouldSuppress == true)
}
@Test("Desktop read 31s ago — message NOT suppressed (window expired)")
func expiredDesktopReadNotSuppressed() {
let senderKey = "02alice"
let now = Date().timeIntervalSince1970
let recentlyRead: [String: Double] = [senderKey: now - 31]
let shouldSuppress: Bool
if let lastReadTime = recentlyRead[senderKey] {
shouldSuppress = now - lastReadTime < Self.recentlyReadWindow
} else {
shouldSuppress = false
}
#expect(shouldSuppress == false)
}
@Test("Desktop read for dialog A does NOT suppress dialog B")
func suppressionPerDialog() {
let now = Date().timeIntervalSince1970
let recentlyRead: [String: Double] = ["02alice": now - 5]
let suppressAlice = recentlyRead["02alice"].map { now - $0 < Self.recentlyReadWindow } ?? false
let suppressBob = recentlyRead["02bob"].map { now - $0 < Self.recentlyReadWindow } ?? false
#expect(suppressAlice == true)
#expect(suppressBob == false)
}
@Test("Stale entries (>60s) evicted on read push")
func staleEntriesEvicted() {
let now = Date().timeIntervalSince1970
var recentlyRead: [String: Double] = [
"02stale_dialog": now - 90, // > 60s should be evicted
"02recent_dialog": now - 10 // < 60s should be kept
]
// Simulate NSE eviction (runs on each READ push)
recentlyRead = recentlyRead.filter { now - $0.value < 60 }
#expect(recentlyRead["02stale_dialog"] == nil)
#expect(recentlyRead["02recent_dialog"] != nil)
}
@Test("Read push records dialog in recently-read map")
func readPushRecordsDialog() {
let dialogKey = "02alice"
let now = Date().timeIntervalSince1970
var recentlyRead: [String: Double] = [:]
recentlyRead[dialogKey] = now
#expect(recentlyRead[dialogKey] != nil)
#expect(abs(recentlyRead[dialogKey]! - now) < 1)
}
@Test("Desktop read at exact boundary (30s) — NOT suppressed")
func exactBoundaryNotSuppressed() {
let senderKey = "02alice"
let now = Date().timeIntervalSince1970
let recentlyRead: [String: Double] = [senderKey: now - 30]
let shouldSuppress = recentlyRead[senderKey].map { now - $0 < Self.recentlyReadWindow } ?? false
#expect(shouldSuppress == false)
}
}
// MARK: - Read Push Group Key Normalization Tests
/// Server sends `dialog` field with `#group:` prefix for group reads.
/// NSE must strip the prefix before matching notifications.
@MainActor
struct PushNotificationReadGroupKeyTests {
@Test("Read push dialog with #group: prefix is stripped")
func groupPrefixStripped() {
let dialogKey = "#group:abc123"
var normalized = dialogKey
if normalized.hasPrefix("#group:") {
normalized = String(normalized.dropFirst("#group:".count))
}
#expect(normalized == "abc123")
}
@Test("Read push dialog without prefix is unchanged")
func personalDialogKeyUnchanged() {
let dialogKey = "02abc123def456"
var normalized = dialogKey
if normalized.hasPrefix("#group:") {
normalized = String(normalized.dropFirst("#group:".count))
}
#expect(normalized == "02abc123def456")
}
@Test("Empty dialog key stays empty")
func emptyDialogKeyStaysEmpty() {
let dialogKey = ""
var normalized = dialogKey
if normalized.hasPrefix("#group:") {
normalized = String(normalized.dropFirst("#group:".count))
}
#expect(normalized == "")
}
@Test("#group: prefix only → empty after strip")
func prefixOnlyBecomesEmpty() {
let dialogKey = "#group:"
var normalized = dialogKey
if normalized.hasPrefix("#group:") {
normalized = String(normalized.dropFirst("#group:".count))
}
#expect(normalized == "")
}
}
// MARK: - Cross-Platform Payload Parity Tests
/// Server sends specific payload formats for each push type.
/// iOS must correctly parse them. Validates parity with Server FCM.java.
@MainActor
struct PushNotificationPayloadParityTests {
// MARK: - Personal Message Payload
@Test("personal_message payload extracts sender from 'dialog' field")
func personalMessagePayload() {
let serverPayload: [AnyHashable: Any] = [
"type": "personal_message",
"dialog": "02abc123def456789",
"title": "Alice"
]
let senderKey = AppDelegate.extractSenderKey(from: serverPayload)
#expect(senderKey == "02abc123def456789")
}
@Test("personal_message with missing dialog falls back to sender_public_key")
func personalMessageFallback() {
let serverPayload: [AnyHashable: Any] = [
"type": "personal_message",
"sender_public_key": "02fallback_key",
"title": "Bob"
]
let senderKey = AppDelegate.extractSenderKey(from: serverPayload)
#expect(senderKey == "02fallback_key")
}
// MARK: - Group Message Payload
@Test("group_message payload has dialog = group ID (no #group: prefix)")
func groupMessagePayload() {
// Server sends group ID without #group: prefix in push payload
let serverPayload: [AnyHashable: Any] = [
"type": "group_message",
"dialog": "groupIdABC123"
]
let senderKey = AppDelegate.extractSenderKey(from: serverPayload)
#expect(senderKey == "groupIdABC123")
}
// MARK: - Read Payload
@Test("read payload with personal dialog")
func readPayloadPersonal() {
let serverPayload: [AnyHashable: Any] = [
"type": "read",
"dialog": "02opponent_key"
]
let dialogKey = serverPayload["dialog"] as? String ?? ""
#expect(dialogKey == "02opponent_key")
}
@Test("read payload with group dialog (#group: prefixed)")
func readPayloadGroup() {
let serverPayload: [AnyHashable: Any] = [
"type": "read",
"dialog": "#group:groupIdXYZ"
]
var dialogKey = serverPayload["dialog"] as? String ?? ""
if dialogKey.hasPrefix("#group:") {
dialogKey = String(dialogKey.dropFirst("#group:".count))
}
#expect(dialogKey == "groupIdXYZ")
}
// MARK: - Call Payload
@Test("call payload has callId and joinToken")
func callPayload() {
let serverPayload: [AnyHashable: Any] = [
"type": "call",
"dialog": "02caller_key",
"callId": "550e8400-e29b-41d4-a716-446655440000",
"joinToken": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
]
let callerKey = serverPayload["dialog"] as? String ?? ""
let callId = serverPayload["callId"] as? String ?? ""
let joinToken = serverPayload["joinToken"] as? String ?? ""
#expect(callerKey == "02caller_key")
#expect(!callId.isEmpty)
#expect(!joinToken.isEmpty)
}
// MARK: - Type Field Routing
@Test("Push type correctly identified from payload")
func pushTypeRouting() {
let types: [(String, String)] = [
("personal_message", "personal_message"),
("group_message", "group_message"),
("read", "read"),
("call", "call")
]
for (input, expected) in types {
let payload: [AnyHashable: Any] = ["type": input]
let pushType = payload["type"] as? String ?? ""
#expect(pushType == expected)
}
}
@Test("Missing type field defaults to empty string")
func missingTypeFieldDefault() {
let payload: [AnyHashable: Any] = ["dialog": "02abc"]
let pushType = payload["type"] as? String ?? ""
#expect(pushType == "")
}
}
// MARK: - Mute Check with Group Key Variants
/// Tests mute suppression with different group key formats.
/// Server sends group ID without #group: prefix in push `dialog` field.
/// Mute list may store with or without prefix.
@MainActor
struct PushNotificationMuteVariantsTests {
private static let appGroupID = "group.com.rosetta.dev"
private var shared: UserDefaults? { UserDefaults(suiteName: Self.appGroupID) }
private func clearMuteList() {
shared?.removeObject(forKey: "muted_chats_keys")
}
@Test("Personal chat muted by exact key match")
func personalChatMuted() {
clearMuteList()
shared?.set(["02muted_user"], forKey: "muted_chats_keys")
let senderKey = "02muted_user"
let mutedKeys = shared?.stringArray(forKey: "muted_chats_keys") ?? []
#expect(mutedKeys.contains(senderKey) == true)
clearMuteList()
}
@Test("Non-muted personal chat is NOT suppressed")
func nonMutedPersonalChat() {
clearMuteList()
shared?.set(["02other_user"], forKey: "muted_chats_keys")
let senderKey = "02not_muted"
let mutedKeys = shared?.stringArray(forKey: "muted_chats_keys") ?? []
#expect(mutedKeys.contains(senderKey) == false)
clearMuteList()
}
@Test("Group chat muted — server sends raw ID, mute list has raw ID")
func groupMutedRawId() {
clearMuteList()
shared?.set(["groupABC"], forKey: "muted_chats_keys")
// Server sends `dialog: "groupABC"` (no prefix)
let senderKey = "groupABC"
let mutedKeys = shared?.stringArray(forKey: "muted_chats_keys") ?? []
#expect(mutedKeys.contains(senderKey) == true)
clearMuteList()
}
@Test("Group chat muted with #group: prefix in mute list")
func groupMutedWithPrefix() {
clearMuteList()
// iOS stores with #group: prefix
shared?.set(["#group:groupABC", "groupABC"], forKey: "muted_chats_keys")
// Server sends raw ID
let senderKey = "groupABC"
let mutedKeys = shared?.stringArray(forKey: "muted_chats_keys") ?? []
// At least one variant should match
let isMuted = mutedKeys.contains(senderKey) || mutedKeys.contains("#group:\(senderKey)")
#expect(isMuted == true)
clearMuteList()
}
@Test("Empty mute list — nothing suppressed")
func emptyMuteList() {
clearMuteList()
let senderKey = "02any_user"
let mutedKeys = shared?.stringArray(forKey: "muted_chats_keys") ?? []
#expect(mutedKeys.contains(senderKey) == false)
}
}
// MARK: - Sender Key Extraction Extended Tests
/// Extended tests for `AppDelegate.extractSenderKey(from:)`.
/// Covers edge cases not in ForegroundNotificationTests.
@MainActor
struct PushSenderKeyExtractionExtendedTests {
@Test("Whitespace-only dialog field falls back to next key")
func whitespaceDialogFallback() {
let payload: [AnyHashable: Any] = [
"dialog": " ",
"sender_public_key": "02real_key"
]
let key = AppDelegate.extractSenderKey(from: payload)
// Should skip whitespace-only dialog and fall back
#expect(key == "02real_key")
}
@Test("All keys present — dialog wins (priority order)")
func dialogWinsPriority() {
let payload: [AnyHashable: Any] = [
"dialog": "02dialog_key",
"sender_public_key": "02sender_key",
"from_public_key": "02from_key",
"fromPublicKey": "02fromPK_key",
"public_key": "02pk_key",
"publicKey": "02PK_key"
]
let key = AppDelegate.extractSenderKey(from: payload)
#expect(key == "02dialog_key")
}
@Test("Only publicKey present (last fallback)")
func lastFallbackPublicKey() {
let payload: [AnyHashable: Any] = [
"publicKey": "02last_resort"
]
let key = AppDelegate.extractSenderKey(from: payload)
#expect(key == "02last_resort")
}
@Test("Non-string dialog value returns empty")
func nonStringDialogReturnsEmpty() {
let payload: [AnyHashable: Any] = [
"dialog": 12345 // Int, not String
]
let key = AppDelegate.extractSenderKey(from: payload)
#expect(key == "")
}
@Test("Unicode sender key preserved")
func unicodeSenderKeyPreserved() {
let payload: [AnyHashable: Any] = [
"dialog": "02абвгд_тест_🔑"
]
let key = AppDelegate.extractSenderKey(from: payload)
#expect(key == "02абвгд_тест_🔑")
}
@Test("Very long sender key handled")
func longSenderKeyHandled() {
let longKey = String(repeating: "a", count: 1000)
let payload: [AnyHashable: Any] = ["dialog": longKey]
let key = AppDelegate.extractSenderKey(from: payload)
#expect(key == longKey)
#expect(key.count == 1000)
}
}
// MARK: - In-App Banner Suppression Extended Tests
/// Extended tests for `InAppNotificationManager.shouldSuppress(senderKey:)`.
/// Covers mute + active dialog combinations.
@MainActor
struct PushInAppBannerSuppressionExtendedTests {
private static let appGroupID = "group.com.rosetta.dev"
private var shared: UserDefaults? { UserDefaults(suiteName: Self.appGroupID) }
private func clearState() {
for key in MessageRepository.shared.activeDialogKeys {
MessageRepository.shared.setDialogActive(key, isActive: false)
}
shared?.removeObject(forKey: "muted_chats_keys")
}
@Test("Both muted AND active — suppressed (double reason)")
func mutedAndActiveSuppressed() {
clearState()
MessageRepository.shared.setDialogActive("02both", isActive: true)
shared?.set(["02both"], forKey: "muted_chats_keys")
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02both") == true)
MessageRepository.shared.setDialogActive("02both", isActive: false)
shared?.removeObject(forKey: "muted_chats_keys")
}
@Test("Muted but NOT active — still suppressed")
func mutedNotActiveSuppressed() {
clearState()
shared?.set(["02muted_only"], forKey: "muted_chats_keys")
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02muted_only") == true)
shared?.removeObject(forKey: "muted_chats_keys")
}
@Test("Active but NOT muted — suppressed (active chat open)")
func activeNotMutedSuppressed() {
clearState()
MessageRepository.shared.setDialogActive("02active_only", isActive: true)
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02active_only") == true)
MessageRepository.shared.setDialogActive("02active_only", isActive: false)
}
@Test("Neither muted nor active — NOT suppressed")
func neitherMutedNorActivNotSuppressed() {
clearState()
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02normal") == false)
}
@Test("System presentation options always return empty set")
func systemPresentationAlwaysEmpty() {
clearState()
// Even for non-suppressed chats, system banner is always suppressed
// (custom in-app banner shown instead)
let options = AppDelegate.foregroundPresentationOptions(for: ["dialog": "02any_user"])
#expect(options == [])
}
}
// MARK: - NSE Thread Identifier (Notification Grouping) Tests
/// Tests notification threading (grouping by conversation).
/// NSE sets `threadIdentifier = senderKey` on notifications.
struct PushNotificationThreadingTests {
@Test("Thread identifier matches sender key for personal messages")
func threadIdPersonalMessage() {
let senderKey = "02alice_key"
let threadIdentifier = senderKey.isEmpty ? nil : senderKey
#expect(threadIdentifier == "02alice_key")
}
@Test("Thread identifier matches group ID for group messages")
func threadIdGroupMessage() {
let senderKey = "groupABC123"
let threadIdentifier = senderKey.isEmpty ? nil : senderKey
#expect(threadIdentifier == "groupABC123")
}
@Test("Empty sender key produces nil thread identifier")
func threadIdEmptySender() {
let senderKey = ""
let threadIdentifier = senderKey.isEmpty ? nil : senderKey
#expect(threadIdentifier == nil)
}
}
// MARK: - Push Notification Packet Extended Tests
/// Validates PacketPushNotification for both FCM and VoIP token types.
struct PushNotificationPacketExtendedTests {
@Test("FCM subscribe packet preserves all fields through round-trip")
func fcmSubscribeRoundTrip() throws {
var original = PacketPushNotification()
original.notificationsToken = "dGVzdF9mY21fdG9rZW5fMTIzNDU2Nzg5MA=="
original.action = .subscribe
original.tokenType = .fcm
original.deviceId = "DEVICE-UUID-1234"
let decoded = try decodePacket(original)
#expect(decoded.notificationsToken == original.notificationsToken)
#expect(decoded.action == .subscribe)
#expect(decoded.tokenType == .fcm)
#expect(decoded.deviceId == "DEVICE-UUID-1234")
}
@Test("VoIP subscribe packet preserves all fields through round-trip")
func voipSubscribeRoundTrip() throws {
var original = PacketPushNotification()
original.notificationsToken = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
original.action = .subscribe
original.tokenType = .voipApns
original.deviceId = "VOIP-DEVICE-5678"
let decoded = try decodePacket(original)
#expect(decoded.notificationsToken == original.notificationsToken)
#expect(decoded.action == .subscribe)
#expect(decoded.tokenType == .voipApns)
#expect(decoded.deviceId == "VOIP-DEVICE-5678")
}
@Test("Unsubscribe packet round-trip")
func unsubscribeRoundTrip() throws {
var original = PacketPushNotification()
original.notificationsToken = "old_token_to_remove"
original.action = .unsubscribe
original.tokenType = .fcm
original.deviceId = "DEVICE-CLEANUP"
let decoded = try decodePacket(original)
#expect(decoded.action == .unsubscribe)
#expect(decoded.notificationsToken == "old_token_to_remove")
}
@Test("Both token types have correct raw values (server parity)")
func tokenTypeRawValues() {
// Server TokenType.java: FCM(0), VoIPApns(1)
#expect(PushTokenType.fcm.rawValue == 0)
#expect(PushTokenType.voipApns.rawValue == 1)
}
@Test("Both actions have correct raw values (server parity)")
func actionRawValues() {
// Server NetworkNotificationAction.java: SUBSCRIBE(0), UNSUBSCRIBE(1)
#expect(PushNotificationAction.subscribe.rawValue == 0)
#expect(PushNotificationAction.unsubscribe.rawValue == 1)
}
// MARK: - Helper
private func decodePacket(
_ packet: PacketPushNotification
) throws -> PacketPushNotification {
let encoded = PacketRegistry.encode(packet)
guard let decoded = PacketRegistry.decode(from: encoded),
let decodedPacket = decoded.packet as? PacketPushNotification
else {
throw NSError(
domain: "PushNotificationPacketExtendedTests", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Failed to decode PacketPushNotification"]
)
}
#expect(decoded.packetId == 0x10)
return decodedPacket
}
}
// MARK: - NSE Category Assignment Tests
/// Tests notification category assignment for message routing.
struct PushNotificationCategoryTests {
@Test("Message type gets 'message' category")
func messageCategoryAssignment() {
let pushType = "personal_message"
let category: String
if pushType == "call" {
category = "call"
} else {
category = "message"
}
#expect(category == "message")
}
@Test("Call type gets 'call' category")
func callCategoryAssignment() {
let pushType = "call"
let category: String
if pushType == "call" {
category = "call"
} else {
category = "message"
}
#expect(category == "call")
}
@Test("Group message type gets 'message' category (not 'group')")
func groupMessageCategoryIsMessage() {
let pushType = "group_message"
let category: String
if pushType == "call" {
category = "call"
} else {
category = "message"
}
#expect(category == "message")
}
}
// MARK: - Contact Name Resolution Tests
/// Tests name resolution logic (NSE + AppDelegate use this pattern).
/// Uses in-memory dictionaries to simulate App Group cache.
struct PushNotificationNameResolutionTests {
@Test("Resolved name from contact_display_names cache")
func resolvedNameFromCache() {
let contactNames: [String: String] = [
"02alice": "Alice Smith",
"02bob": "Bob Jones"
]
#expect(contactNames["02alice"] == "Alice Smith")
#expect(contactNames["02bob"] == "Bob Jones")
#expect(contactNames["02unknown"] == nil)
}
@Test("Fallback to push payload title when not in cache")
func fallbackToPayloadTitle() {
let contactNames: [String: String] = [:] // Empty cache
let payload: [AnyHashable: Any] = [
"dialog": "02unknown_sender",
"title": "Server Title"
]
let senderKey = payload["dialog"] as? String ?? ""
let resolvedName = contactNames[senderKey] ?? (payload["title"] as? String)
#expect(resolvedName == "Server Title")
}
@Test("Empty cache and no title — name is nil")
func emptyEverythingNameNil() {
let contactNames: [String: String] = [:] // Empty cache
let payload: [AnyHashable: Any] = [
"dialog": "02no_name_sender"
]
let senderKey = payload["dialog"] as? String ?? ""
let resolvedName = contactNames[senderKey] ?? (payload["title"] as? String)
#expect(resolvedName == nil)
}
@Test("Cache prefers local name over server title")
func cachePreferredOverServerTitle() {
let contactNames: [String: String] = ["02alice": "Local Alice"]
let payload: [AnyHashable: Any] = [
"dialog": "02alice",
"title": "Server Alice"
]
let senderKey = payload["dialog"] as? String ?? ""
let resolvedName = contactNames[senderKey] ?? (payload["title"] as? String)
#expect(resolvedName == "Local Alice")
}
}