import XCTest @testable import Rosetta /// Tests for MessageRepository sliding window: soft cache limits, bidirectional pagination, /// and batch re-decryption. Ensures cache doesn't grow unbounded during scroll and that /// pagination trim removes messages from the correct edge. @MainActor final class SlidingWindowTests: XCTestCase { private var ctx: DBTestContext! private let opponent = "02peer_sliding_window_test" override func setUpWithError() throws { ctx = DBTestContext() } override func tearDownWithError() throws { ctx.teardown() ctx = nil } // MARK: - Helpers /// Inserts `count` incoming messages with sequential timestamps into DB + cache. private func insertMessages(count: Int, startTimestamp: Int64 = 1000) async throws { try await ctx.bootstrap() var events: [FixtureEvent] = [] for i in 0.. 3 * 200 = 600 messages in cache let totalMessages = 700 try await insertMessages(count: totalMessages) // Reset and reload latest 200 MessageRepository.shared.reloadLatest(for: opponent) let initial = MessageRepository.shared.messages(for: opponent) XCTAssertEqual(initial.count, MessageRepository.maxCacheSize) // Paginate up multiple times to grow cache beyond 3× maxCacheSize var loadCount = 0 while loadCount < 15 { guard let earliest = MessageRepository.shared.messages(for: opponent).first else { break } let older = MessageRepository.shared.loadOlderMessages( for: opponent, beforeTimestamp: earliest.timestamp, beforeMessageId: earliest.id, limit: MessageRepository.pageSize ) if older.isEmpty { break } loadCount += 1 } let finalCache = MessageRepository.shared.messages(for: opponent) let hardLimit = MessageRepository.maxCacheSize * 3 XCTAssertLessThanOrEqual(finalCache.count, hardLimit, "Cache must not exceed 3× maxCacheSize (\(hardLimit)), got \(finalCache.count)") } /// loadOlderMessages does NOT trim when cache is within 3× maxCacheSize. func testLoadOlderDoesNotTrimBelowThreshold() async throws { try await insertMessages(count: 300) // Load initial 200 MessageRepository.shared.reloadLatest(for: opponent) // One pagination = +50, total ~250 (below 600 threshold) guard let earliest = MessageRepository.shared.messages(for: opponent).first else { XCTFail("No messages") return } let older = MessageRepository.shared.loadOlderMessages( for: opponent, beforeTimestamp: earliest.timestamp, beforeMessageId: earliest.id, limit: MessageRepository.pageSize ) let cached = MessageRepository.shared.messages(for: opponent) // 200 + 50 = 250 (no dedup issues since IDs are unique) // Some may be deduped, but should be > 200 XCTAssertGreaterThan(cached.count, MessageRepository.maxCacheSize, "Cache should grow beyond maxCacheSize during pagination") XCTAssertLessThanOrEqual(cached.count, MessageRepository.maxCacheSize * 3, "Cache should stay below soft limit") } // MARK: - loadNewerMessages Soft Trim /// When cache exceeds 3× maxCacheSize via loadNewerMessages, /// oldest messages are trimmed to 2× maxCacheSize. func testLoadNewerTrimsCacheWhenExceeding3xMax() async throws { let totalMessages = 700 try await insertMessages(count: totalMessages) // Load oldest 200 (simulate user scrolled all the way up) // We'll manually load from the beginning MessageRepository.shared.reloadLatest(for: opponent) // Paginate up to load oldest messages first for _ in 0..<12 { guard let earliest = MessageRepository.shared.messages(for: opponent).first else { break } let older = MessageRepository.shared.loadOlderMessages( for: opponent, beforeTimestamp: earliest.timestamp, beforeMessageId: earliest.id, limit: MessageRepository.pageSize ) if older.isEmpty { break } } // Now paginate DOWN (loadNewerMessages) to grow cache from the other direction for _ in 0..<15 { guard let latest = MessageRepository.shared.messages(for: opponent).last else { break } let newer = MessageRepository.shared.loadNewerMessages( for: opponent, afterTimestamp: latest.timestamp, afterMessageId: latest.id, limit: MessageRepository.pageSize ) if newer.isEmpty { break } } let finalCache = MessageRepository.shared.messages(for: opponent) let hardLimit = MessageRepository.maxCacheSize * 3 XCTAssertLessThanOrEqual(finalCache.count, hardLimit, "Cache must not exceed 3× maxCacheSize (\(hardLimit)) after bidirectional pagination, got \(finalCache.count)") } // MARK: - reloadLatest Resets Cache /// reloadLatest must reset cache to exactly maxCacheSize. func testReloadLatestResetsCacheSize() async throws { try await insertMessages(count: 300) // reloadLatest loads maxCacheSize (200) messages MessageRepository.shared.reloadLatest(for: opponent) let initial = MessageRepository.shared.messages(for: opponent) XCTAssertEqual(initial.count, MessageRepository.maxCacheSize, "reloadLatest must load exactly maxCacheSize messages") // Paginate up to grow cache beyond maxCacheSize for _ in 0..<3 { guard let earliest = MessageRepository.shared.messages(for: opponent).first else { break } let _ = MessageRepository.shared.loadOlderMessages( for: opponent, beforeTimestamp: earliest.timestamp, beforeMessageId: earliest.id, limit: MessageRepository.pageSize ) } let beforeReload = MessageRepository.shared.messages(for: opponent).count XCTAssertGreaterThan(beforeReload, MessageRepository.maxCacheSize, "Cache should have grown via pagination, got \(beforeReload)") // Jump to bottom = reloadLatest resets cache MessageRepository.shared.reloadLatest(for: opponent) let afterReload = MessageRepository.shared.messages(for: opponent).count XCTAssertEqual(afterReload, MessageRepository.maxCacheSize, "reloadLatest must reset cache to maxCacheSize (\(MessageRepository.maxCacheSize)), got \(afterReload)") } // MARK: - Message Order Preserved /// Messages must stay sorted by timestamp ASC after pagination trim. func testMessageOrderPreservedAfterTrim() async throws { try await insertMessages(count: 700) MessageRepository.shared.reloadLatest(for: opponent) // Paginate up to trigger trim for _ in 0..<15 { guard let earliest = MessageRepository.shared.messages(for: opponent).first else { break } let older = MessageRepository.shared.loadOlderMessages( for: opponent, beforeTimestamp: earliest.timestamp, beforeMessageId: earliest.id, limit: MessageRepository.pageSize ) if older.isEmpty { break } } let cached = MessageRepository.shared.messages(for: opponent) // Verify ascending timestamp order for i in 1..