import XCTest @testable import Rosetta /// Tests for the read eligibility lifecycle fix: /// readEligibleDialogs must be cleared on background entry to prevent /// stale eligibility from marking messages as read during background sync /// or premature foreground resume. @MainActor final class ReadEligibilityTests: XCTestCase { private var ctx: DBTestContext! private let peer = "02peer_read_test" override func setUpWithError() throws { ctx = DBTestContext() } override func tearDownWithError() throws { // Clean up dialog state MessageRepository.shared.setDialogActive(peer, isActive: false) MessageRepository.shared.clearAllReadEligibility() ctx.teardown() ctx = nil } // MARK: - clearAllReadEligibility func testClearAllReadEligibility_removesAllEntries() async throws { try await ctx.bootstrap() // Set up two dialogs as active + read-eligible let peer2 = "02peer_read_test_2" MessageRepository.shared.setDialogActive(peer, isActive: true) MessageRepository.shared.setDialogActive(peer2, isActive: true) MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) MessageRepository.shared.setDialogReadEligible(peer2, isEligible: true) XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer)) XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer2)) // Clear all MessageRepository.shared.clearAllReadEligibility() XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer)) XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer2)) // Active state is preserved (only eligibility cleared) XCTAssertTrue(MessageRepository.shared.isDialogActive(peer)) XCTAssertTrue(MessageRepository.shared.isDialogActive(peer2)) // Cleanup MessageRepository.shared.setDialogActive(peer2, isActive: false) } // MARK: - Stale eligibility → messages inserted as read (the bug) func testStaleEligibility_marksIncomingAsReadOnInsert() async throws { try await ctx.bootstrap() // Simulate: user opens chat, scrolls to bottom → read-eligible MessageRepository.shared.setDialogActive(peer, isActive: true) MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) // Simulate: message arrives while eligibility is stale (e.g., background sync) try await ctx.runScenario(FixtureScenario(name: "stale read", events: [ .incoming(opponent: peer, messageId: "stale-1", timestamp: 1000, text: "hello"), ])) let snapshot = try ctx.normalizedSnapshot() // BUG: message is marked read because readEligibleDialogs had stale entry XCTAssertEqual(snapshot.messages.first?.read, true, "With stale eligibility, incoming message is incorrectly marked as read") XCTAssertEqual(snapshot.dialogs.first?.unreadCount, 0, "With stale eligibility, unread count is 0 (should be 1)") } func testClearedEligibility_keepsIncomingAsUnread() async throws { try await ctx.bootstrap() // Simulate: user opens chat, scrolls to bottom → read-eligible MessageRepository.shared.setDialogActive(peer, isActive: true) MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) // Simulate: app enters background → eligibility cleared (THE FIX) MessageRepository.shared.clearAllReadEligibility() // Simulate: message arrives during background sync try await ctx.runScenario(FixtureScenario(name: "cleared read", events: [ .incoming(opponent: peer, messageId: "cleared-1", timestamp: 2000, text: "world"), ])) let snapshot = try ctx.normalizedSnapshot() // FIXED: message stays unread because eligibility was cleared XCTAssertEqual(snapshot.messages.first?.read, false, "After clearing eligibility, incoming message must stay unread") XCTAssertEqual(snapshot.dialogs.first?.unreadCount, 1, "After clearing eligibility, unread count must be 1") } // MARK: - Re-enabling eligibility after foreground resume func testReEnableEligibility_afterClear_marksAsRead() async throws { try await ctx.bootstrap() // Simulate: user opens chat → active + eligible MessageRepository.shared.setDialogActive(peer, isActive: true) MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) // Background → clear MessageRepository.shared.clearAllReadEligibility() // Message arrives while cleared → stays unread try await ctx.runScenario(FixtureScenario(name: "pre-reenable", events: [ .incoming(opponent: peer, messageId: "re-1", timestamp: 3000, text: "hey"), ])) let snapshot1 = try ctx.normalizedSnapshot() XCTAssertEqual(snapshot1.messages.first?.read, false) XCTAssertEqual(snapshot1.dialogs.first?.unreadCount, 1) // Foreground → ChatDetailView re-enables eligibility + marks as read MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) MessageRepository.shared.markIncomingAsRead(opponentKey: peer, myPublicKey: ctx.account) DialogRepository.shared.markAsRead(opponentKey: peer) let snapshot2 = try ctx.normalizedSnapshot() XCTAssertEqual(snapshot2.messages.first?.read, true, "After re-enabling eligibility and marking, message must be read") XCTAssertEqual(snapshot2.dialogs.first?.unreadCount, 0) } // MARK: - Notification tap scenario (Chat A open, tap notification for Chat B) func testNotificationTap_differentChat_doesNotMarkOriginalAsRead() async throws { try await ctx.bootstrap() let chatA = peer let chatB = "02peer_chat_b" // User is in Chat A, scrolled to bottom MessageRepository.shared.setDialogActive(chatA, isActive: true) MessageRepository.shared.setDialogReadEligible(chatA, isEligible: true) // App goes to background → eligibility cleared MessageRepository.shared.clearAllReadEligibility() // Messages arrive for Chat A during background try await ctx.runScenario(FixtureScenario(name: "chatA bg", events: [ .incoming(opponent: chatA, messageId: "a-1", timestamp: 4000, text: "from A"), ])) // Messages arrive for Chat B during background try await ctx.runScenario(FixtureScenario(name: "chatB bg", events: [ .incoming(opponent: chatB, messageId: "b-1", timestamp: 4001, text: "from B"), ])) let snapshot = try ctx.normalizedSnapshot() // Chat A messages must be unread (user didn't view them) let chatAMsg = snapshot.messages.first(where: { $0.messageId == "a-1" }) XCTAssertEqual(chatAMsg?.read, false, "Chat A message must be unread after background") let chatADialog = snapshot.dialogs.first(where: { $0.opponentKey == chatA }) XCTAssertEqual(chatADialog?.unreadCount, 1, "Chat A must have 1 unread") // Chat B messages must also be unread let chatBMsg = snapshot.messages.first(where: { $0.messageId == "b-1" }) XCTAssertEqual(chatBMsg?.read, false, "Chat B message must be unread") let chatBDialog = snapshot.dialogs.first(where: { $0.opponentKey == chatB }) XCTAssertEqual(chatBDialog?.unreadCount, 1, "Chat B must have 1 unread") // Now simulate: user taps notification for Chat B → Chat B becomes active + eligible MessageRepository.shared.setDialogActive(chatA, isActive: false) MessageRepository.shared.setDialogActive(chatB, isActive: true) MessageRepository.shared.setDialogReadEligible(chatB, isEligible: true) MessageRepository.shared.markIncomingAsRead(opponentKey: chatB, myPublicKey: ctx.account) DialogRepository.shared.markAsRead(opponentKey: chatB) let snapshot2 = try ctx.normalizedSnapshot() // Chat A still unread let chatAMsg2 = snapshot2.messages.first(where: { $0.messageId == "a-1" }) XCTAssertEqual(chatAMsg2?.read, false, "Chat A must STILL be unread after tapping Chat B notification") let chatADialog2 = snapshot2.dialogs.first(where: { $0.opponentKey == chatA }) XCTAssertEqual(chatADialog2?.unreadCount, 1, "Chat A must STILL have 1 unread") // Chat B is now read let chatBMsg2 = snapshot2.messages.first(where: { $0.messageId == "b-1" }) XCTAssertEqual(chatBMsg2?.read, true, "Chat B message must be read after opening") let chatBDialog2 = snapshot2.dialogs.first(where: { $0.opponentKey == chatB }) XCTAssertEqual(chatBDialog2?.unreadCount, 0, "Chat B must have 0 unread after opening") // Cleanup MessageRepository.shared.setDialogActive(chatB, isActive: false) } // MARK: - setDialogActive(false) also clears eligibility func testSetDialogActiveOff_clearsEligibility() async throws { try await ctx.bootstrap() MessageRepository.shared.setDialogActive(peer, isActive: true) MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer)) MessageRepository.shared.setDialogActive(peer, isActive: false) XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer)) XCTAssertFalse(MessageRepository.shared.isDialogActive(peer)) } // MARK: - Eligibility requires active dialog func testSetDialogReadEligible_requiresActiveDialog() async throws { try await ctx.bootstrap() // Try to set eligible without active → should be ignored MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer), "Eligibility must not be set for inactive dialogs") // Now activate and set eligible MessageRepository.shared.setDialogActive(peer, isActive: true) MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer)) } // MARK: - Multiple messages during background func testMultipleMessagesWhileBackgrounded_allStayUnread() async throws { try await ctx.bootstrap() // User in chat, at bottom MessageRepository.shared.setDialogActive(peer, isActive: true) MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) // Background → clear MessageRepository.shared.clearAllReadEligibility() // Multiple messages arrive during background try await ctx.runScenario(FixtureScenario(name: "batch bg", events: [ .incoming(opponent: peer, messageId: "batch-1", timestamp: 5000, text: "msg 1"), .incoming(opponent: peer, messageId: "batch-2", timestamp: 5001, text: "msg 2"), .incoming(opponent: peer, messageId: "batch-3", timestamp: 5002, text: "msg 3"), ])) let snapshot = try ctx.normalizedSnapshot() let messages = snapshot.messages.filter { $0.messageId.hasPrefix("batch-") } XCTAssertEqual(messages.count, 3) for msg in messages { XCTAssertEqual(msg.read, false, "Message \(msg.messageId) must be unread") } let dialog = snapshot.dialogs.first(where: { $0.opponentKey == peer }) XCTAssertEqual(dialog?.unreadCount, 3, "All 3 messages must be unread") } // MARK: - Return to same chat after background func testReturnToSameChat_afterReEnable_marksAllRead() async throws { try await ctx.bootstrap() MessageRepository.shared.setDialogActive(peer, isActive: true) MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) // Background → clear MessageRepository.shared.clearAllReadEligibility() // Messages during background try await ctx.runScenario(FixtureScenario(name: "return same", events: [ .incoming(opponent: peer, messageId: "ret-1", timestamp: 6000, text: "a"), .incoming(opponent: peer, messageId: "ret-2", timestamp: 6001, text: "b"), ])) // Foreground → user returns to same chat → re-enable eligibility + mark MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) MessageRepository.shared.markIncomingAsRead(opponentKey: peer, myPublicKey: ctx.account) DialogRepository.shared.markAsRead(opponentKey: peer) let snapshot = try ctx.normalizedSnapshot() let messages = snapshot.messages.filter { $0.messageId.hasPrefix("ret-") } for msg in messages { XCTAssertEqual(msg.read, true, "Message \(msg.messageId) must be read after re-enable") } let dialog = snapshot.dialogs.first(where: { $0.opponentKey == peer }) XCTAssertEqual(dialog?.unreadCount, 0) } // MARK: - clearAllReadEligibility is idempotent func testClearAllReadEligibility_idempotent() async throws { try await ctx.bootstrap() // Call on empty set — no crash MessageRepository.shared.clearAllReadEligibility() MessageRepository.shared.clearAllReadEligibility() XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer)) } // MARK: - Idle detection (Desktop/Android parity: 20s) func testIdleTimer_clearsEligibilityAfterTimeout() async throws { try await ctx.bootstrap() MessageRepository.shared.setDialogActive(peer, isActive: true) MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer)) // Simulate idle timeout (call clearAllReadEligibility directly, // since we can't wait 20s in a unit test) SessionManager.shared.resetIdleTimer() XCTAssertFalse(SessionManager.shared.isUserIdle, "User should not be idle right after reset") // Simulate what the idle timer callback does MessageRepository.shared.clearAllReadEligibility() XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer), "After idle timeout, eligibility must be cleared") // Messages arriving during idle should be unread try await ctx.runScenario(FixtureScenario(name: "idle msg", events: [ .incoming(opponent: peer, messageId: "idle-1", timestamp: 7000, text: "idle msg"), ])) let snapshot = try ctx.normalizedSnapshot() let msg = snapshot.messages.first(where: { $0.messageId == "idle-1" }) XCTAssertEqual(msg?.read, false, "Message during idle must be unread") let dialog = snapshot.dialogs.first(where: { $0.opponentKey == peer }) XCTAssertEqual(dialog?.unreadCount, 1, "Unread count must be 1 during idle") // Cleanup SessionManager.shared.stopIdleTimer() } func testIdleTimer_resetRestoresEligibility() async throws { try await ctx.bootstrap() MessageRepository.shared.setDialogActive(peer, isActive: true) MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) // Simulate idle → clear eligibility MessageRepository.shared.clearAllReadEligibility() XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer)) // Simulate user interaction → reset timer + re-enable eligibility SessionManager.shared.resetIdleTimer() MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer), "After user interaction, eligibility must be restored") // Cleanup SessionManager.shared.stopIdleTimer() } func testStopIdleTimer_resetsIdleState() async throws { try await ctx.bootstrap() SessionManager.shared.resetIdleTimer() XCTAssertFalse(SessionManager.shared.isUserIdle) SessionManager.shared.stopIdleTimer() XCTAssertFalse(SessionManager.shared.isUserIdle, "stopIdleTimer must clear idle state") } }