Files
mobile-ios/RosettaTests/ReadEligibilityTests.swift

372 lines
16 KiB
Swift

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")
}
}