Files
mobile-ios/RosettaTests/PushNotificationAuditTests.swift

907 lines
32 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}