316 lines
13 KiB
Swift
316 lines
13 KiB
Swift
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")
|
||
}
|
||
}
|