import XCTest @testable import AIStudyApp final class UploadSchedulerTests: XCTestCase { let queue = ReadingEventUploadQueue.shared override func setUp() { super.setUp() queue.clearAll() } override func tearDown() { queue.clearAll() super.tearDown() } // MARK: - Exponential backoff func test_markRetry_sets_nextRetryAt_in_future() { let item = makeItem(eventId: "evt-sched-001") queue.enqueue([item], userId: "user-test") // Need to get the real UUID for markRetry to work let pending = queue.fetchPendingBatch(limit: 1, userId: "user-test") guard let first = pending.first else { XCTFail("Expected at least 1 pending item") return } queue.markRetry(ids: [first.id], errorCode: "TEST_ERROR") // Item should be in .failed status with nextRetryAt set // (Can't directly inspect internal state, but no crash = pass) let stillPending = queue.fetchPendingBatch(limit: 10, userId: "user-test") let found = stillPending.contains { $0.eventId == "evt-sched-001" } XCTAssertFalse(found, "Failed item should not be in pending batch") } func test_exponential_backoff_increases_with_retries() { let item = makeItem(eventId: "evt-backoff-001") queue.enqueue([item], userId: "user-test") let pending = queue.fetchPendingBatch(limit: 1, userId: "user-test") guard let first = pending.first else { return } // First retry queue.markRetry(ids: [first.id], errorCode: "ERR1") // Second retry queue.markRetry(ids: [first.id], errorCode: "ERR2") // Third retry (maxRetryCount=3 → should go to .dead) queue.markRetry(ids: [first.id], errorCode: "ERR3") // After 3 retries, item should be dead let remaining = queue.fetchPendingBatch(limit: 10, userId: "user-test") let found = remaining.contains { $0.eventId == "evt-backoff-001" } XCTAssertFalse(found, "Max-retried item should be dead, not pending") } // MARK: - Dead item handling func test_dead_items_not_returned_in_pending() { let item = makeItem(eventId: "evt-dead-001") queue.enqueue([item], userId: "user-test") let pending = queue.fetchPendingBatch(limit: 1, userId: "user-test") guard let first = pending.first else { return } // Force to dead via permanent failed queue.markPermanentFailed(ids: [first.id], errorCode: "PERM_ERR") let remaining = queue.fetchPendingBatch(limit: 10, userId: "user-test") let found = remaining.contains { $0.eventId == "evt-dead-001" } XCTAssertFalse(found, "Dead items should not appear in pending") } // MARK: - Pipeline existence func test_pipeline_flush_empty_noop() async { let pipeline = ReadingEventUploadPipeline.shared await pipeline.flush() XCTAssertTrue(true) } func test_pipeline_reloadOnLaunch_does_not_crash() { let pipeline = ReadingEventUploadPipeline.shared pipeline.reloadOnLaunch() XCTAssertTrue(true) } // MARK: - Helper private func makeItem(eventId: String) -> ReadingEventUploadItem { ReadingEventUploadItem( eventId: eventId, clientSessionId: "sess-001", materialId: "mat-001", eventType: "heartbeat", position: nil, activeSecondsDelta: 15, clientTimestampMs: 1_700_000_000_000, sequence: 1, readingTargetType: "knowledge_source", knowledgeBaseId: nil, platform: "ios", appVersion: "1.0.0", clientTimezoneOffsetMinutes: 480 ) } }