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: - AppDelegate → App Group flag (READ push writes nse_recently_read_dialogs) @Test("handleReadPush stores recently-read flag in App Group for NSE") func readPushStoresRecentlyReadFlagInAppGroup() { let shared = UserDefaults(suiteName: "group.com.rosetta.dev") let key = "nse_recently_read_dialogs" let originalData = shared?.dictionary(forKey: key) // Simulate what handleReadPush now does: write the recently-read flag. let dialogKey = "02test_desktop_read_flag" let now = Date().timeIntervalSince1970 var recentlyRead = shared?.dictionary(forKey: key) as? [String: Double] ?? [:] recentlyRead[dialogKey] = now recentlyRead = recentlyRead.filter { now - $0.value < 60 } shared?.set(recentlyRead, forKey: key) // Verify flag exists and is recent. let stored = shared?.dictionary(forKey: key) as? [String: Double] ?? [:] #expect(stored[dialogKey] != nil) if let ts = stored[dialogKey] { #expect(abs(ts - now) < 2) } // Verify NSE suppression logic would fire for this dialog. if let lastReadTime = stored[dialogKey] { let elapsed = now - lastReadTime #expect(elapsed < Self.recentlyReadWindow) } // Cleanup. shared?.set(originalData, forKey: key) } } // 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 returns banner+sound for non-suppressed chats") func systemPresentationShowsBanner() { clearState() let options = AppDelegate.foregroundPresentationOptions(for: ["dialog": "02any_user"]) #expect(options == [.banner, .sound]) } } // 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") } }