Files
mobile-ios/RosettaTests/SlidingWindowTests.swift

316 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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..<count {
events.append(.incoming(
opponent: opponent,
messageId: "msg-\(String(format: "%04d", i))",
timestamp: startTimestamp + Int64(i),
text: "Message \(i)"
))
}
try await ctx.runScenario(FixtureScenario(name: "bulk insert", events: events))
}
// MARK: - maxCacheSize Sanity
/// Verify that maxCacheSize is 200 (Telegram parity).
func testMaxCacheSizeIs200() {
XCTAssertEqual(MessageRepository.maxCacheSize, 200)
}
/// Verify that pageSize is 50 (Android parity).
func testPageSizeIs50() {
XCTAssertEqual(MessageRepository.pageSize, 50)
}
// MARK: - Initial Load
/// Initial cache load uses pageSize (50), not maxCacheSize (200).
/// After async initial load optimization, messages(for:) returns [] on cache miss
/// and triggers background decryption. Wait for Combine emission.
func testInitialLoadUsesPageSize() async throws {
try await insertMessages(count: 100)
// Clear cache, force reload
MessageRepository.shared.reset()
try await ctx.bootstrap()
// Trigger async initial load
let _ = MessageRepository.shared.messages(for: opponent)
// Wait for background decrypt to complete and update cache
for _ in 0..<20 {
try? await Task.sleep(for: .milliseconds(50))
let cached = MessageRepository.shared.messages(for: opponent)
if !cached.isEmpty {
XCTAssertEqual(cached.count, MessageRepository.pageSize,
"Initial load must use pageSize (\(MessageRepository.pageSize)), got \(cached.count)")
return
}
}
XCTFail("Async initial load did not complete within 1 second")
}
// MARK: - loadOlderMessages Soft Trim
/// When cache exceeds 3× maxCacheSize via loadOlderMessages,
/// newest messages are trimmed to 2× maxCacheSize.
func testLoadOlderTrimsCacheWhenExceeding3xMax() async throws {
// Insert enough messages to trigger soft trim
// We need > 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..<cached.count {
XCTAssertGreaterThanOrEqual(cached[i].timestamp, cached[i - 1].timestamp,
"Messages must be sorted by timestamp ASC after trim. " +
"Index \(i): \(cached[i].timestamp) < \(cached[i - 1].timestamp)")
}
}
// MARK: - No Duplicate Messages After Pagination
/// Pagination must not create duplicate message IDs in cache.
func testNoDuplicatesAfterPagination() async throws {
try await insertMessages(count: 400)
MessageRepository.shared.reloadLatest(for: opponent)
// Paginate up
for _ in 0..<5 {
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 cached = MessageRepository.shared.messages(for: opponent)
let ids = cached.map(\.id)
let uniqueIds = Set(ids)
XCTAssertEqual(ids.count, uniqueIds.count,
"Cache must not contain duplicate message IDs. \(ids.count) total, \(uniqueIds.count) unique")
}
// MARK: - In-Memory Patch (Delivery Status)
/// Delivery status update patches cache in-memory without full refresh.
func testDeliveryStatusPatchesInMemory() async throws {
try await ctx.bootstrap()
try await ctx.runScenario(FixtureScenario(name: "patch test", events: [
.outgoing(opponent: opponent, messageId: "patch-1", timestamp: 5000, text: "hello"),
]))
let before = MessageRepository.shared.messages(for: opponent)
XCTAssertEqual(before.first?.deliveryStatus, .waiting)
// Patch delivery status
MessageRepository.shared.updateDeliveryStatus(messageId: "patch-1", status: .delivered)
let after = MessageRepository.shared.messages(for: opponent)
XCTAssertEqual(after.first?.deliveryStatus, .delivered,
"Delivery status must be patched in-memory without full cache refresh")
}
// MARK: - In-Memory Patch (Read Status)
/// markOutgoingAsRead patches cache in-memory.
func testMarkOutgoingReadPatchesInMemory() async throws {
try await ctx.bootstrap()
try await ctx.runScenario(FixtureScenario(name: "read patch", events: [
.outgoing(opponent: opponent, messageId: "read-1", timestamp: 6000, text: "hello"),
.markDelivered(opponent: opponent, messageId: "read-1"),
]))
let before = MessageRepository.shared.messages(for: opponent)
XCTAssertFalse(before.first?.isRead ?? true)
MessageRepository.shared.markOutgoingAsRead(opponentKey: opponent, myPublicKey: ctx.account)
let after = MessageRepository.shared.messages(for: opponent)
XCTAssertTrue(after.first?.isRead ?? false,
"Read status must be patched in-memory")
}
}