diff --git a/ios/EthosProtocol/Sources/Services/APIClient.swift b/ios/EthosProtocol/Sources/Services/APIClient.swift index ea04379..584c67e 100644 --- a/ios/EthosProtocol/Sources/Services/APIClient.swift +++ b/ios/EthosProtocol/Sources/Services/APIClient.swift @@ -184,6 +184,13 @@ public final class APIClient { return try await get(path: path) } + /// How long ago the cached `/vaults` response was written, or nil if nothing is cached yet. + /// Lets VaultStore surface "data as of X ago" when a `listVaults()` call was served from + /// the offline cache. Uses the same cache key `execute(_:)` saves under (the request URL). + func vaultsCacheAge() -> TimeInterval? { + OfflineCache.shared.age(for: request(path: "/vaults").url?.absoluteString ?? "") + } + func getVault(id: String) async throws -> Vault { try await get(path: "/vaults/\(id)") } diff --git a/ios/EthosProtocol/Sources/Services/BackgroundRefreshService.swift b/ios/EthosProtocol/Sources/Services/BackgroundRefreshService.swift index 64f901e..e534f0b 100644 --- a/ios/EthosProtocol/Sources/Services/BackgroundRefreshService.swift +++ b/ios/EthosProtocol/Sources/Services/BackgroundRefreshService.swift @@ -22,10 +22,18 @@ final class BackgroundRefreshService { // an account with more vaults than fit on one page. var vaultListProvider: VaultListProvider = { try await APIClient.shared.listAllVaults() } - // Tracks whether scheduleAppRefresh was called (for testing) - private(set) var scheduleAppRefreshCallCount = 0 + // Injected dependency for testing; defaults to the shared check-in retry queue. + var checkInSync: CheckInSyncService = .shared - private init() {} + // Tracks whether scheduleAppRefresh was called (for testing). `internal` (not + // `private(set)`): HandleRefreshTests resets this between runs via `@testable import`, + // which — like `private init()` — a `private` setter would block from another file. + var scheduleAppRefreshCallCount = 0 + + // `internal` (not `private`): lets tests construct a BackgroundRefreshService via + // `@testable import` instead of only exercising the shared singleton, mirroring + // APIClient's testable init. + init() {} func registerBackgroundTask() { BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.taskIdentifier, using: nil) { [weak self] task in @@ -45,10 +53,23 @@ final class BackgroundRefreshService { // Register the expiration handler before kicking off the async work so there's no // window where the system could expire the task before we're able to cancel it. + // `BackgroundRefreshTask` isn't class-constrained, so the compiler won't assign through + // the immutable `task` parameter directly even though every real conformer + // (BGAppRefreshTask, MockBackgroundRefreshTask) is a class — shadow it with a local + // `var` for this one synchronous mutation. The `Task { ... }` closure below captures + // the original `task` (never mutated) instead, so it doesn't need to capture a `var` + // across a concurrency boundary. + var mutableTask = task var refreshTask: Task? - task.expirationHandler = { refreshTask?.cancel() } + mutableTask.expirationHandler = { refreshTask?.cancel() } refreshTask = Task { + // Piggyback the offline check-in retry queue on this already-registered, + // already-scheduled BGAppRefreshTask rather than registering a second background + // task identifier — see issue #28. Best-effort: a flush failure shouldn't fail the + // TTL refresh this task also performs. + await checkInSync.flush() + do { let vaults = try await vaultListProvider() for vault in vaults where vault.status == .active { diff --git a/ios/EthosProtocol/Sources/Services/CheckInQueue.swift b/ios/EthosProtocol/Sources/Services/CheckInQueue.swift new file mode 100644 index 0000000..97ed164 --- /dev/null +++ b/ios/EthosProtocol/Sources/Services/CheckInQueue.swift @@ -0,0 +1,63 @@ +import Foundation + +/// A check-in attempted while offline, waiting to be retried once connectivity returns. +struct PendingCheckIn: Codable, Equatable { + let vaultID: String + let queuedAt: Date +} + +/// Durable, file-backed queue for check-ins attempted while offline, so a missed connectivity +/// window doesn't silently drop a dead-man's-switch check-in. Mirrors Android's +/// PendingCheckInDao (android/.../services/CheckInQueue.kt) using a flat JSON file instead of +/// Room, since this app has no other on-device database to extend. +final class CheckInQueue { + static let shared = CheckInQueue() + + private let fileURL: URL + private let queue = DispatchQueue(label: "com.ethosprotocol.checkinqueue") + + // `internal` (not `private`): lets tests construct an isolated instance pointed at a + // scratch directory via `@testable import`, instead of mutating the real `.shared` queue. + init(directory: URL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]) { + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + fileURL = directory.appendingPathComponent("pending_checkins.json") + } + + /// Vaults with a check-in currently queued for retry, oldest first. + var pending: [PendingCheckIn] { + queue.sync { readAll() } + } + + var count: Int { pending.count } + + /// Queues `vaultID` for retry, replacing any existing queued entry for the same vault + /// rather than duplicating it. + func enqueue(vaultID: String) { + queue.sync { + var all = readAll().filter { $0.vaultID != vaultID } + all.append(PendingCheckIn(vaultID: vaultID, queuedAt: Date())) + write(all) + } + } + + func remove(vaultID: String) { + queue.sync { + write(readAll().filter { $0.vaultID != vaultID }) + } + } + + func removeAll() { + queue.sync { write([]) } + } + + private func readAll() -> [PendingCheckIn] { + guard let data = try? Data(contentsOf: fileURL), + let decoded = try? JSONDecoder().decode([PendingCheckIn].self, from: data) else { return [] } + return decoded + } + + private func write(_ items: [PendingCheckIn]) { + guard let data = try? JSONEncoder().encode(items) else { return } + try? data.write(to: fileURL) + } +} diff --git a/ios/EthosProtocol/Sources/Services/CheckInSyncService.swift b/ios/EthosProtocol/Sources/Services/CheckInSyncService.swift new file mode 100644 index 0000000..c51dbd3 --- /dev/null +++ b/ios/EthosProtocol/Sources/Services/CheckInSyncService.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Retries every queued check-in. Mirrors Android's CheckInSyncWorker +/// (android/.../services/CheckInSyncWorker.kt): a check-in is a dead-man's-switch signal, so +/// only errors the server has definitively rejected as invalid (the vault no longer exists) +/// drop the queued item — everything else (offline, timeout, server error, expired auth) is +/// left queued for the next retry rather than risking a vault being released even though the +/// user did check in. +final class CheckInSyncService { + static let shared = CheckInSyncService() + + var queue: CheckInQueue = .shared + + // Injected for testing; defaults to a real check-in call. + var checkInProvider: (String) async throws -> Void = { try await APIClient.shared.checkIn(vaultID: $0) } + + // `internal` (not `private`): lets tests construct an isolated instance with its own + // queue/provider via `@testable import`, instead of mutating the real `.shared` service. + init() {} + + /// Attempts every queued check-in, dropping only the non-retryable failures, and leaves + /// the "check-in queued" notification up to date with what's left afterward. + @discardableResult + func flush() async -> Bool { + let items = queue.pending + // Nothing queued means nothing to do — in particular, skip touching + // NotificationService entirely so a routine flush with an empty queue (e.g. every + // hourly BGAppRefreshTask tick) doesn't needlessly re-post/cancel a notification. + guard !items.isEmpty else { return true } + + var allSucceeded = true + for item in items { + do { + try await checkInProvider(item.vaultID) + queue.remove(vaultID: item.vaultID) + } catch APIError.notFound { + // The server has told us unambiguously this check-in can never succeed + // (the vault no longer exists) — retrying it is pointless. + queue.remove(vaultID: item.vaultID) + } catch { + allSucceeded = false + } + } + + let remaining = queue.count + if remaining > 0 { + NotificationService.shared.showQueuedCheckIn(count: remaining) + } else { + NotificationService.shared.cancelQueuedCheckIn() + } + return allSucceeded + } +} diff --git a/ios/EthosProtocol/Sources/Services/NotificationService.swift b/ios/EthosProtocol/Sources/Services/NotificationService.swift index aa09fb8..1695a75 100644 --- a/ios/EthosProtocol/Sources/Services/NotificationService.swift +++ b/ios/EthosProtocol/Sources/Services/NotificationService.swift @@ -79,6 +79,36 @@ final class NotificationService: NSObject, UNUserNotificationCenterDelegate { } } + // MARK: - Offline Check-In Queue Indicator + + private static let queuedCheckInIdentifier = "checkin-queued" + + /// Surfaces a persistent local notification while check-ins are queued for retry, mirroring + /// Android's NotificationHelper.showQueuedCheckIn. iOS has no true "ongoing" notification + /// (UNNotificationRequest has no non-dismissable/foreground-service equivalent to Android's + /// setOngoing(true)), so this re-posts the same identifier every time the queue changes; + /// cancelQueuedCheckIn() removes it once the queue drains. VaultStore's queuedCheckInCount + /// mirrors this in-app for while the app is foregrounded. + func showQueuedCheckIn(count: Int) { + let center = UNUserNotificationCenter.current() + let content = UNMutableNotificationContent() + content.title = "Check-in queued" + content.body = count == 1 + ? "1 check-in will be submitted when back online" + : "\(count) check-ins will be submitted when back online" + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + let request = UNNotificationRequest(identifier: Self.queuedCheckInIdentifier, content: content, trigger: trigger) + center.removePendingNotificationRequests(withIdentifiers: [Self.queuedCheckInIdentifier]) + center.add(request) + } + + func cancelQueuedCheckIn() { + let center = UNUserNotificationCenter.current() + center.removePendingNotificationRequests(withIdentifiers: [Self.queuedCheckInIdentifier]) + center.removeDeliveredNotifications(withIdentifiers: [Self.queuedCheckInIdentifier]) + } + // MARK: - UNUserNotificationCenterDelegate func userNotificationCenter(_ center: UNUserNotificationCenter, diff --git a/ios/EthosProtocol/Sources/Services/OfflineSupport.swift b/ios/EthosProtocol/Sources/Services/OfflineSupport.swift index 594baef..8ee1667 100644 --- a/ios/EthosProtocol/Sources/Services/OfflineSupport.swift +++ b/ios/EthosProtocol/Sources/Services/OfflineSupport.swift @@ -1,24 +1,64 @@ import Network import Foundation +/// Abstracts NWPathMonitor so NetworkMonitor's cold-start behavior can be exercised in tests +/// without a real network stack. NWPath itself has no public initializer, so tests can't +/// construct one to fake `pathUpdateHandler` callbacks — this narrows the surface to the two +/// things NetworkMonitor actually needs down to plain, fakeable types. +protocol NetworkPathProvider { + var isCurrentlySatisfied: Bool { get } + func startMonitoring(_ handler: @escaping (Bool) -> Void) +} + +struct NWPathMonitorProvider: NetworkPathProvider { + private let monitor = NWPathMonitor() + + var isCurrentlySatisfied: Bool { monitor.currentPath.status == .satisfied } + + func startMonitoring(_ handler: @escaping (Bool) -> Void) { + monitor.pathUpdateHandler = { path in handler(path.status == .satisfied) } + monitor.start(queue: DispatchQueue(label: "NetworkMonitor")) + } +} + final class NetworkMonitor { static let shared = NetworkMonitor() - private let monitor = NWPathMonitor() - private(set) var isConnected = true - private init() { - monitor.pathUpdateHandler = { [weak self] path in - self?.isConnected = path.status == .satisfied + // `private(set)` so production code can only read this; tests observe it via a fresh + // instance constructed with a mock provider instead of mutating `.shared`. + private(set) var isConnected: Bool + + // `internal` (not `private`): lets tests construct a NetworkMonitor with a mock + // NetworkPathProvider via `@testable import`, mirroring APIClient's testable init. + init(provider: NetworkPathProvider = NWPathMonitorProvider()) { + // NWPathMonitor.currentPath reflects the system's last-known path synchronously, + // even before `start(queue:)` attaches pathUpdateHandler — reading it here instead of + // defaulting to `true` closes the cold-start window where a request made before the + // first async path update would otherwise assume connectivity and attempt (and time + // out on) a real network call while actually offline. + isConnected = provider.isCurrentlySatisfied + provider.startMonitoring { [weak self] satisfied in + self?.isConnected = satisfied } - monitor.start(queue: DispatchQueue(label: "NetworkMonitor")) } } -/// Simple disk-based cache for offline reads. +/// Simple disk-based cache for offline reads. Entries are timestamped so callers can surface +/// staleness ("as of 3 days ago") and so entries older than `maxAge` can be treated as absent. +/// Bounded to `maxBytes` total via LRU eviction (least-recently-*loaded* entry evicted first). final class OfflineCache { static let shared = OfflineCache() private let dir: URL + /// Entries older than this are treated as absent by `load(for:)`/`age(for:)`. `nil` + /// (the default) disables expiry enforcement — staleness is still tracked and can be + /// surfaced in the UI even when it isn't used to refuse serving the entry. + var maxAge: TimeInterval? + + /// Total on-disk size cap across all cached entries. Exceeding this on `save(_:for:)` + /// evicts the least-recently-loaded entries first until back under the cap. + var maxBytes: Int = 20 * 1_024 * 1_024 // 20 MB + private init() { dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] .appendingPathComponent("EthosProtocolOfflineCache", isDirectory: true) @@ -26,18 +66,81 @@ final class OfflineCache { } func save(_ data: Data, for key: String) { - let file = dir.appendingPathComponent(key.sha256Hex) + let file = dataFile(for: key) try? data.write(to: file) + try? Date().timeIntervalSince1970.description.data(using: .utf8)?.write(to: metaFile(for: key)) + enforceSizeCap() } func load(for key: String) -> Data? { - let file = dir.appendingPathComponent(key.sha256Hex) - return try? Data(contentsOf: file) + guard !isExpired(key) else { return nil } + let file = dataFile(for: key) + guard let data = try? Data(contentsOf: file) else { return nil } + // Bump the entry's mtime so it's treated as recently used for LRU eviction, without + // touching the separate `.meta` timestamp `age(for:)` reports — a cache hit shouldn't + // reset how stale the underlying data actually is. + try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: file.path) + return data } func delete(for key: String) { - let file = dir.appendingPathComponent(key.sha256Hex) - try? FileManager.default.removeItem(at: file) + try? FileManager.default.removeItem(at: dataFile(for: key)) + try? FileManager.default.removeItem(at: metaFile(for: key)) + } + + /// Timestamp the entry for `key` was cached, or nil if no entry exists. + func cachedAt(for key: String) -> Date? { + guard let raw = try? String(contentsOf: metaFile(for: key), encoding: .utf8), + let interval = TimeInterval(raw) else { return nil } + return Date(timeIntervalSince1970: interval) + } + + /// How long ago the entry for `key` was cached, or nil if no entry exists. + func age(for key: String) -> TimeInterval? { + cachedAt(for: key).map { Date().timeIntervalSince($0) } + } + + /// Removes every cached entry. Called on sign-out so a subsequent user on the same + /// device can't be served the previous user's cached vault data while offline. + func clearAll() { + try? FileManager.default.removeItem(at: dir) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } + + private func isExpired(_ key: String) -> Bool { + guard let maxAge, let cachedAt = cachedAt(for: key) else { return false } + return Date().timeIntervalSince(cachedAt) > maxAge + } + + private func dataFile(for key: String) -> URL { + dir.appendingPathComponent(key.sha256Hex) + } + + private func metaFile(for key: String) -> URL { + dataFile(for: key).appendingPathExtension("meta") + } + + private func enforceSizeCap() { + guard let contents = try? FileManager.default.contentsOfDirectory( + at: dir, includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey]) else { return } + + let entries = contents + .filter { $0.pathExtension != "meta" } + .compactMap { url -> (url: URL, size: Int, modified: Date)? in + guard let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]), + let size = values.fileSize, let modified = values.contentModificationDate else { return nil } + return (url, size, modified) + } + + var totalSize = entries.reduce(0) { $0 + $1.size } + guard totalSize > maxBytes else { return } + + for entry in entries.sorted(by: { $0.modified < $1.modified }) { + guard totalSize > maxBytes else { break } + try? FileManager.default.removeItem(at: entry.url) + try? FileManager.default.removeItem(at: entry.url.appendingPathExtension("meta")) + totalSize -= entry.size + } } } diff --git a/ios/EthosProtocol/Sources/ViewModels/Stores.swift b/ios/EthosProtocol/Sources/ViewModels/Stores.swift index e2b2a06..86861e3 100644 --- a/ios/EthosProtocol/Sources/ViewModels/Stores.swift +++ b/ios/EthosProtocol/Sources/ViewModels/Stores.swift @@ -63,6 +63,9 @@ final class AuthStore: ObservableObject { KeychainService.shared.deletePushToken() } KeychainService.shared.deleteToken() + // Ties into #10: a subsequent user signing in on the same device must never be served + // the previous user's cached vault data while offline. + OfflineCache.shared.clearAll() isAuthenticated = false } } @@ -81,6 +84,10 @@ final class VaultStore: ObservableObject { func load() async { isLoading = true; error = nil + if NetworkMonitor.shared.isConnected { + await CheckInSyncService.shared.flush() + updateQueuedIndicator() + } do { let page = try await APIClient.shared.listVaults() ifNotCancelled { @@ -90,6 +97,7 @@ final class VaultStore: ObservableObject { } } catch APIError.networkUnavailable { // Vaults already populated from offline cache via APIClient + ifNotCancelled { vaultsCacheAge = APIClient.shared.vaultsCacheAge() } } catch { ifNotCancelled { self.error = ErrorPresentation(error) } } diff --git a/ios/EthosProtocol/Sources/Views/Views.swift b/ios/EthosProtocol/Sources/Views/Views.swift index 56c0eda..0b23d98 100644 --- a/ios/EthosProtocol/Sources/Views/Views.swift +++ b/ios/EthosProtocol/Sources/Views/Views.swift @@ -244,6 +244,48 @@ struct VaultListView: View { } } } + + // Surfaces staleness (issue #25) and any check-ins still waiting to sync (issue #28) above + // the vault list, so both stay visible without blocking the list itself. + @ViewBuilder + private var statusBanners: some View { + if let age = vaultStore.vaultsCacheAge { + StatusBannerView( + text: "Offline — showing vaults from \(Self.relativeAge(age))", + systemImage: "wifi.slash", + color: .orange) + } + if vaultStore.queuedCheckInCount > 0 { + StatusBannerView( + text: vaultStore.queuedCheckInCount == 1 + ? "1 check-in queued — will retry when back online" + : "\(vaultStore.queuedCheckInCount) check-ins queued — will retry when back online", + systemImage: "clock.arrow.circlepath", + color: .blue) + } + } + + private static func relativeAge(_ interval: TimeInterval) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter.localizedString(fromTimeInterval: -interval) + } +} + +struct StatusBannerView: View { + let text: String + let systemImage: String + let color: Color + + var body: some View { + Label(text, systemImage: systemImage) + .font(.caption) + .foregroundStyle(color) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.vertical, 6) + .background(color.opacity(0.1)) + } } /// Trailing row in VaultListView's list: a "Load More" button while a further @@ -387,6 +429,11 @@ struct VaultDetailView: View { if let error = biometricError { Text(error).foregroundStyle(.red).font(.caption) } + if vaultStore.queuedCheckInCount > 0 { + Label("Check-in queued — will retry automatically when back online", systemImage: "clock.arrow.circlepath") + .font(.caption) + .foregroundStyle(.orange) + } } Section("Funds") { diff --git a/ios/EthosProtocol/Tests/APIClientTests.swift b/ios/EthosProtocol/Tests/APIClientTests.swift index 8d8ccaf..2a21b6a 100644 --- a/ios/EthosProtocol/Tests/APIClientTests.swift +++ b/ios/EthosProtocol/Tests/APIClientTests.swift @@ -58,15 +58,11 @@ final class MockURLProtocol: URLProtocol { // MARK: - APIClient Extension for Testing extension APIClient { - /// Creates a test instance of APIClient with a mocked URLSession + /// Creates a test instance of APIClient with a mocked URLSession, via the + /// `init(baseURL:session:retryPolicy:)` designated initializer APIClient.swift exposes + /// as `internal` specifically for this purpose (see the comment on that initializer). static func makeTestInstance(session: URLSession) -> APIClient { - let instance = APIClient() - // Use reflection to set the private session property for testing - let sessionKey = "session" - if instance.responds(to: NSSelectorFromString("setValue:forKey:")) { - instance.setValue(session, forKey: sessionKey) - } - return instance + APIClient(baseURL: URL(string: "https://api.ethos-protocol.app/v1")!, session: session) } } diff --git a/ios/EthosProtocol/Tests/CheckInQueueTests.swift b/ios/EthosProtocol/Tests/CheckInQueueTests.swift new file mode 100644 index 0000000..49b34eb --- /dev/null +++ b/ios/EthosProtocol/Tests/CheckInQueueTests.swift @@ -0,0 +1,163 @@ +import XCTest +@testable import EthosProtocol + +// MARK: - Issue #28: Offline Check-In Queue Tests + +final class CheckInQueueTests: XCTestCase { + + private func makeQueue() -> CheckInQueue { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + return CheckInQueue(directory: dir) + } + + func test_enqueue_addsPendingEntry() { + let queue = makeQueue() + queue.enqueue(vaultID: "vault-1") + XCTAssertEqual(queue.pending.map(\.vaultID), ["vault-1"]) + XCTAssertEqual(queue.count, 1) + } + + func test_enqueue_sameVaultTwice_doesNotDuplicate() { + let queue = makeQueue() + queue.enqueue(vaultID: "vault-1") + queue.enqueue(vaultID: "vault-1") + XCTAssertEqual(queue.count, 1) + } + + func test_remove_dropsOnlyMatchingEntry() { + let queue = makeQueue() + queue.enqueue(vaultID: "vault-1") + queue.enqueue(vaultID: "vault-2") + queue.remove(vaultID: "vault-1") + XCTAssertEqual(queue.pending.map(\.vaultID), ["vault-2"]) + } + + func test_removeAll_clearsQueue() { + let queue = makeQueue() + queue.enqueue(vaultID: "vault-1") + queue.enqueue(vaultID: "vault-2") + queue.removeAll() + XCTAssertTrue(queue.pending.isEmpty) + } + + func test_pending_isOrderedOldestFirst() { + let queue = makeQueue() + queue.enqueue(vaultID: "vault-1") + queue.enqueue(vaultID: "vault-2") + queue.enqueue(vaultID: "vault-3") + XCTAssertEqual(queue.pending.map(\.vaultID), ["vault-1", "vault-2", "vault-3"]) + } + + func test_persistsAcrossInstances_sameDirectory() { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + CheckInQueue(directory: dir).enqueue(vaultID: "vault-1") + let reloaded = CheckInQueue(directory: dir) + XCTAssertEqual(reloaded.pending.map(\.vaultID), ["vault-1"]) + } +} + +final class CheckInSyncServiceTests: XCTestCase { + + // flush() with anything queued ends by calling + // NotificationService.shared.{show,cancel}QueuedCheckIn(), and UNUserNotificationCenter + // .current() traps with "bundleProxyForCurrentProcess is nil" in this bare, hostless SPM + // test bundle in CI — same constraint as BackgroundRefreshServiceTests' notification tests. + // Only the empty-queue case (flush() returns before touching NotificationService) is safe + // to run there. + private func skipIfCI() throws { + try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] != nil, + "UNUserNotificationCenter requires a real app host process, unavailable in CI") + } + + private func makeService(queueDirectory: URL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)) -> (CheckInSyncService, CheckInQueue) { + let queue = CheckInQueue(directory: queueDirectory) + let service = CheckInSyncService() + service.queue = queue + return (service, queue) + } + + func test_flush_emptyQueue_succeedsWithoutCallingProvider() async { + let (service, _) = makeService() + var callCount = 0 + service.checkInProvider = { _ in callCount += 1 } + + let succeeded = await service.flush() + + XCTAssertTrue(succeeded) + XCTAssertEqual(callCount, 0) + } + + func test_flush_successfulRetry_removesFromQueue() async throws { + try skipIfCI() + let (service, queue) = makeService() + queue.enqueue(vaultID: "vault-1") + service.checkInProvider = { _ in } + + let succeeded = await service.flush() + + XCTAssertTrue(succeeded) + XCTAssertTrue(queue.pending.isEmpty) + } + + func test_flush_networkStillUnavailable_leavesItemQueued() async throws { + try skipIfCI() + let (service, queue) = makeService() + queue.enqueue(vaultID: "vault-1") + service.checkInProvider = { _ in throw APIError.networkUnavailable } + + let succeeded = await service.flush() + + XCTAssertFalse(succeeded) + XCTAssertEqual(queue.pending.map(\.vaultID), ["vault-1"]) + } + + func test_flush_serverError_leavesItemQueuedForRetry() async throws { + try skipIfCI() + // Mirrors Android: only a definitive rejection (vault no longer exists) drops the + // item — a transient server error must not, since dropping a dead-man's-switch + // check-in on a retryable failure risks the vault being released even though the + // user did check in. + let (service, queue) = makeService() + queue.enqueue(vaultID: "vault-1") + service.checkInProvider = { _ in throw APIError.serverError("boom") } + + let succeeded = await service.flush() + + XCTAssertFalse(succeeded) + XCTAssertEqual(queue.pending.map(\.vaultID), ["vault-1"]) + } + + func test_flush_vaultNotFound_dropsFromQueue() async throws { + try skipIfCI() + // The one non-retryable case: the server has definitively rejected the check-in + // because the vault no longer exists, so retrying it can never succeed. + let (service, queue) = makeService() + queue.enqueue(vaultID: "vault-1") + service.checkInProvider = { _ in throw APIError.notFound } + + let succeeded = await service.flush() + + XCTAssertTrue(succeeded) + XCTAssertTrue(queue.pending.isEmpty) + } + + func test_flush_mixedResults_onlyRemovesSucceededAndNonRetryable() async throws { + try skipIfCI() + let (service, queue) = makeService() + queue.enqueue(vaultID: "vault-succeeds") + queue.enqueue(vaultID: "vault-not-found") + queue.enqueue(vaultID: "vault-still-offline") + service.checkInProvider = { vaultID in + switch vaultID { + case "vault-succeeds": return + case "vault-not-found": throw APIError.notFound + default: throw APIError.networkUnavailable + } + } + + let succeeded = await service.flush() + + XCTAssertFalse(succeeded) + XCTAssertEqual(queue.pending.map(\.vaultID), ["vault-still-offline"]) + } +} diff --git a/ios/EthosProtocol/Tests/EthosProtocolTests.swift b/ios/EthosProtocol/Tests/EthosProtocolTests.swift index 7353830..2ab466a 100644 --- a/ios/EthosProtocol/Tests/EthosProtocolTests.swift +++ b/ios/EthosProtocol/Tests/EthosProtocolTests.swift @@ -808,7 +808,7 @@ final class HandleRefreshTests: XCTestCase { XCTAssertEqual(service.scheduleAppRefreshCallCount, 1) } - func test_handleRefresh_onlySchedulesTTLWarningForVaultsUnder24h() async { + func test_handleRefresh_onlySchedulesTTLWarningForVaultsUnder24h() async throws { try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] != nil, "UNUserNotificationCenter requires a real app host process, unavailable in CI") diff --git a/ios/EthosProtocol/Tests/OfflineSupportTests.swift b/ios/EthosProtocol/Tests/OfflineSupportTests.swift new file mode 100644 index 0000000..513546f --- /dev/null +++ b/ios/EthosProtocol/Tests/OfflineSupportTests.swift @@ -0,0 +1,170 @@ +import XCTest +@testable import EthosProtocol + +// MARK: - Issue #27: NetworkMonitor Cold-Start Tests + +final class NetworkMonitorColdStartTests: XCTestCase { + + private struct MockPathProvider: NetworkPathProvider { + let isCurrentlySatisfied: Bool + func startMonitoring(_ handler: @escaping (Bool) -> Void) { + // Intentionally never calls `handler` — these tests assert on the synchronous + // snapshot NetworkMonitor reads at init, before any async path update could fire. + } + } + + func test_coldStart_offlineDevice_reportsDisconnectedImmediately() { + let monitor = NetworkMonitor(provider: MockPathProvider(isCurrentlySatisfied: false)) + // No async path update ever fires in this test — if `isConnected` were still + // defaulting to `true` at this point, the very first request made right after + // launch would wrongly assume connectivity instead of using the offline cache path. + XCTAssertFalse(monitor.isConnected) + } + + func test_coldStart_onlineDevice_reportsConnectedImmediately() { + let monitor = NetworkMonitor(provider: MockPathProvider(isCurrentlySatisfied: true)) + XCTAssertTrue(monitor.isConnected) + } + + func test_pathUpdate_afterColdStart_updatesIsConnected() { + final class RecordingProvider: NetworkPathProvider { + let isCurrentlySatisfied = true + var handler: ((Bool) -> Void)? + func startMonitoring(_ handler: @escaping (Bool) -> Void) { self.handler = handler } + } + let provider = RecordingProvider() + let monitor = NetworkMonitor(provider: provider) + XCTAssertTrue(monitor.isConnected) + + provider.handler?(false) + XCTAssertFalse(monitor.isConnected) + } +} + +// MARK: - Issue #25 / #26: OfflineCache Expiry, Age, and Eviction Tests + +final class OfflineCacheExpiryTests: XCTestCase { + + override func tearDown() { + OfflineCache.shared.maxAge = nil + OfflineCache.shared.maxBytes = 20 * 1_024 * 1_024 + super.tearDown() + } + + func test_age_forFreshEntry_isNearZero() { + let key = "age-fresh-\(UUID())" + OfflineCache.shared.save(Data("x".utf8), for: key) + let age = OfflineCache.shared.age(for: key) + XCTAssertNotNil(age) + XCTAssertLessThan(age ?? .infinity, 2) + } + + func test_age_forMissingKey_isNil() { + XCTAssertNil(OfflineCache.shared.age(for: "missing-\(UUID())")) + } + + func test_cachedAt_isRoughlyNow() { + let key = "cachedat-\(UUID())" + let before = Date() + OfflineCache.shared.save(Data("x".utf8), for: key) + let cachedAt = OfflineCache.shared.cachedAt(for: key) + XCTAssertNotNil(cachedAt) + XCTAssertGreaterThanOrEqual(cachedAt ?? .distantPast, before.addingTimeInterval(-1)) + } + + func test_load_entryOlderThanMaxAge_returnsNil() { + let key = "expired-\(UUID())" + OfflineCache.shared.save(Data("stale".utf8), for: key) + // Backdate the entry directly rather than sleeping in the test. + setCachedAt(Date().addingTimeInterval(-1000), for: key) + + OfflineCache.shared.maxAge = 500 + XCTAssertNil(OfflineCache.shared.load(for: key)) + } + + func test_load_entryWithinMaxAge_stillReturnsData() { + let key = "fresh-within-ttl-\(UUID())" + let data = Data("still good".utf8) + OfflineCache.shared.save(data, for: key) + + OfflineCache.shared.maxAge = 3_600 + XCTAssertEqual(OfflineCache.shared.load(for: key), data) + } + + func test_load_withNoMaxAgeSet_neverExpires() { + let key = "no-ttl-\(UUID())" + OfflineCache.shared.save(Data("x".utf8), for: key) + setCachedAt(Date().addingTimeInterval(-1_000_000), for: key) + + XCTAssertNil(OfflineCache.shared.maxAge) + XCTAssertNotNil(OfflineCache.shared.load(for: key)) + } + + private func setCachedAt(_ date: Date, for key: String) { + let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appendingPathComponent("EthosProtocolOfflineCache", isDirectory: true) + let metaFile = dir.appendingPathComponent(key.sha256Hex + ".meta") + try? date.timeIntervalSince1970.description.data(using: .utf8)?.write(to: metaFile) + } +} + +final class OfflineCacheEvictionTests: XCTestCase { + + override func setUp() { + super.setUp() + OfflineCache.shared.clearAll() + } + + override func tearDown() { + OfflineCache.shared.maxBytes = 20 * 1_024 * 1_024 + OfflineCache.shared.clearAll() + super.tearDown() + } + + func test_saveBeyondCap_evictsLeastRecentlyUsedFirst() { + // Three ~1KB entries, capped at ~2KB — the third save must evict exactly one entry. + OfflineCache.shared.maxBytes = 2_200 + let payload = Data(repeating: 0x41, count: 1_000) + + OfflineCache.shared.save(payload, for: "lru-a") + OfflineCache.shared.save(payload, for: "lru-b") + // Set mtimes explicitly rather than relying on OS mtime resolution between two saves + // microseconds apart, which can collide and make ordering nondeterministic. + setDataFileModified(Date(), for: "lru-a") + setDataFileModified(Date().addingTimeInterval(-100), for: "lru-b") + OfflineCache.shared.save(payload, for: "lru-c") + + XCTAssertNil(OfflineCache.shared.load(for: "lru-b"), "Least-recently-used entry should have been evicted") + XCTAssertNotNil(OfflineCache.shared.load(for: "lru-a"), "Recently-touched entry should survive eviction") + XCTAssertNotNil(OfflineCache.shared.load(for: "lru-c"), "Newly-written entry should survive eviction") + } + + private func setDataFileModified(_ date: Date, for key: String) { + let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appendingPathComponent("EthosProtocolOfflineCache", isDirectory: true) + let dataFile = dir.appendingPathComponent(key.sha256Hex) + try? FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: dataFile.path) + } + + func test_saveUnderCap_evictsNothing() { + OfflineCache.shared.maxBytes = 1_024 * 1_024 + let payload = Data(repeating: 0x42, count: 100) + OfflineCache.shared.save(payload, for: "small-a") + OfflineCache.shared.save(payload, for: "small-b") + + XCTAssertNotNil(OfflineCache.shared.load(for: "small-a")) + XCTAssertNotNil(OfflineCache.shared.load(for: "small-b")) + } +} + +final class OfflineCacheSignOutTests: XCTestCase { + func test_clearAll_removesPreviouslySavedEntries() { + let key = "signout-\(UUID())" + OfflineCache.shared.save(Data("secret".utf8), for: key) + XCTAssertNotNil(OfflineCache.shared.load(for: key)) + + OfflineCache.shared.clearAll() + + XCTAssertNil(OfflineCache.shared.load(for: key)) + } +}