diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index d3acac4..aa03ce4 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -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 = ""; }; F0A1B2C3D4E5F60718293A42 /* PushNotificationPacketTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushNotificationPacketTests.swift; sourceTree = ""; }; F0A1B2C3D4E5F60718293A43 /* ForegroundNotificationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ForegroundNotificationTests.swift; sourceTree = ""; }; + F0A1B2C3D4E5F60718293A44 /* PushNotificationAuditTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushNotificationAuditTests.swift; sourceTree = ""; }; 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 = ""; }; LA000000F2F8D22220092AD05 /* CallActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallActivityAttributes.swift; sourceTree = ""; }; @@ -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 */, diff --git a/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist b/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist index 414c6b6..1fb243c 100644 --- a/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,12 +12,12 @@ RosettaLiveActivityWidget.xcscheme_^#shared#^_ orderHint - 2 + 1 RosettaNotificationService.xcscheme_^#shared#^_ orderHint - 1 + 2 SuppressBuildableAutocreation diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 45af35a..1ef264f 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -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: { diff --git a/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift b/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift index df3bb52..1177979 100644 --- a/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift +++ b/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift @@ -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) - .clipped() - .offset(x: sourceFrame.minX, y: sourceFrame.minY) - .offset(dragOffset) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } else { - content - .offset(dragOffset) - } + content + .frame( + width: heroActive ? sourceFrame.width : fullSize.width, + height: heroActive ? sourceFrame.height : fullSize.height + ) + .clipped() + .offset( + x: heroActive ? sourceFrame.minX : 0, + y: heroActive ? sourceFrame.minY : 0 + ) + .offset(dragOffset) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } diff --git a/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift b/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift index 90bc924..52562c1 100644 --- a/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift +++ b/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift @@ -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)) { diff --git a/RosettaTests/PushNotificationAuditTests.swift b/RosettaTests/PushNotificationAuditTests.swift new file mode 100644 index 0000000..dd343db --- /dev/null +++ b/RosettaTests/PushNotificationAuditTests.swift @@ -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") + } +}