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