372 lines
16 KiB
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")
|
|
}
|
|
}
|