feat(ios): implement offline mode & sync conflict resolution (#23)
- Add OfflineSyncCoordinator for managing offline/online transitions - Add OfflineSyncIndicatorView for UI feedback during sync - Add SyncProgress tracking with stage descriptions and progress bars - Add delta sync support with savings tracking - Add BackgroundTaskScheduler interval configs for low-power mode - Add isProcessingTask discriminator to BackgroundTaskID - Add DeltaFetchResult generic type for efficient data fetching - Add SyncProgressStage enum with localized descriptions - Add progress reset on app launch to prevent stale state - Add delta sync savings percentage calculation - Update BackgroundSyncTests with comprehensive coverage - Add OfflineSyncTests for offline queue and conflict resolution - Mark task 22 (Token Refresh) and task 28 (Review Compliance) as done - Update Xcode project with new source files and build phases
This commit is contained in:
@@ -2,6 +2,7 @@ import Testing
|
||||
@testable import Kordant
|
||||
import BackgroundTasks
|
||||
import SwiftUI
|
||||
import Network
|
||||
|
||||
// MARK: - SyncStatus Tests
|
||||
|
||||
@@ -17,6 +18,8 @@ struct SyncStatusTests {
|
||||
#expect(status.deltaSyncSavings == 0)
|
||||
#expect(status.isLowPowerMode == false)
|
||||
#expect(status.isOffline == false)
|
||||
#expect(status.syncProgress == 0.0)
|
||||
#expect(status.syncStageDescription == "")
|
||||
}
|
||||
|
||||
@Test("SyncStatus lastSyncDescription shows Never when no sync")
|
||||
@@ -66,6 +69,18 @@ struct SyncStatusTests {
|
||||
status2.currentSyncState = .completed
|
||||
#expect(status1 == status2)
|
||||
}
|
||||
|
||||
@Test("SyncStatus progress fields do not affect equality")
|
||||
func equalityIgnoresProgress() {
|
||||
var status1 = SyncStatus()
|
||||
var status2 = SyncStatus()
|
||||
status1.syncProgress = 0.5
|
||||
status1.syncStageDescription = "Fetching..."
|
||||
status2.syncProgress = 0.8
|
||||
status2.syncStageDescription = "Saving..."
|
||||
// Progress fields are transient and should not affect equality
|
||||
#expect(status1 == status2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SyncStatusManager Tests
|
||||
@@ -76,6 +91,8 @@ struct SyncStatusManagerTests {
|
||||
func initialState() {
|
||||
let manager = SyncStatusManager(defaults: UserDefaults(suiteName: UUID().uuidString)!)
|
||||
#expect(manager.status.currentSyncState == .idle)
|
||||
#expect(manager.status.syncProgress == 0.0)
|
||||
#expect(manager.status.syncStageDescription == "")
|
||||
}
|
||||
|
||||
@Test("SyncStatusManager startSync updates state")
|
||||
@@ -86,6 +103,7 @@ struct SyncStatusManagerTests {
|
||||
#expect(manager.status.lastSyncAttempt != nil)
|
||||
#expect(manager.status.lastSyncOperation == .appRefresh)
|
||||
#expect(manager.status.syncError == nil)
|
||||
#expect(manager.status.syncProgress == 0.0)
|
||||
}
|
||||
|
||||
@Test("SyncStatusManager completeSync updates state")
|
||||
@@ -98,6 +116,7 @@ struct SyncStatusManagerTests {
|
||||
#expect(manager.status.syncError == nil)
|
||||
#expect(manager.status.totalBytesTransferred == 1024)
|
||||
#expect(manager.status.deltaSyncSavings == 512)
|
||||
#expect(manager.status.syncProgress == 1.0)
|
||||
}
|
||||
|
||||
@Test("SyncStatusManager failSync updates state")
|
||||
@@ -107,6 +126,7 @@ struct SyncStatusManagerTests {
|
||||
manager.failSync(with: "Network error")
|
||||
#expect(manager.status.currentSyncState == .failed)
|
||||
#expect(manager.status.syncError == "Network error")
|
||||
#expect(manager.status.syncProgress == 0.0)
|
||||
}
|
||||
|
||||
@Test("SyncStatusManager setOffline updates state")
|
||||
@@ -152,6 +172,28 @@ struct SyncStatusManagerTests {
|
||||
// Should reset to idle
|
||||
#expect(manager2.status.currentSyncState == .idle)
|
||||
}
|
||||
|
||||
@Test("SyncStatusManager resets progress on launch")
|
||||
func resetProgressOnLaunch() {
|
||||
let defaults = UserDefaults(suiteName: UUID().uuidString)!
|
||||
let manager1 = SyncStatusManager(defaults: defaults)
|
||||
manager1.startSync(.appRefresh)
|
||||
manager1.updateProgress(0.5, stage: "Fetching alerts...")
|
||||
|
||||
let manager2 = SyncStatusManager(defaults: defaults)
|
||||
// Progress should be reset on launch
|
||||
#expect(manager2.status.syncProgress == 0.0)
|
||||
#expect(manager2.status.syncStageDescription == "")
|
||||
}
|
||||
|
||||
@Test("SyncStatusManager updateProgress updates progress fields")
|
||||
func updateProgress() {
|
||||
let manager = SyncStatusManager(defaults: UserDefaults(suiteName: UUID().uuidString)!)
|
||||
manager.startSync(.appRefresh)
|
||||
manager.updateProgress(0.3, stage: "Fetching alerts...")
|
||||
#expect(manager.status.syncProgress == 0.3)
|
||||
#expect(manager.status.syncStageDescription == "Fetching alerts...")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BackgroundTaskID Tests
|
||||
@@ -163,6 +205,13 @@ struct BackgroundTaskIDTests {
|
||||
#expect(BackgroundTaskID.darkWebScan.rawValue == "com.frenocorp.kordant.darkWebScan")
|
||||
#expect(BackgroundTaskID.spamDatabaseUpdate.rawValue == "com.frenocorp.kordant.spamDatabaseUpdate")
|
||||
}
|
||||
|
||||
@Test("BackgroundTaskID isProcessingTask returns correct values")
|
||||
func isProcessingTask() {
|
||||
#expect(BackgroundTaskID.appRefresh.isProcessingTask == false)
|
||||
#expect(BackgroundTaskID.darkWebScan.isProcessingTask == true)
|
||||
#expect(BackgroundTaskID.spamDatabaseUpdate.isProcessingTask == true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BackgroundSyncService Tests
|
||||
@@ -223,6 +272,75 @@ struct BackgroundSyncServiceTests {
|
||||
#expect(SyncState.failed.rawValue == "failed")
|
||||
#expect(SyncState.offline.rawValue == "offline")
|
||||
}
|
||||
|
||||
@Test("SyncProgressStage fetching has correct description")
|
||||
func progressStageFetching() {
|
||||
let progress = SyncProgress(
|
||||
operation: .appRefresh,
|
||||
stage: .fetching(label: "alerts"),
|
||||
fractionCompleted: 0.3,
|
||||
estimatedRemaining: nil
|
||||
)
|
||||
#expect(progress.description == "Fetching alerts...")
|
||||
}
|
||||
|
||||
@Test("SyncProgressStage processing has correct description")
|
||||
func progressStageProcessing() {
|
||||
let progress = SyncProgress(
|
||||
operation: .appRefresh,
|
||||
stage: .processing,
|
||||
fractionCompleted: 0.7,
|
||||
estimatedRemaining: nil
|
||||
)
|
||||
#expect(progress.description == "Processing data...")
|
||||
}
|
||||
|
||||
@Test("SyncProgressStage saving has correct description")
|
||||
func progressStageSaving() {
|
||||
let progress = SyncProgress(
|
||||
operation: .appRefresh,
|
||||
stage: .saving,
|
||||
fractionCompleted: 0.9,
|
||||
estimatedRemaining: nil
|
||||
)
|
||||
#expect(progress.description == "Saving data...")
|
||||
}
|
||||
|
||||
@Test("SyncProgressStage completed has correct description")
|
||||
func progressStageCompleted() {
|
||||
let progress = SyncProgress(
|
||||
operation: .appRefresh,
|
||||
stage: .completed,
|
||||
fractionCompleted: 1.0,
|
||||
estimatedRemaining: nil
|
||||
)
|
||||
#expect(progress.description == "Sync completed")
|
||||
}
|
||||
|
||||
@Test("SyncProgressStage failed has correct description")
|
||||
func progressStageFailed() {
|
||||
let progress = SyncProgress(
|
||||
operation: .appRefresh,
|
||||
stage: .failed(message: "Network error"),
|
||||
fractionCompleted: 0.0,
|
||||
estimatedRemaining: nil
|
||||
)
|
||||
#expect(progress.description == "Sync failed: Network error")
|
||||
}
|
||||
|
||||
@Test("DeltaFetchResult is generic and works with any type")
|
||||
func deltaFetchResultGeneric() {
|
||||
let result: DeltaFetchResult<String> = DeltaFetchResult(
|
||||
changed: true,
|
||||
data: "test",
|
||||
bytes: 1024,
|
||||
savings: 512
|
||||
)
|
||||
#expect(result.changed == true)
|
||||
#expect(result.data == "test")
|
||||
#expect(result.bytes == 1024)
|
||||
#expect(result.savings == 512)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BackgroundTaskScheduler Tests
|
||||
@@ -231,9 +349,37 @@ struct BackgroundTaskSchedulerTests {
|
||||
@Test("BackgroundTaskScheduler minimumRefreshInterval is 15 minutes")
|
||||
func minimumRefreshInterval() {
|
||||
let scheduler = BackgroundTaskScheduler()
|
||||
// We can't directly access private properties, but we can verify
|
||||
// the scheduling logic works through the public API
|
||||
#expect(scheduler.shouldDeferBackgroundTasks() == false)
|
||||
#expect(scheduler.minimumRefreshInterval == 15 * 60)
|
||||
}
|
||||
|
||||
@Test("BackgroundTaskScheduler lowPowerRefreshInterval is 30 minutes")
|
||||
func lowPowerRefreshInterval() {
|
||||
let scheduler = BackgroundTaskScheduler()
|
||||
#expect(scheduler.lowPowerRefreshInterval == 30 * 60)
|
||||
}
|
||||
|
||||
@Test("BackgroundTaskScheduler darkWebScanInterval is 6 hours")
|
||||
func darkWebScanInterval() {
|
||||
let scheduler = BackgroundTaskScheduler()
|
||||
#expect(scheduler.darkWebScanInterval == 6 * 60 * 60)
|
||||
}
|
||||
|
||||
@Test("BackgroundTaskScheduler spamUpdateInterval is 24 hours")
|
||||
func spamUpdateInterval() {
|
||||
let scheduler = BackgroundTaskScheduler()
|
||||
#expect(scheduler.spamUpdateInterval == 24 * 60 * 60)
|
||||
}
|
||||
|
||||
@Test("BackgroundTaskScheduler lowPowerDarkWebScanInterval is 12 hours")
|
||||
func lowPowerDarkWebScanInterval() {
|
||||
let scheduler = BackgroundTaskScheduler()
|
||||
#expect(scheduler.lowPowerDarkWebScanInterval == 12 * 60 * 60)
|
||||
}
|
||||
|
||||
@Test("BackgroundTaskScheduler lowPowerSpamUpdateInterval is 48 hours")
|
||||
func lowPowerSpamUpdateInterval() {
|
||||
let scheduler = BackgroundTaskScheduler()
|
||||
#expect(scheduler.lowPowerSpamUpdateInterval == 48 * 60 * 60)
|
||||
}
|
||||
|
||||
@Test("BackgroundTaskScheduler should not defer when recently synced in normal mode")
|
||||
@@ -255,6 +401,19 @@ struct BackgroundTaskSchedulerTests {
|
||||
// Should not throw
|
||||
scheduler.scheduleAllTasks()
|
||||
}
|
||||
|
||||
@Test("BackgroundTaskScheduler currentlyHasRunningTask starts as false")
|
||||
func currentlyHasRunningTask() {
|
||||
let scheduler = BackgroundTaskScheduler()
|
||||
#expect(scheduler.currentlyHasRunningTask == false)
|
||||
}
|
||||
|
||||
@Test("BackgroundTaskScheduler task counters start at zero")
|
||||
func taskCountersStartAtZero() {
|
||||
let scheduler = BackgroundTaskScheduler()
|
||||
#expect(scheduler.tasksCompleted == 0)
|
||||
#expect(scheduler.tasksExpired == 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ETagCacheEntry Tests
|
||||
@@ -306,6 +465,19 @@ struct SyncOperationCodableTests {
|
||||
let decoded = try JSONDecoder().decode(SyncOperation.self, from: data)
|
||||
#expect(decoded == .appRefresh)
|
||||
}
|
||||
|
||||
@Test("All SyncOperation values encode and decode correctly")
|
||||
func allOperationsEncodeDecode() throws {
|
||||
let operations: [SyncOperation] = [
|
||||
.appRefresh, .darkWebScan, .spamDatabaseUpdate,
|
||||
.pushNotificationSync, .manual
|
||||
]
|
||||
for op in operations {
|
||||
let data = try JSONEncoder().encode(op)
|
||||
let decoded = try JSONDecoder().decode(SyncOperation.self, from: data)
|
||||
#expect(decoded == op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SyncState Codable Tests
|
||||
@@ -318,4 +490,84 @@ struct SyncStateCodableTests {
|
||||
let decoded = try JSONDecoder().decode(SyncState.self, from: data)
|
||||
#expect(decoded == .syncing)
|
||||
}
|
||||
|
||||
@Test("All SyncState values encode and decode correctly")
|
||||
func allStatesEncodeDecode() throws {
|
||||
let states: [SyncState] = [.idle, .syncing, .completed, .failed, .offline]
|
||||
for state in states {
|
||||
let data = try JSONEncoder().encode(state)
|
||||
let decoded = try JSONDecoder().decode(SyncState.self, from: data)
|
||||
#expect(decoded == state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SyncProgress Tests
|
||||
|
||||
struct SyncProgressTests {
|
||||
@Test("SyncProgress is Equatable on stage")
|
||||
func progressStageEquatable() {
|
||||
#expect(SyncProgressStage.fetching(label: "alerts") == SyncProgressStage.fetching(label: "alerts"))
|
||||
#expect(SyncProgressStage.fetching(label: "alerts") != SyncProgressStage.fetching(label: "exposures"))
|
||||
#expect(SyncProgressStage.processing == SyncProgressStage.processing)
|
||||
#expect(SyncProgressStage.completed == SyncProgressStage.completed)
|
||||
#expect(SyncProgressStage.processing != SyncProgressStage.saving)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background Fetch Timing Tests
|
||||
|
||||
struct BackgroundFetchTimingTests {
|
||||
@Test("App refresh interval meets 15-minute minimum")
|
||||
func appRefreshMeetsMinimum() {
|
||||
let scheduler = BackgroundTaskScheduler()
|
||||
#expect(scheduler.minimumRefreshInterval >= 15 * 60)
|
||||
}
|
||||
|
||||
@Test("Low power mode doubles the refresh interval")
|
||||
func lowPowerDoublesInterval() {
|
||||
let scheduler = BackgroundTaskScheduler()
|
||||
#expect(scheduler.lowPowerRefreshInterval == scheduler.minimumRefreshInterval * 2)
|
||||
}
|
||||
|
||||
@Test("Dark web scan interval in low power mode is doubled")
|
||||
func lowPowerDarkWebDoublesInterval() {
|
||||
let scheduler = BackgroundTaskScheduler()
|
||||
#expect(scheduler.lowPowerDarkWebScanInterval == scheduler.darkWebScanInterval * 2)
|
||||
}
|
||||
|
||||
@Test("Spam update interval in low power mode is doubled")
|
||||
func lowPowerSpamDoublesInterval() {
|
||||
let scheduler = BackgroundTaskScheduler()
|
||||
#expect(scheduler.lowPowerSpamUpdateInterval == scheduler.spamUpdateInterval * 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delta Sync Savings Tests
|
||||
|
||||
struct DeltaSyncSavingsTests {
|
||||
@Test("Delta sync savings percent is 0 when no savings")
|
||||
func noSavings() {
|
||||
var status = SyncStatus()
|
||||
status.totalBytesTransferred = 1000
|
||||
status.deltaSyncSavings = 0
|
||||
#expect(status.deltaSyncSavingsPercent == 0.0)
|
||||
}
|
||||
|
||||
@Test("Delta sync savings percent is 100 when all data was cached")
|
||||
func allCached() {
|
||||
var status = SyncStatus()
|
||||
status.totalBytesTransferred = 0
|
||||
status.deltaSyncSavings = 1000
|
||||
#expect(status.deltaSyncSavingsPercent == 100.0)
|
||||
}
|
||||
|
||||
@Test("Delta sync savings percent is 75 when 75% was saved")
|
||||
func seventyFivePercentSaved() {
|
||||
var status = SyncStatus()
|
||||
status.totalBytesTransferred = 250
|
||||
status.deltaSyncSavings = 750
|
||||
// 750 / (250 + 750) * 100 = 75%
|
||||
#expect(status.deltaSyncSavingsPercent == 75.0)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user