WIP: implementer stopped mid-task (weekly model limit); untested
Saved as-is by the coordinating session so the work isn't lost. The next implementer continues from here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
17 files changed,
+1421
−10
Package.swift+3 −3Sources/RastrilloApple/NativeOfflineBanner.swift+15 −7Sources/RastrilloApple/NativeSealedStore.swift+68 −0Sources/RastrilloApple/NativeSyncStatusView.swift+43 −0Sources/RastrilloApple/NativeSyncStrings.swift+40 −0Sources/RastrilloApple/OutboxDrainer.swift+74 −0Sources/RastrilloApple/Resources/en.lproj/Localizable.strings+1 −0Sources/RastrilloNative/BlobStore.swift+38 −0Sources/RastrilloNative/Outbox.swift+119 −0Sources/RastrilloNative/SyncStatus.swift+38 −0Sources/RastrilloTestSupport/FaultingBlobStore.swift+44 −0Tests/RastrilloAppleTests/OutboxDrainerTests.swift+272 −0Tests/RastrilloAppleTests/SealedStoreTests.swift+175 −0Tests/RastrilloAppleTests/SyncStringsTests.swift+63 −0Tests/RastrilloNativeTests/OutboxTests.swift+309 −0Tests/RastrilloNativeTests/SyncStatusTests.swift+71 −0Tests/RastrilloTestSupportTests/FaultingBlobStoreTests.swift+48 −0
CI failed — run details
Intent
diff --git a/Package.swift b/Package.swift| index 974a178..ba69758 100644 |
| --- a/Package.swift |
| +++ b/Package.swift |
| @@ -12,12 +12,12 @@ let package = Package( |
| dependencies: [.package(url: "https://github.com/sparkle-project/Sparkle", exact: "2.9.6")], |
| targets: [ |
| .target(name: "RastrilloNative"), |
| - .target(name: "RastrilloApple", dependencies: ["RastrilloNative"]), |
| - .target(name: "RastrilloTestSupport", dependencies: ["RastrilloApple"]), |
| + .target(name: "RastrilloApple", dependencies: ["RastrilloNative"], resources: [.process("Resources")]), |
| + .target(name: "RastrilloTestSupport", dependencies: ["RastrilloApple", "RastrilloNative"]), |
| .target(name: "RastrilloMacUpdates", dependencies: [.product(name: "Sparkle", package: "Sparkle", condition: .when(platforms: [.macOS]))], resources: [.process("Resources")]), |
| .testTarget(name: "RastrilloMacUpdatesTests", dependencies: ["RastrilloMacUpdates"]), |
| .testTarget(name: "RastrilloAppleTests", dependencies: ["RastrilloApple", "RastrilloNative"]), |
| - .testTarget(name: "RastrilloNativeTests", dependencies: ["RastrilloNative"]), |
| + .testTarget(name: "RastrilloNativeTests", dependencies: ["RastrilloNative", "RastrilloTestSupport"]), |
| .testTarget(name: "RastrilloTestSupportTests", dependencies: ["RastrilloTestSupport", "RastrilloApple"]), |
| ] |
| ) |
diff --git a/Sources/RastrilloApple/NativeOfflineBanner.swift b/Sources/RastrilloApple/NativeOfflineBanner.swift| index e4bb586..080c4ea 100644 |
| --- a/Sources/RastrilloApple/NativeOfflineBanner.swift |
| +++ b/Sources/RastrilloApple/NativeOfflineBanner.swift |
| @@ -1,17 +1,25 @@ |
| +import RastrilloNative |
| import SwiftUI |
| -/// A thin inset bar shown while `origin` is offline. It says only what is |
| -/// true without a durable outbox: pass the durable wording ("Changes are saved |
| -/// on this device…") once the app's edits survive a relaunch. Entering the |
| -/// offline state is announced to assistive technologies; leaving it is not. |
| +/// A thin inset bar shown while `origin` is offline. Entering the offline |
| +/// state is announced to assistive technologies; leaving it is not. |
| +/// |
| +/// Pass `summary` once the app's edits are kept in an `Outbox`: the banner |
| +/// then says they are saved on this device, which is only true from then on. |
| public struct NativeOfflineBanner: View { |
| private let connectivity: NativeConnectivity |
| private let origin: URL |
| private let message: String |
| - @MainActor public init(connectivity: NativeConnectivity? = nil, origin: URL, |
| - message: String = "You’re offline. Waiting for a connection.") { |
| - self.connectivity = connectivity ?? .shared; self.origin = origin; self.message = message |
| + @MainActor public init(connectivity: NativeConnectivity? = nil, origin: URL, message: String? = nil) { |
| + self.connectivity = connectivity ?? .shared; self.origin = origin |
| + self.message = message ?? NativeSyncStrings.kit.offlineWaiting |
| + } |
| + |
| + /// The durable wording, for an app whose changes go through an outbox. |
| + @MainActor public init(connectivity: NativeConnectivity? = nil, origin: URL, summary: OutboxSummary, |
| + strings: NativeSyncStrings = .kit) { |
| + self.init(connectivity: connectivity, origin: origin, message: strings.offlineDurable) |
| } |
| public var body: some View { |
diff --git a/Sources/RastrilloApple/NativeSealedStore.swift b/Sources/RastrilloApple/NativeSealedStore.swift| new file mode 100644 |
| index 0000000..3b75ec2 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/NativeSealedStore.swift |
| @@ -0,0 +1,68 @@ |
| +import CryptoKit |
| +import Foundation |
| +import RastrilloNative |
| +import Security |
| + |
| +/// Where an account's store key lives. `KeychainKeyProvider` is the real one; |
| +/// tests script every Keychain status with `ScriptedKeyProvider`. |
| +/// |
| +/// It answers in raw Keychain statuses on purpose: telling *unavailable* |
| +/// from *lost* is the store's job, and it must see the status to do it. |
| +public protocol KeyProvider: Sendable { |
| + /// `errSecSuccess` with the key, or the status the Keychain gave. |
| + func copyKey() -> (OSStatus, SymmetricKey?) |
| + func addKey(_ key: SymmetricKey) -> OSStatus |
| + func deleteKey() -> OSStatus |
| +} |
| + |
| +/// One AES-256 key per account, a generic password under service |
| +/// `<bundle id>.store` and account `store:<account id>|<origin>`, readable |
| +/// only while the device is unlocked, on this device, and never synchronised. |
| +public struct KeychainKeyProvider: KeyProvider { |
| + public let service: String |
| + public let account: String |
| + |
| + public init(accountID: String, origin: URL, bundleID: String = Bundle.main.bundleIdentifier ?? "oficina.native") { |
| + service = bundleID + ".store" |
| + account = "store:\(accountID)|\(origin.absoluteString)" |
| + } |
| + |
| + private var query: [String: Any] { |
| + [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, |
| + kSecAttrAccount as String: account, kSecAttrSynchronizable as String: false] |
| + } |
| + |
| + public func copyKey() -> (OSStatus, SymmetricKey?) { |
| + var q = query |
| + q[kSecReturnData as String] = true |
| + q[kSecMatchLimit as String] = kSecMatchLimitOne |
| + var result: CFTypeRef? |
| + let status = SecItemCopyMatching(q as CFDictionary, &result) |
| + guard status == errSecSuccess else { return (status, nil) } |
| + guard let data = result as? Data, data.count == 32 else { return (errSecDecode, nil) } |
| + return (status, SymmetricKey(data: data)) |
| + } |
| + |
| + public func addKey(_ key: SymmetricKey) -> OSStatus { |
| + var q = query |
| + q[kSecValueData as String] = key.withUnsafeBytes { Data($0) } |
| + q[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly |
| + return SecItemAdd(q as CFDictionary, nil) |
| + } |
| + |
| + public func deleteKey() -> OSStatus { SecItemDelete(query as CFDictionary) } |
| +} |
| + |
| +/// STUB for the red run. |
| +public final class NativeSealedStore: BlobStore, @unchecked Sendable { |
| + public static let formatVersion = 1 |
| + public let root: URL |
| + public let accountID: String |
| + public init(root: URL, accountID: String, keys: KeyProvider) { self.root = root; self.accountID = accountID } |
| + public func write(_ data: Data, to path: String) throws {} |
| + public func read(_ path: String) throws -> Data? { nil } |
| + public func list(_ directory: String) throws -> [String] { [] } |
| + public func remove(_ path: String) throws {} |
| + public func wipe() throws {} |
| + static let writeOptions: Data.WritingOptions = [] |
| +} |
diff --git a/Sources/RastrilloApple/NativeSyncStatusView.swift b/Sources/RastrilloApple/NativeSyncStatusView.swift| new file mode 100644 |
| index 0000000..4c47f71 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/NativeSyncStatusView.swift |
| @@ -0,0 +1,43 @@ |
| +import RastrilloNative |
| +import SwiftUI |
| + |
| +/// A compact save status for a toolbar or title bar. Silent when saved, but |
| +/// the accessibility element stays present while silent, so the first |
| +/// failure is announced from a region that already exists (estilo |
| +/// savestatus). Entering offline, needs-sign-in and failed is announced; a |
| +/// saving flicker never is. |
| +public struct NativeSyncStatusView: View { |
| + private let status: SyncStatus |
| + private let strings: NativeSyncStrings |
| + |
| + public init(status: SyncStatus, strings: NativeSyncStrings = .kit) { |
| + self.status = status |
| + self.strings = strings |
| + } |
| + |
| + public var body: some View { |
| + let text = strings.text(for: status) ?? "" |
| + Text(text) |
| + .font(.callout) |
| + .fontWeight(status.saveState == .failed ? .semibold : .regular) |
| + .foregroundStyle(status.saveState == .failed ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary)) |
| + .lineLimit(2) |
| + .frame(minWidth: 8) |
| + .accessibilityElement(children: .ignore) |
| + .accessibilityLabel(text) |
| + .accessibilityAddTraits(.updatesFrequently) |
| + .onChange(of: status) { old, new in |
| + guard new.isAnnounced, !Self.sameKind(old, new), let text = strings.text(for: new) else { return } |
| + AccessibilityNotification.Announcement(text).post() |
| + } |
| + } |
| + |
| + /// A count changing inside the same state is not news. |
| + static func sameKind(_ a: SyncStatus, _ b: SyncStatus) -> Bool { |
| + switch (a, b) { |
| + case (.offline, .offline), (.needsSignIn, .needsSignIn), (.retrying, .retrying): return true |
| + case (.failed(_, let x), .failed(_, let y)): return x == y |
| + default: return a == b |
| + } |
| + } |
| +} |
diff --git a/Sources/RastrilloApple/NativeSyncStrings.swift b/Sources/RastrilloApple/NativeSyncStrings.swift| new file mode 100644 |
| index 0000000..2d81af4 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/NativeSyncStrings.swift |
| @@ -0,0 +1,40 @@ |
| +import Foundation |
| +import RastrilloNative |
| + |
| +/// The words the kit's sync views say. `.kit` reads the package's own |
| +/// catalog, in the same twelve languages as the apps; an app overrides any |
| +/// of them, for example to count cells instead of documents, by changing a |
| +/// copy. |
| +public struct NativeSyncStrings: Sendable { |
| + public var saving: String |
| + public var retrying: String |
| + /// "Offline · N pending". |
| + public var offline: @Sendable (Int) -> String |
| + /// "Sign in to send N pending". |
| + public var needsSignIn: @Sendable (Int) -> String |
| + public var signIn: String |
| + /// A held entry's words are the app's; this is the fallback. |
| + public var failed: @Sendable (SyncFailure) -> String |
| + /// The banner while the path or server is down and edits are not durable. |
| + public var offlineWaiting: String |
| + /// The banner once edits are kept on this device. |
| + public var offlineDurable: String |
| + /// Reported once after a lost key or corrupt entry removed unsent changes. |
| + public var unsentLost: String |
| + |
| + public init(saving: String, retrying: String, offline: @escaping @Sendable (Int) -> String, |
| + needsSignIn: @escaping @Sendable (Int) -> String, signIn: String, |
| + failed: @escaping @Sendable (SyncFailure) -> String, offlineWaiting: String, |
| + offlineDurable: String, unsentLost: String) { |
| + self.saving = saving; self.retrying = retrying; self.offline = offline; self.needsSignIn = needsSignIn |
| + self.signIn = signIn; self.failed = failed; self.offlineWaiting = offlineWaiting |
| + self.offlineDurable = offlineDurable; self.unsentLost = unsentLost |
| + } |
| + |
| + public static var kit: NativeSyncStrings { kit(localization: "en") } |
| + public static func kit(localization: String) -> NativeSyncStrings { |
| + NativeSyncStrings(saving: "", retrying: "", offline: { _ in "" }, needsSignIn: { _ in "" }, signIn: "", failed: { _ in "" }, |
| + offlineWaiting: "", offlineDurable: "", unsentLost: "") |
| + } |
| + public func text(for status: SyncStatus) -> String? { nil } |
| +} |
diff --git a/Sources/RastrilloApple/OutboxDrainer.swift b/Sources/RastrilloApple/OutboxDrainer.swift| new file mode 100644 |
| index 0000000..5350a5b |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/OutboxDrainer.swift |
| @@ -0,0 +1,74 @@ |
| +import Foundation |
| +import Observation |
| +import RastrilloNative |
| + |
| +/// Sends one account's outbox. The app supplies `send`, which turns a batch |
| +/// of one lane into requests; the drainer decides when to call it, and what |
| +/// an error it throws means, so every app reacts the same way: |
| +/// |
| +/// | Thrown | Reaction | |
| +/// |---|---| |
| +/// | `.offline`, path down | stop; wait for a connectivity change, with no timer | |
| +/// | `.offline`, path up (a timeout, a lost response) | back off, retry the same batch | |
| +/// | `.unauthorized` | session ended: stop every lane; never hold or drop | |
| +/// | `.rateLimited(after)` | back off at least `after` | |
| +/// | `.server`, `.transport`, `.invalidResponse` | back off, retry the same batch | |
| +/// | `BlobStoreError.unavailable` | store locked: wait for the next change (foreground, unlock) | |
| +/// | `.forbidden`, `.notFound`, `.conflict`, `.tooLarge`, `.rejected` | hold the batch rather than spin | |
| +/// |
| +/// A failure to acknowledge or hold after a send is a `.retry`: the server |
| +/// may already have the batch, and each app's server-side idempotency makes |
| +/// sending it again harmless. |
| +/// |
| +/// At most one batch is in flight, across all lanes: the simplest correct |
| +/// ordering against one server. |
| +@MainActor @Observable public final class OutboxDrainer { |
| + public enum Disposition: Sendable, Equatable { |
| + case sent |
| + case retry |
| + case hold(Hold) |
| + } |
| + |
| + /// Where the drainer is. `SyncStatus` reads it. |
| + public enum Phase: Sendable, Equatable { |
| + case idle |
| + case sending |
| + case waitingForNetwork |
| + case backingOff |
| + case sessionEnded |
| + case storeUnavailable |
| + } |
| + |
| + /// What one `drain()` ended on. |
| + public enum Outcome: Sendable, Equatable { |
| + /// Nothing left that can be sent. |
| + case idle |
| + case waitingForNetwork |
| + /// Retry after this delay (a connectivity change may cut it short). |
| + case backingOff(TimeInterval) |
| + /// The server's Retry-After: nothing but "Try again" crosses it. |
| + case rateLimited(TimeInterval) |
| + case sessionEnded |
| + case storeUnavailable |
| + } |
| + |
| + /// Returns a disposition per entry. An entry it leaves out was not sent |
| + /// this time (for example, a merge that had to fall back to the first |
| + /// entry): it goes back to sendable with no backoff. |
| + public typealias Send = @MainActor (_ lane: String, _ entries: [OutboxEntry], _ transport: NativeTransport) async throws -> [UUID: Disposition] |
| + |
| + public let outbox: Outbox |
| + public private(set) var phase: Phase = .idle |
| + public private(set) var summary = OutboxSummary() |
| + public private(set) var sessionEnded = false |
| + public init(outbox: Outbox, transport: @escaping @MainActor () -> NativeTransport?, connectivity: NativeConnectivity, |
| + maxBatchBytes: Int? = nil, backoff: Backoff = Backoff(), send: @escaping Send) { self.outbox = outbox } |
| + public func start() {} |
| + public func stop() {} |
| + public func kick() {} |
| + public func retryNow() {} |
| + public func sessionRestored() {} |
| + @discardableResult public func drain() async -> Outcome { .idle } |
| + public func status(lane: String? = nil, localFailure: SyncFailure? = nil, |
| + count: (OutboxSummary) -> Int = { $0.pendingLanes }) -> SyncStatus { .saved } |
| +} |
diff --git a/Sources/RastrilloApple/Resources/en.lproj/Localizable.strings b/Sources/RastrilloApple/Resources/en.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..c91be22 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/en.lproj/Localizable.strings |
| @@ -0,0 +1 @@ |
| +"stub" = "stub"; |
diff --git a/Sources/RastrilloNative/BlobStore.swift b/Sources/RastrilloNative/BlobStore.swift| new file mode 100644 |
| index 0000000..04edf31 |
| --- /dev/null |
| +++ b/Sources/RastrilloNative/BlobStore.swift |
| @@ -0,0 +1,38 @@ |
| +import Foundation |
| + |
| +/// Why a store operation failed. The three cases are kept apart because each |
| +/// one asks for a different reaction, and folding them together is how a |
| +/// locked phone ends up deleting the changes it was keeping. |
| +public enum BlobStoreError: Error, Sendable, Equatable { |
| + /// The device is locked, file protection refused the read, or the Keychain |
| + /// would not answer. Retry later (on `.active` or unlock). Never read this |
| + /// as "empty", and never delete anything because of it. |
| + case unavailable |
| + /// The account's key is gone (`errSecItemNotFound`) while its directory |
| + /// still held sealed files. The store has already removed that directory, |
| + /// because nothing in it can ever be read again; the caller reports the |
| + /// loss once and carries on with an empty store. |
| + case keyLost |
| + /// One file failed its authentication tag. The store has removed that |
| + /// file, and only that file; the path is given so it can be reported once. |
| + case corrupt(String) |
| +} |
| + |
| +/// Durable bytes by relative path, for one account. `NativeSealedStore` is the |
| +/// real one; tests use `MemoryBlobStore` and `FaultingBlobStore`. |
| +/// |
| +/// Paths are relative, `/`-separated, and never renamed once written: a |
| +/// sealed file is bound to its path. |
| +public protocol BlobStore: Sendable { |
| + /// Atomic: after a crash the path holds either the old bytes or the new |
| + /// ones. Durable on return means it survives a process kill, not a power |
| + /// loss. |
| + func write(_ data: Data, to path: String) throws |
| + /// nil when there is no such file. |
| + func read(_ path: String) throws -> Data? |
| + /// The names (not paths) of the files directly in `directory`, sorted. An |
| + /// absent directory lists as empty. |
| + func list(_ directory: String) throws -> [String] |
| + /// Removes a file or a directory. Removing what is not there succeeds. |
| + func remove(_ path: String) throws |
| +} |
diff --git a/Sources/RastrilloNative/Outbox.swift b/Sources/RastrilloNative/Outbox.swift| new file mode 100644 |
| index 0000000..5c5afb4 |
| --- /dev/null |
| +++ b/Sources/RastrilloNative/Outbox.swift |
| @@ -0,0 +1,119 @@ |
| +import Foundation |
| + |
| +/// One queued change, durable on disk until the server has it. |
| +public struct OutboxEntry: Codable, Sendable, Identifiable, Equatable { |
| + /// Stable for the entry's life; apps may send it as an idempotency key. |
| + public let id: UUID |
| + /// Per account and monotonic, recovered as max + 1 at load. It orders a lane. |
| + public let seq: UInt64 |
| + /// The FIFO scope, such as "doc:<key>". It never changes. |
| + public let lane: String |
| + /// App-defined. |
| + public let kind: String |
| + /// Opaque app bytes. |
| + public let payload: Data |
| + public let createdAt: Date |
| + /// Failed sends so far. Counted in memory; written with the next rewrite. |
| + public var attempts: Int |
| + /// nil means sendable. |
| + public var hold: Hold? |
| + |
| + public init(id: UUID, seq: UInt64, lane: String, kind: String, payload: Data, createdAt: Date, attempts: Int = 0, hold: Hold? = nil) { |
| + self.id = id; self.seq = seq; self.lane = lane; self.kind = kind; self.payload = payload |
| + self.createdAt = createdAt; self.attempts = attempts; self.hold = hold |
| + } |
| +} |
| + |
| +/// Why an entry stopped: the person has to decide what happens to it. |
| +public struct Hold: Codable, Sendable, Equatable { |
| + public enum Reason: Codable, Sendable, Equatable { |
| + case conflict(Data) |
| + case rejected(String) |
| + case accessLost |
| + case tooLarge |
| + } |
| + public let reason: Reason |
| + /// Every v1 hold stops its lane. A hold that lets later entries pass |
| + /// arrives with Sheets, whose writes to different cells do not depend on |
| + /// each other; Docs' updates do, so it never needs one. |
| + public var blocksLane: Bool { true } |
| + public init(_ reason: Reason) { self.reason = reason } |
| +} |
| + |
| +/// Per-lane counts, for status and for the app's own caps. |
| +public struct OutboxSummary: Sendable, Equatable { |
| + public struct Lane: Sendable, Equatable { |
| + /// Entries not held, including any in flight. |
| + public var pending = 0 |
| + public var held = 0 |
| + public var inFlight = 0 |
| + /// Payload bytes of every entry in the lane, held or not. |
| + public var bytes = 0 |
| + public var firstHold: Hold.Reason? |
| + public init() {} |
| + } |
| + public var lanes: [String: Lane] = [:] |
| + /// The store could not be read. Counts are the last ones known, which is |
| + /// never the same thing as "nothing pending". |
| + public var unavailable = false |
| + /// Unsent entries were lost to a lost key or a corrupt file. Report it |
| + /// once, then call `Outbox.acknowledgeLoss()`. |
| + public var lostUnsent = false |
| + |
| + public init() {} |
| + |
| + public var pending: Int { lanes.values.reduce(0) { $0 + $1.pending } } |
| + public var held: Int { lanes.values.reduce(0) { $0 + $1.held } } |
| + public var inFlight: Int { lanes.values.reduce(0) { $0 + $1.inFlight } } |
| + /// Lanes with anything in them: the default "N" of "Offline · N pending". |
| + public var pendingLanes: Int { lanes.values.filter { $0.pending + $0.held > 0 }.count } |
| + public var isEmpty: Bool { lanes.values.allSatisfy { $0.pending + $0.held == 0 } } |
| + |
| + /// The same summary, narrowed to one lane. |
| + public func only(_ lane: String) -> OutboxSummary { |
| + var copy = self |
| + copy.lanes = lanes[lane].map { [lane: $0] } ?? [:] |
| + return copy |
| + } |
| +} |
| + |
| +public enum OutboxError: Error, Sendable, Equatable { |
| + /// `replace` was asked to merge an entry that is being sent. The batch's |
| + /// `ack` would otherwise delete the merged file, and with it content whose |
| + /// own files the merge had already removed. |
| + case inFlight |
| + /// `replace` needs a run of entries that are next to each other in one lane. |
| + case notContiguous |
| + /// `replace` never merges a held entry: it is waiting for a person. |
| + case held |
| + case unknownEntry(UUID) |
| + case duplicateEntry(UUID) |
| +} |
| + |
| +/// STUB for the red run: every behaviour missing. |
| +public actor Outbox { |
| + public nonisolated let account: String |
| + public static let directory = "outbox" |
| + public init(store: BlobStore, account: String) { self.account = account } |
| + public func load() throws {} |
| + @discardableResult |
| + public func enqueue(lane: String, kind: String, payload: Data, id: UUID = UUID()) throws -> OutboxEntry { |
| + OutboxEntry(id: id, seq: 1, lane: lane, kind: kind, payload: payload, createdAt: Date()) |
| + } |
| + public func entries(lane: String) -> [OutboxEntry] { [] } |
| + public func sendable(lane: String, maxBytes: Int?) -> [OutboxEntry] { [] } |
| + public func beginSend(lane: String, maxBytes: Int?) throws -> [OutboxEntry] { [] } |
| + public func endSend(_ ids: [UUID], failed: Bool = true) {} |
| + @discardableResult |
| + public func replace(_ ids: [UUID], with payload: Data) throws -> OutboxEntry { |
| + OutboxEntry(id: UUID(), seq: 0, lane: "", kind: "", payload: payload, createdAt: Date()) |
| + } |
| + public func ack(_ ids: [UUID]) throws {} |
| + public func hold(_ ids: [UUID], _ hold: Hold) throws {} |
| + public func release(_ id: UUID) throws {} |
| + public func discard(lane: String) throws {} |
| + public func purge() throws {} |
| + public func acknowledgeLoss() {} |
| + public var summary: OutboxSummary { OutboxSummary() } |
| + public var changes: AsyncStream<OutboxSummary> { AsyncStream { $0.finish() } } |
| +} |
diff --git a/Sources/RastrilloNative/SyncStatus.swift b/Sources/RastrilloNative/SyncStatus.swift| new file mode 100644 |
| index 0000000..5c20188 |
| --- /dev/null |
| +++ b/Sources/RastrilloNative/SyncStatus.swift |
| @@ -0,0 +1,38 @@ |
| +import Foundation |
| + |
| +/// A failure the person has to see and act on. |
| +public enum SyncFailure: Sendable, Equatable { |
| + /// An entry is held; the app owns the wording and the action. |
| + case held(Hold.Reason) |
| + /// Enqueueing failed (disk full, or the store is unreadable): input pauses. |
| + case cannotSaveOnDevice |
| + /// The app's per-lane byte ceiling was reached: input stops, and says why. |
| + case tooManyUnsent |
| +} |
| + |
| +/// What the save status says, computed from outbox state and never from HTTP |
| +/// activity. The cases are in priority order: the first that applies wins. |
| +/// |
| +/// "Offline" with nothing pending is `.saved`: status stays silent and the |
| +/// connectivity chrome says offline, so "Offline · 0 pending" never appears. |
| +public enum SyncStatus: Sendable, Equatable { |
| + /// 1. A held entry, or a local failure. Sticky until resolved. |
| + case failed(pending: Int, reason: SyncFailure) |
| + /// 2. The session ended with changes still to send. Sticky until sign-in. |
| + case needsSignIn(pending: Int) |
| + /// 3. Nothing to send and nothing in flight. Silent. |
| + case saved |
| + /// 4. A send is in flight (or about to start). |
| + case saving |
| + /// 5. Changes are waiting for a connection. |
| + case offline(pending: Int) |
| + /// 6. Backing off after a transient failure. |
| + case retrying(pending: Int) |
| + |
| + public enum SaveState: String, Sendable { case saved, saving, retrying, failed } |
| + public var saveState: SaveState { .saved } |
| + public var isSticky: Bool { false } |
| + public var isAnnounced: Bool { false } |
| + public init(summary: OutboxSummary, sending: Bool, sessionEnded: Bool, offline: Bool, backingOff: Bool, |
| + localFailure: SyncFailure? = nil, count: (OutboxSummary) -> Int = { $0.pendingLanes }) { self = .saved } |
| +} |
diff --git a/Sources/RastrilloTestSupport/FaultingBlobStore.swift b/Sources/RastrilloTestSupport/FaultingBlobStore.swift| new file mode 100644 |
| index 0000000..14365fc |
| --- /dev/null |
| +++ b/Sources/RastrilloTestSupport/FaultingBlobStore.swift |
| @@ -0,0 +1,44 @@ |
| +import CryptoKit |
| +import Foundation |
| +import RastrilloApple |
| +import RastrilloNative |
| +import Security |
| + |
| +/// STUB for the red run. |
| +public final class MemoryBlobStore: BlobStore, @unchecked Sendable { |
| + public init(_ files: [String: Data] = [:]) {} |
| + public var contents: [String: Data] { [:] } |
| + public func write(_ data: Data, to path: String) throws {} |
| + public func read(_ path: String) throws -> Data? { nil } |
| + public func list(_ directory: String) throws -> [String] { [] } |
| + public func remove(_ path: String) throws {} |
| +} |
| +public struct SimulatedCrash: Error, Sendable, Equatable { |
| + public let operation: Int |
| + public init(operation: Int) { self.operation = operation } |
| +} |
| +public final class FaultingBlobStore: BlobStore, @unchecked Sendable { |
| + public enum Operation: Sendable, Equatable { case write(String), read(String), list(String), remove(String) } |
| + private let base: MemoryBlobStore |
| + public init(_ base: MemoryBlobStore = MemoryBlobStore(), crashAt: Int? = nil) { self.base = base } |
| + public func crash(after operations: Int) {} |
| + public var unavailable: Bool { get { false } set {} } |
| + public var hasCrashed: Bool { false } |
| + public var operationCount: Int { 0 } |
| + public var operations: [Operation] { [] } |
| + public var committed: MemoryBlobStore { MemoryBlobStore() } |
| + public func write(_ data: Data, to path: String) throws { try base.write(data, to: path) } |
| + public func read(_ path: String) throws -> Data? { try base.read(path) } |
| + public func list(_ directory: String) throws -> [String] { try base.list(directory) } |
| + public func remove(_ path: String) throws { try base.remove(path) } |
| +} |
| +public final class ScriptedKeyProvider: KeyProvider, @unchecked Sendable { |
| + public init(key: SymmetricKey? = nil) {} |
| + public func failCopies(with status: OSStatus?) {} |
| + public func failAdds(with status: OSStatus?) {} |
| + public var storedKey: SymmetricKey? { nil } |
| + public var log: [String] { [] } |
| + public func copyKey() -> (OSStatus, SymmetricKey?) { (errSecItemNotFound, nil) } |
| + public func addKey(_ new: SymmetricKey) -> OSStatus { errSecSuccess } |
| + public func deleteKey() -> OSStatus { errSecSuccess } |
| +} |
diff --git a/Tests/RastrilloAppleTests/OutboxDrainerTests.swift b/Tests/RastrilloAppleTests/OutboxDrainerTests.swift| new file mode 100644 |
| index 0000000..a7baacb |
| --- /dev/null |
| +++ b/Tests/RastrilloAppleTests/OutboxDrainerTests.swift |
| @@ -0,0 +1,272 @@ |
| +import Foundation |
| +import RastrilloNative |
| +import RastrilloTestSupport |
| +import XCTest |
| +@testable import RastrilloApple |
| + |
| +@MainActor private func settle(until condition: () async -> Bool, seconds: Double = 3) async { |
| + let deadline = Date().addingTimeInterval(seconds) |
| + while !(await condition()) && Date() < deadline { try? await Task.sleep(nanoseconds: 5_000_000) } |
| +} |
| + |
| +/// A send that POSTs each entry's payload, in order, as one request per batch. |
| +@MainActor private func poster(_ calls: @escaping @MainActor (String, [OutboxEntry]) -> Void = { _, _ in }) -> OutboxDrainer.Send { |
| + { lane, entries, transport in |
| + calls(lane, entries) |
| + let body = entries.map { String(decoding: $0.payload, as: UTF8.self) }.joined(separator: ",") |
| + let reply = try await transport.responseDetails("/\(lane)", method: "POST", body: Data(body.utf8)) |
| + try reply.requireSuccess() |
| + return Dictionary(uniqueKeysWithValues: entries.map { ($0.id, .sent) }) |
| + } |
| +} |
| + |
| +@MainActor final class OutboxDrainerTests: XCTestCase { |
| + private func connectivity(_ status: NativePath.Status = .satisfied) async -> (NativeConnectivity, ScriptedPathSource) { |
| + let source = ScriptedPathSource(status) |
| + let connectivity = NativeConnectivity(source: source) |
| + await settle { connectivity.path?.status == status } |
| + return (connectivity, source) |
| + } |
| + |
| + private func outbox(_ lanes: [(String, String)]) async throws -> Outbox { |
| + let outbox = Outbox(store: MemoryBlobStore(), account: "a") |
| + for (lane, text) in lanes { try await outbox.enqueue(lane: lane, kind: "u", payload: Data(text.utf8)) } |
| + return outbox |
| + } |
| + |
| + private func bodies(_ transport: ScriptedTransport) -> [String] { |
| + transport.committed.map { String(decoding: $0.body ?? Data(), as: UTF8.self) } |
| + } |
| + |
| + func testDrainsEachLaneInOrderAndAcknowledges() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let transport = ScriptedTransport([.respond(status: 204), .respond(status: 204)]) |
| + let outbox = try await outbox([("doc:a", "1"), ("doc:b", "x"), ("doc:a", "2")]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + let outcome = await drainer.drain() |
| + XCTAssertEqual(outcome, .idle) |
| + XCTAssertEqual(transport.committed.map(\.path), ["/doc:a", "/doc:b"]) |
| + XCTAssertEqual(bodies(transport), ["1,2", "x"], "One batch per lane, FIFO") |
| + let summary = await outbox.summary |
| + XCTAssertTrue(summary.isEmpty) |
| + XCTAssertEqual(drainer.status(), .saved) |
| + } |
| + |
| + func testBatchesAreCutByBytes() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let transport = ScriptedTransport([.respond(status: 204), .respond(status: 204)]) |
| + let outbox = try await outbox([("doc:a", "aaaa"), ("doc:a", "bbbb"), ("doc:a", "cccc")]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, maxBatchBytes: 8, send: poster()) |
| + await drainer.drain() |
| + XCTAssertEqual(bodies(transport), ["aaaa,bbbb", "cccc"]) |
| + } |
| + |
| + func testAPathDownMakesNoAttemptAndWaits() async throws { |
| + let (connectivity, _) = await connectivity(.unsatisfied) |
| + let transport = ScriptedTransport([]) |
| + let outbox = try await outbox([("doc:a", "1")]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + let outcome = await drainer.drain() |
| + XCTAssertEqual(outcome, .waitingForNetwork) |
| + XCTAssertEqual(transport.calls.count, 0, "An offline phone makes no attempts") |
| + XCTAssertEqual(drainer.status(), .offline(pending: 1)) |
| + } |
| + |
| + func testOfflineMidSendWithThePathDownWaitsForTheNetwork() async throws { |
| + let (connectivity, source) = await connectivity() |
| + let outbox = try await outbox([("doc:a", "1")]) |
| + let transport = ScriptedTransport(responder: { _ in |
| + source.send(.unsatisfied) |
| + return .offline(.notConnectedToInternet) |
| + }) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + await settle { connectivity.isPathSatisfied } |
| + let first = await drainer.drain() |
| + await settle { !connectivity.isPathSatisfied } |
| + // Whether the path update landed before or after the classification, |
| + // the next drain must not try again while the path is down. |
| + let second = await drainer.drain() |
| + XCTAssertEqual(second, .waitingForNetwork, "first was \(first)") |
| + XCTAssertEqual(transport.calls.count, 1) |
| + } |
| + |
| + /// M2: a lost response or timeout while the path stays up backs off and |
| + /// retries the same batch; it does not wait for a path change. |
| + func testALostResponseOnAWorkingPathBacksOffAndResendsTheSameBatch() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let transport = ScriptedTransport([.commitThenLoseResponse(), .respond(status: 204)]) |
| + let outbox = try await outbox([("doc:a", "1"), ("doc:a", "2")]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + let first = await drainer.drain() |
| + guard case .backingOff(let delay) = first else { return XCTFail("Expected a backoff, got \(first)") } |
| + XCTAssertGreaterThan(delay, 0) |
| + XCTAssertEqual(drainer.phase, .backingOff) |
| + XCTAssertEqual(drainer.status(), .retrying(pending: 1)) |
| + let kept = await outbox.entries(lane: "doc:a") |
| + XCTAssertEqual(kept.count, 2, "Nothing is acknowledged that the drainer did not see acknowledged") |
| + let outcome1 = await drainer.drain() |
| + XCTAssertEqual(outcome1, .idle) |
| + XCTAssertEqual(bodies(transport), ["1,2", "1,2"], "The same batch, sent again") |
| + } |
| + |
| + func testAnEndedSessionStopsEveryLaneAndHoldsNothing() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let transport = ScriptedTransport([.respond(status: 401), .respond(status: 204), .respond(status: 204)]) |
| + let outbox = try await outbox([("doc:a", "1"), ("doc:b", "2")]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + let outcome2 = await drainer.drain() |
| + XCTAssertEqual(outcome2, .sessionEnded) |
| + XCTAssertEqual(transport.calls.count, 1, "Every lane stops") |
| + let summary = await outbox.summary |
| + XCTAssertEqual(summary.held, 0, "Never hold or drop on 401") |
| + XCTAssertEqual(summary.pending, 2) |
| + XCTAssertEqual(drainer.status(), .needsSignIn(pending: 2)) |
| + let outcome3 = await drainer.drain() |
| + XCTAssertEqual(outcome3, .sessionEnded, "It stays stopped until signed in again") |
| + drainer.sessionRestored() |
| + let outcome4 = await drainer.drain() |
| + XCTAssertEqual(outcome4, .idle) |
| + XCTAssertEqual(transport.calls.count, 3) |
| + } |
| + |
| + func testNoSessionIsAnEndedSession() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let outbox = try await outbox([("doc:a", "1")]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { nil }, connectivity: connectivity, send: poster()) |
| + let outcome5 = await drainer.drain() |
| + XCTAssertEqual(outcome5, .sessionEnded) |
| + } |
| + |
| + func testRefusalsHoldTheirLaneAndOtherLanesKeepDraining() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let transport = ScriptedTransport { call in |
| + switch call.path { |
| + case "/doc:gone": return .respond(status: 403) |
| + case "/doc:big": return .respond(status: 413) |
| + case "/doc:bad": return .respond(status: 400) |
| + default: return .respond(status: 204) |
| + } |
| + } |
| + let outbox = try await outbox([("doc:gone", "1"), ("doc:big", "2"), ("doc:bad", "3"), ("doc:ok", "4")]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + let outcome6 = await drainer.drain() |
| + XCTAssertEqual(outcome6, .idle) |
| + let summary = await outbox.summary |
| + XCTAssertEqual(summary.lanes["doc:gone"]?.firstHold, .accessLost) |
| + XCTAssertEqual(summary.lanes["doc:big"]?.firstHold, .tooLarge) |
| + XCTAssertEqual(summary.lanes["doc:bad"]?.firstHold, .rejected("400")) |
| + XCTAssertNil(summary.lanes["doc:ok"], "Other lanes are not stopped by a hold") |
| + XCTAssertEqual(transport.calls.count, 4, "A held lane is not retried") |
| + XCTAssertEqual(drainer.status(lane: "doc:gone"), .failed(pending: 1, reason: .held(.accessLost))) |
| + XCTAssertEqual(drainer.status(lane: "doc:ok"), .saved) |
| + } |
| + |
| + func testRateLimitingIsAFloorOnTheDelay() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let transport = ScriptedTransport([.respond(status: 429, headers: ["retry-after": "30"])]) |
| + let outbox = try await outbox([("doc:a", "1")]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + let outcome = await drainer.drain() |
| + guard case .rateLimited(let delay) = outcome else { return XCTFail("Expected rate limiting, got \(outcome)") } |
| + XCTAssertGreaterThanOrEqual(delay, 30) |
| + } |
| + |
| + func testServerErrorsBackOff() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let transport = ScriptedTransport([.respond(status: 502)]) |
| + let outbox = try await outbox([("doc:a", "1")]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + let backedOff = await drainer.drain() |
| + guard case .backingOff = backedOff else { return XCTFail("Expected a backoff") } |
| + let summary = await outbox.summary |
| + XCTAssertEqual(summary.pending, 1) |
| + XCTAssertEqual(summary.held, 0) |
| + } |
| + |
| + /// H4: the send went through but the store locked before the ack. The |
| + /// batch stays queued and goes again; it is never lost. |
| + func testALockedStoreDuringTheAckIsARetry() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let disk = FaultingBlobStore() |
| + let outbox = Outbox(store: disk, account: "a") |
| + try await outbox.enqueue(lane: "doc:a", kind: "u", payload: Data("1".utf8)) |
| + let transport = ScriptedTransport([.respond(status: 204), .respond(status: 204)]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, |
| + send: poster { _, _ in disk.unavailable = true }) |
| + let backedOff = await drainer.drain() |
| + guard case .backingOff = backedOff else { return XCTFail("Expected a retry") } |
| + disk.unavailable = false |
| + let kept = await outbox.entries(lane: "doc:a") |
| + XCTAssertEqual(kept.count, 1) |
| + let again = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + let outcome7 = await again.drain() |
| + XCTAssertEqual(outcome7, .idle) |
| + XCTAssertEqual(transport.committed.count, 2) |
| + } |
| + |
| + func testAnUnavailableStoreWaitsAndDeletesNothing() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let disk = FaultingBlobStore() |
| + let setup = Outbox(store: disk, account: "a") |
| + try await setup.enqueue(lane: "doc:a", kind: "u", payload: Data("1".utf8)) |
| + disk.unavailable = true |
| + let outbox = Outbox(store: disk, account: "a") |
| + let transport = ScriptedTransport([]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + let outcome = await drainer.drain() |
| + XCTAssertTrue(outcome == .storeUnavailable || outcome == .idle, "\(outcome)") |
| + XCTAssertEqual(transport.calls.count, 0) |
| + disk.unavailable = false |
| + XCTAssertEqual(disk.committed.contents.count, 1) |
| + } |
| + |
| + func testAppDispositionsRetryHoldAndSkip() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let outbox = try await outbox([("doc:a", "1"), ("doc:a", "2"), ("doc:a", "3")]) |
| + let transport = ScriptedTransport([]) |
| + var round = 0 |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity) { _, entries, _ in |
| + round += 1 |
| + // Round 1: only the first could be sent (a merge fallback); the rest |
| + // are left out and go back without penalty. |
| + if round == 1 { return [entries[0].id: .sent] } |
| + return [entries[0].id: .hold(Hold(.rejected("no")))] |
| + } |
| + let outcome8 = await drainer.drain() |
| + XCTAssertEqual(outcome8, .idle) |
| + XCTAssertEqual(round, 2) |
| + let lane = await outbox.entries(lane: "doc:a") |
| + XCTAssertEqual(lane.map { String(decoding: $0.payload, as: UTF8.self) }, ["2", "3"]) |
| + XCTAssertEqual(lane.first?.hold, Hold(.rejected("no"))) |
| + XCTAssertEqual(lane.last?.attempts, 0, "A left-out entry is not a failed attempt") |
| + } |
| + |
| + /// The background loop: an enqueue kicks it, and a restored path wakes it |
| + /// with no timer running in between. |
| + func testTheLoopSendsOnKickAndOnReconnect() async throws { |
| + let (connectivity, source) = await connectivity() |
| + let transport = ScriptedTransport { _ in .respond(status: 204) } |
| + let outbox = Outbox(store: MemoryBlobStore(), account: "a") |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + drainer.start() |
| + defer { drainer.stop() } |
| + try await outbox.enqueue(lane: "doc:a", kind: "u", payload: Data("1".utf8)) |
| + drainer.kick() |
| + await settle { transport.committed.count == 1 } |
| + XCTAssertEqual(transport.committed.count, 1) |
| + |
| + source.send(.unsatisfied) |
| + await settle { !connectivity.isPathSatisfied } |
| + try await outbox.enqueue(lane: "doc:a", kind: "u", payload: Data("2".utf8)) |
| + drainer.kick() |
| + try await Task.sleep(nanoseconds: 100_000_000) |
| + XCTAssertEqual(transport.calls.count, 1, "Nothing is attempted while the path is down") |
| + await settle { drainer.status() == .offline(pending: 1) } |
| + XCTAssertEqual(drainer.status(), .offline(pending: 1)) |
| + source.send(.satisfied) |
| + await settle { transport.committed.count == 2 } |
| + XCTAssertEqual(bodies(transport), ["1", "2"]) |
| + await settle { drainer.summary.isEmpty } |
| + XCTAssertEqual(drainer.status(), .saved) |
| + } |
| +} |
diff --git a/Tests/RastrilloAppleTests/SealedStoreTests.swift b/Tests/RastrilloAppleTests/SealedStoreTests.swift| new file mode 100644 |
| index 0000000..d4be5cd |
| --- /dev/null |
| +++ b/Tests/RastrilloAppleTests/SealedStoreTests.swift |
| @@ -0,0 +1,175 @@ |
| +import CryptoKit |
| +import Foundation |
| +import RastrilloNative |
| +import RastrilloTestSupport |
| +import Security |
| +import XCTest |
| +@testable import RastrilloApple |
| + |
| +final class SealedStoreTests: XCTestCase { |
| + var directory: URL! |
| + |
| + override func setUpWithError() throws { |
| + directory = FileManager.default.temporaryDirectory.appendingPathComponent("sealed-\(UUID().uuidString)", isDirectory: true) |
| + } |
| + |
| + override func tearDownWithError() throws { |
| + try? FileManager.default.removeItem(at: directory) |
| + } |
| + |
| + private func store(_ account: String = "account-a", keys: KeyProvider) -> NativeSealedStore { |
| + NativeSealedStore(root: directory.appendingPathComponent(account, isDirectory: true), accountID: account, keys: keys) |
| + } |
| + |
| + private func file(_ account: String, _ path: String) -> URL { |
| + directory.appendingPathComponent(account).appendingPathComponent(path) |
| + } |
| + |
| + func testRoundTripIsSealedWithAFreshNonceAndBoundToItsPath() throws { |
| + let keys = ScriptedKeyProvider() |
| + let sealed = store(keys: keys) |
| + let secret = Data("the quarterly numbers".utf8) |
| + try sealed.write(secret, to: "outbox/0000000000000001.entry") |
| + XCTAssertEqual(try sealed.read("outbox/0000000000000001.entry"), secret) |
| + XCTAssertNotNil(keys.storedKey, "The key is created on first write") |
| + |
| + let onDisk = try Data(contentsOf: file("account-a", "outbox/0000000000000001.entry")) |
| + XCTAssertNil(onDisk.range(of: secret), "Plaintext must never reach the disk") |
| + XCTAssertEqual(onDisk.count, 12 + secret.count + 16, ".combined is nonce, ciphertext, tag") |
| + |
| + try sealed.write(secret, to: "outbox/0000000000000001.entry") |
| + let again = try Data(contentsOf: file("account-a", "outbox/0000000000000001.entry")) |
| + XCTAssertNotEqual(onDisk.prefix(12), again.prefix(12), "Every seal draws a fresh nonce") |
| + |
| + // The associated data is exactly accountID | format version | path. |
| + let box = try AES.GCM.SealedBox(combined: again) |
| + XCTAssertEqual(try AES.GCM.open(box, using: keys.storedKey!, authenticating: Data("account-a|1|outbox/0000000000000001.entry".utf8)), secret) |
| + XCTAssertEqual(try sealed.list("outbox"), ["0000000000000001.entry"]) |
| + XCTAssertNil(try sealed.read("outbox/missing.entry")) |
| + } |
| + |
| + func testATamperedFileFailsAndIsRemovedAlone() throws { |
| + let sealed = store(keys: ScriptedKeyProvider()) |
| + try sealed.write(Data("one".utf8), to: "cache/docs/a/replica") |
| + try sealed.write(Data("two".utf8), to: "cache/docs/b/replica") |
| + var bytes = try Data(contentsOf: file("account-a", "cache/docs/a/replica")) |
| + bytes[bytes.count - 1] ^= 0x01 |
| + try bytes.write(to: file("account-a", "cache/docs/a/replica")) |
| + |
| + XCTAssertThrowsError(try sealed.read("cache/docs/a/replica")) { XCTAssertEqual($0 as? BlobStoreError, .corrupt("cache/docs/a/replica")) } |
| + XCTAssertFalse(FileManager.default.fileExists(atPath: file("account-a", "cache/docs/a/replica").path)) |
| + XCTAssertEqual(try sealed.read("cache/docs/b/replica"), Data("two".utf8), "The directory is never removed for one file") |
| + } |
| + |
| + func testARenamedFileFailsToOpen() throws { |
| + let sealed = store(keys: ScriptedKeyProvider()) |
| + try sealed.write(Data("document A".utf8), to: "cache/docs/a/replica") |
| + try sealed.write(Data("document B".utf8), to: "cache/docs/b/replica") |
| + // Swap A's replica in as B's. |
| + try FileManager.default.removeItem(at: file("account-a", "cache/docs/b/replica")) |
| + try FileManager.default.copyItem(at: file("account-a", "cache/docs/a/replica"), to: file("account-a", "cache/docs/b/replica")) |
| + XCTAssertThrowsError(try sealed.read("cache/docs/b/replica")) { XCTAssertEqual($0 as? BlobStoreError, .corrupt("cache/docs/b/replica")) } |
| + XCTAssertEqual(try sealed.read("cache/docs/a/replica"), Data("document A".utf8)) |
| + } |
| + |
| + func testAFileFromAnotherAccountFailsToOpenEvenUnderTheSameKey() throws { |
| + let key = SymmetricKey(size: .bits256) |
| + let a = store("account-a", keys: ScriptedKeyProvider(key: key)) |
| + let b = store("account-b", keys: ScriptedKeyProvider(key: key)) |
| + try a.write(Data("A's entry".utf8), to: "outbox/0000000000000001.entry") |
| + try b.write(Data("B's entry".utf8), to: "outbox/0000000000000001.entry") |
| + try FileManager.default.removeItem(at: file("account-b", "outbox/0000000000000001.entry")) |
| + try FileManager.default.copyItem(at: file("account-a", "outbox/0000000000000001.entry"), to: file("account-b", "outbox/0000000000000001.entry")) |
| + XCTAssertThrowsError(try b.read("outbox/0000000000000001.entry")) { XCTAssertEqual($0 as? BlobStoreError, .corrupt("outbox/0000000000000001.entry")) } |
| + } |
| + |
| + func testALockedKeychainIsUnavailableAndDeletesNothing() throws { |
| + let keys = ScriptedKeyProvider() |
| + try store(keys: keys).write(Data("kept".utf8), to: "outbox/0000000000000001.entry") |
| + for status in [errSecInteractionNotAllowed, errSecAuthFailed, errSecNotAvailable, OSStatus(-1)] { |
| + keys.failCopies(with: status) |
| + let relaunched = store(keys: keys) // no cached key: it must ask the Keychain |
| + XCTAssertThrowsError(try relaunched.read("outbox/0000000000000001.entry"), "status \(status)") { |
| + XCTAssertEqual($0 as? BlobStoreError, .unavailable) |
| + } |
| + XCTAssertThrowsError(try relaunched.write(Data("new".utf8), to: "outbox/0000000000000002.entry")) { |
| + XCTAssertEqual($0 as? BlobStoreError, .unavailable) |
| + } |
| + XCTAssertTrue(FileManager.default.fileExists(atPath: file("account-a", "outbox/0000000000000001.entry").path), "status \(status) deleted a file") |
| + XCTAssertFalse(keys.log.contains("delete")) |
| + } |
| + keys.failCopies(with: nil) |
| + XCTAssertEqual(try store(keys: keys).read("outbox/0000000000000001.entry"), Data("kept".utf8), "Unlocking reads everything again") |
| + } |
| + |
| + func testALostKeyWithFilesPresentRemovesTheDirectoryOnce() throws { |
| + let old = store(keys: ScriptedKeyProvider()) |
| + try old.write(Data("sealed under a key that is gone".utf8), to: "outbox/0000000000000001.entry") |
| + try old.write(Data("cache".utf8), to: "cache/docs/a/replica") |
| + let fresh = ScriptedKeyProvider() // errSecItemNotFound |
| + let relaunched = store(keys: fresh) |
| + XCTAssertThrowsError(try relaunched.read("outbox/0000000000000001.entry")) { XCTAssertEqual($0 as? BlobStoreError, .keyLost) } |
| + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("account-a").path)) |
| + // After the loss, the store starts again with a new key. |
| + try relaunched.write(Data("new".utf8), to: "outbox/0000000000000002.entry") |
| + XCTAssertEqual(try relaunched.read("outbox/0000000000000002.entry"), Data("new".utf8)) |
| + XCTAssertNotNil(fresh.storedKey) |
| + } |
| + |
| + func testNoKeyAndNoFilesIsAFirstWriteNotALoss() throws { |
| + let keys = ScriptedKeyProvider() |
| + let sealed = store(keys: keys) |
| + XCTAssertNil(try sealed.read("outbox/0000000000000001.entry")) |
| + XCTAssertEqual(try sealed.list("outbox"), []) |
| + XCTAssertEqual(keys.log, [], "A missing file needs no key") |
| + try sealed.write(Data("first".utf8), to: "outbox/0000000000000001.entry") |
| + XCTAssertEqual(keys.log, ["copy", "add"]) |
| + } |
| + |
| + func testAFailedKeyCreationIsUnavailable() throws { |
| + let keys = ScriptedKeyProvider() |
| + keys.failAdds(with: errSecInteractionNotAllowed) |
| + XCTAssertThrowsError(try store(keys: keys).write(Data("x".utf8), to: "outbox/0000000000000001.entry")) { |
| + XCTAssertEqual($0 as? BlobStoreError, .unavailable) |
| + } |
| + } |
| + |
| + func testTheAccountDirectoryIsExcludedFromBackup() throws { |
| + let sealed = store(keys: ScriptedKeyProvider()) |
| + try sealed.write(Data("x".utf8), to: "outbox/0000000000000001.entry") |
| + let values = try directory.appendingPathComponent("account-a").resourceValues(forKeys: [.isExcludedFromBackupKey]) |
| + XCTAssertEqual(values.isExcludedFromBackup, true) |
| + #if os(iOS) |
| + XCTAssertTrue(NativeSealedStore.writeOptions.contains(.completeFileProtection)) |
| + #endif |
| + XCTAssertTrue(NativeSealedStore.writeOptions.contains(.atomic)) |
| + } |
| + |
| + func testWipeDeletesTheKeyFirstThenTheDirectory() throws { |
| + let keys = ScriptedKeyProvider() |
| + let sealed = store(keys: keys) |
| + try sealed.write(Data("x".utf8), to: "outbox/0000000000000001.entry") |
| + try sealed.wipe() |
| + XCTAssertNil(keys.storedKey) |
| + XCTAssertEqual(keys.log.last, "delete") |
| + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("account-a").path)) |
| + } |
| + |
| + func testPathsCannotEscapeTheAccountDirectory() { |
| + let sealed = store(keys: ScriptedKeyProvider()) |
| + for path in ["../other/outbox/1.entry", "/etc/passwd", "outbox/../../x", ""] { |
| + XCTAssertThrowsError(try sealed.write(Data(), to: path), path) |
| + } |
| + } |
| + |
| + /// The outbox over the real sealed store survives a relaunch, and an |
| + /// entry copied from another account cannot be smuggled into this one. |
| + func testAnOutboxRelaunchesFromSealedFiles() async throws { |
| + let keys = ScriptedKeyProvider() |
| + let outbox = Outbox(store: store(keys: keys), account: "account-a") |
| + let entry = try await outbox.enqueue(lane: "doc:1", kind: "update", payload: Data("typed".utf8)) |
| + let relaunched = Outbox(store: store(keys: keys), account: "account-a") |
| + let lane = await relaunched.entries(lane: "doc:1") |
| + XCTAssertEqual(lane.map(\.id), [entry.id]) |
| + } |
| +} |
diff --git a/Tests/RastrilloAppleTests/SyncStringsTests.swift b/Tests/RastrilloAppleTests/SyncStringsTests.swift| new file mode 100644 |
| index 0000000..f784e59 |
| --- /dev/null |
| +++ b/Tests/RastrilloAppleTests/SyncStringsTests.swift |
| @@ -0,0 +1,63 @@ |
| +import Foundation |
| +import RastrilloNative |
| +import XCTest |
| +@testable import RastrilloApple |
| + |
| +final class SyncStringsTests: XCTestCase { |
| + static let languages = ["ar", "bn", "en", "es", "ga", "hi", "ja", "pt", "ru", "vi", "yue", "zh-Hans"] |
| + static let keys = ["sync.saving", "sync.retrying", "sync.offline", "sync.needsSignIn", "sync.signIn", |
| + "sync.failed.cannotSave", "sync.failed.tooManyUnsent", "sync.failed.held", |
| + "offline.waiting", "offline.durable", "sync.unsentLost"] |
| + |
| + /// The catalog ships in the same twelve languages as the apps, and no |
| + /// language is missing a string: a missing one would show its key. |
| + func testEveryLanguageHasEveryString() throws { |
| + for language in Self.languages { |
| + let strings = try XCTUnwrap(Bundle.module.path(forResource: "Localizable", ofType: "strings", inDirectory: nil, forLocalization: language), language) |
| + let table = try XCTUnwrap(NSDictionary(contentsOfFile: strings) as? [String: String], language) |
| + XCTAssertEqual(Set(table.keys), Set(Self.keys), language) |
| + for (key, value) in table { XCTAssertFalse(value.isEmpty, "\(language) \(key)") } |
| + let plurals = try XCTUnwrap(Bundle.module.path(forResource: "Localizable", ofType: "stringsdict", inDirectory: nil, forLocalization: language), language) |
| + let rules = try XCTUnwrap(NSDictionary(contentsOfFile: plurals) as? [String: Any], language) |
| + XCTAssertEqual(Set(rules.keys), ["sync.offline", "sync.needsSignIn"], language) |
| + for key in rules.keys { |
| + let rule = try XCTUnwrap((rules[key] as? [String: Any])?["n"] as? [String: String], "\(language) \(key)") |
| + XCTAssertNotNil(rule["other"], "\(language) \(key) needs an other form") |
| + for (category, text) in rule where !category.hasPrefix("NSString") { |
| + XCTAssertTrue(text.contains("%d"), "\(language) \(key) \(category) drops the count") |
| + } |
| + } |
| + } |
| + } |
| + |
| + func testEnglishWording() { |
| + let strings = NativeSyncStrings.kit(localization: "en") |
| + XCTAssertNil(strings.text(for: .saved), "Saved is silent") |
| + XCTAssertEqual(strings.text(for: .saving), "Saving…") |
| + XCTAssertEqual(strings.text(for: .offline(pending: 1)), "Offline · 1 pending") |
| + XCTAssertEqual(strings.text(for: .offline(pending: 3)), "Offline · 3 pending") |
| + XCTAssertEqual(strings.text(for: .retrying(pending: 2)), "Trouble saving · Retrying…") |
| + XCTAssertEqual(strings.text(for: .needsSignIn(pending: 2)), "Sign in to send 2 pending") |
| + XCTAssertEqual(strings.text(for: .failed(pending: 1, reason: .tooManyUnsent)), |
| + "Too many unsent changes to keep on this device. Connect to save.") |
| + XCTAssertEqual(strings.text(for: .failed(pending: 1, reason: .cannotSaveOnDevice)), "Can’t save on this device.") |
| + XCTAssertEqual(strings.offlineDurable, |
| + "You’re offline. Changes are saved on this device and will be sent when you reconnect.") |
| + XCTAssertEqual(strings.offlineWaiting, "You’re offline. Waiting for a connection.") |
| + } |
| + |
| + func testPluralRulesAreApplied() { |
| + let russian = NativeSyncStrings.kit(localization: "ru") |
| + XCTAssertEqual(russian.text(for: .offline(pending: 1)), "Не в сети · 1 ожидает отправки") |
| + XCTAssertEqual(russian.text(for: .offline(pending: 3)), "Не в сети · 3 ожидают отправки") |
| + let spanish = NativeSyncStrings.kit(localization: "es") |
| + XCTAssertEqual(spanish.text(for: .offline(pending: 1)), "Sin conexión · 1 pendiente") |
| + XCTAssertEqual(spanish.text(for: .offline(pending: 2)), "Sin conexión · 2 pendientes") |
| + } |
| + |
| + func testAnAppOverridesWithACopy() { |
| + var strings = NativeSyncStrings.kit(localization: "en") |
| + strings.offline = { "Offline · \($0) cells pending" } |
| + XCTAssertEqual(strings.text(for: .offline(pending: 4)), "Offline · 4 cells pending") |
| + } |
| +} |
diff --git a/Tests/RastrilloNativeTests/OutboxTests.swift b/Tests/RastrilloNativeTests/OutboxTests.swift| new file mode 100644 |
| index 0000000..ff23d4c |
| --- /dev/null |
| +++ b/Tests/RastrilloNativeTests/OutboxTests.swift |
| @@ -0,0 +1,309 @@ |
| +import Foundation |
| +import RastrilloNative |
| +import RastrilloTestSupport |
| +import XCTest |
| + |
| +private func bytes(_ text: String) -> Data { Data(text.utf8) } |
| + |
| +/// Answers `.corrupt` for chosen paths, removing them, as the sealed store does. |
| +private final class CorruptingStore: BlobStore, @unchecked Sendable { |
| + let base: MemoryBlobStore |
| + let corrupt: Set<String> |
| + init(_ base: MemoryBlobStore, corrupt: Set<String>) { self.base = base; self.corrupt = corrupt } |
| + func write(_ data: Data, to path: String) throws { try base.write(data, to: path) } |
| + func read(_ path: String) throws -> Data? { |
| + if corrupt.contains(path) { try base.remove(path); throw BlobStoreError.corrupt(path) } |
| + return try base.read(path) |
| + } |
| + func list(_ directory: String) throws -> [String] { try base.list(directory) } |
| + func remove(_ path: String) throws { try base.remove(path) } |
| +} |
| + |
| +/// Answers `.keyLost` on the first read and removes everything, as the |
| +/// sealed store does when the Keychain has lost the key. |
| +private final class KeyLosingStore: BlobStore, @unchecked Sendable { |
| + let base: MemoryBlobStore |
| + init(_ base: MemoryBlobStore) { self.base = base } |
| + func write(_ data: Data, to path: String) throws { try base.write(data, to: path) } |
| + func read(_ path: String) throws -> Data? { |
| + guard try base.read(path) != nil else { return nil } |
| + try base.remove("") |
| + throw BlobStoreError.keyLost |
| + } |
| + func list(_ directory: String) throws -> [String] { try base.list(directory) } |
| + func remove(_ path: String) throws { try base.remove(path) } |
| +} |
| + |
| +final class OutboxTests: XCTestCase { |
| + func testEnqueueIsDurableOnReturnAndSeqResumesAfterTheHighest() async throws { |
| + let disk = MemoryBlobStore() |
| + let outbox = Outbox(store: disk, account: "a") |
| + let first = try await outbox.enqueue(lane: "doc:1", kind: "update", payload: bytes("one")) |
| + let second = try await outbox.enqueue(lane: "doc:1", kind: "update", payload: bytes("two")) |
| + XCTAssertEqual(disk.contents.keys.sorted(), ["outbox/0000000000000001.entry", "outbox/0000000000000002.entry"]) |
| + |
| + let relaunched = Outbox(store: MemoryBlobStore(disk.contents), account: "a") |
| + let entries = await relaunched.entries(lane: "doc:1") |
| + XCTAssertEqual(entries.map(\.id), [first.id, second.id]) |
| + XCTAssertEqual(entries.map(\.payload), [bytes("one"), bytes("two")]) |
| + let third = try await relaunched.enqueue(lane: "doc:1", kind: "update", payload: bytes("three")) |
| + XCTAssertEqual(third.seq, 3, "seq is recovered as max + 1") |
| + } |
| + |
| + func testLanesAreFIFOAndIndependentAndBatchesAreCutByBytes() async throws { |
| + let outbox = Outbox(store: MemoryBlobStore(), account: "a") |
| + for index in 0..<4 { try await outbox.enqueue(lane: "doc:a", kind: "u", payload: Data(repeating: UInt8(index), count: 100)) } |
| + try await outbox.enqueue(lane: "doc:b", kind: "u", payload: bytes("b")) |
| + let batch = await outbox.sendable(lane: "doc:a", maxBytes: 250) |
| + XCTAssertEqual(batch.map(\.seq), [1, 2], "Cut by bytes, in seq order") |
| + let oversized = await outbox.sendable(lane: "doc:a", maxBytes: 10) |
| + XCTAssertEqual(oversized.map(\.seq), [1], "A batch always carries at least one entry") |
| + let other = await outbox.sendable(lane: "doc:b", maxBytes: nil) |
| + XCTAssertEqual(other.map(\.payload), [bytes("b")]) |
| + } |
| + |
| + func testAHoldStopsItsOwnLaneOnly() async throws { |
| + let outbox = Outbox(store: MemoryBlobStore(), account: "a") |
| + let first = try await outbox.enqueue(lane: "doc:a", kind: "u", payload: bytes("1")) |
| + try await outbox.enqueue(lane: "doc:a", kind: "u", payload: bytes("2")) |
| + try await outbox.enqueue(lane: "doc:b", kind: "u", payload: bytes("3")) |
| + try await outbox.hold([first.id], Hold(.accessLost)) |
| + let held = await outbox.sendable(lane: "doc:a", maxBytes: nil) |
| + XCTAssertEqual(held, [], "Nothing passes a hold: Docs updates depend on the ones before them") |
| + let other = await outbox.sendable(lane: "doc:b", maxBytes: nil) |
| + XCTAssertEqual(other.count, 1) |
| + let summary = await outbox.summary |
| + XCTAssertEqual(summary.lanes["doc:a"]?.held, 1) |
| + XCTAssertEqual(summary.lanes["doc:a"]?.pending, 1) |
| + XCTAssertEqual(summary.lanes["doc:a"]?.firstHold, .accessLost) |
| + try await outbox.release(first.id) |
| + let released = await outbox.sendable(lane: "doc:a", maxBytes: nil) |
| + XCTAssertEqual(released.count, 2) |
| + } |
| + |
| + func testReplaceKeepsTheOldestSeqAndTakesANewID() async throws { |
| + let disk = MemoryBlobStore() |
| + let outbox = Outbox(store: disk, account: "a") |
| + var ids: [UUID] = [] |
| + for text in ["a", "b", "c"] { ids.append(try await outbox.enqueue(lane: "doc:1", kind: "update", payload: bytes(text)).id) } |
| + let after = try await outbox.enqueue(lane: "doc:1", kind: "update", payload: bytes("d")) |
| + let merged = try await outbox.replace(Array(ids[0...2]), with: bytes("abc")) |
| + XCTAssertEqual(merged.seq, 1) |
| + XCTAssertFalse(ids.contains(merged.id), "The merged entry gets a new id") |
| + XCTAssertEqual(merged.lane, "doc:1") |
| + XCTAssertEqual(merged.kind, "update") |
| + let lane = await outbox.entries(lane: "doc:1") |
| + XCTAssertEqual(lane.map(\.payload), [bytes("abc"), bytes("d")], "Lane order is unchanged") |
| + XCTAssertEqual(lane.last?.id, after.id) |
| + XCTAssertEqual(disk.contents.keys.sorted(), ["outbox/0000000000000001.entry", "outbox/0000000000000004.entry"]) |
| + } |
| + |
| + func testReplaceRefusesInFlightHeldGappedAndCrossLaneRuns() async throws { |
| + let outbox = Outbox(store: MemoryBlobStore(), account: "a") |
| + var a: [UUID] = [] |
| + for index in 0..<4 { |
| + a.append(try await outbox.enqueue(lane: "doc:a", kind: "u", payload: Data(repeating: 1, count: 10)).id) |
| + // Another lane interleaves in seq; that does not break a run of doc:a. |
| + try await outbox.enqueue(lane: "doc:b", kind: "u", payload: bytes("b\(index)")) |
| + } |
| + let b = await outbox.entries(lane: "doc:b").map(\.id) |
| + let inFlight = try await outbox.beginSend(lane: "doc:a", maxBytes: 15) |
| + XCTAssertEqual(inFlight.map(\.id), [a[0]]) |
| + |
| + await assertThrows(OutboxError.inFlight) { try await outbox.replace([a[0], a[1]], with: Data()) } |
| + await assertThrows(OutboxError.notContiguous) { try await outbox.replace([a[1], a[3]], with: Data()) } |
| + await assertThrows(OutboxError.notContiguous) { try await outbox.replace([a[1], b[1]], with: Data()) } |
| + try await outbox.hold([a[3]], Hold(.tooLarge)) |
| + await assertThrows(OutboxError.held) { try await outbox.replace([a[2], a[3]], with: Data()) } |
| + |
| + let merged = try await outbox.replace([a[1], a[2]], with: bytes("merged")) |
| + XCTAssertEqual(merged.seq, 3, "A run of doc:a skips over doc:b's seqs") |
| + try await outbox.ack([a[0]]) |
| + let lane = await outbox.entries(lane: "doc:a") |
| + XCTAssertEqual(lane.map(\.id), [merged.id, a[3]], "Acking the in-flight head never touches the merged entry") |
| + } |
| + |
| + func testACrashBetweenReplaceStepsLeavesDuplicatesNeverAGap() async throws { |
| + let base = MemoryBlobStore() |
| + let setup = Outbox(store: base, account: "a") |
| + var ids: [UUID] = [] |
| + for text in ["a", "b", "c", "d"] { ids.append(try await setup.enqueue(lane: "doc:1", kind: "u", payload: bytes(text)).id) } |
| + |
| + let faulting = FaultingBlobStore(MemoryBlobStore(base.contents)) |
| + let outbox = Outbox(store: faulting, account: "a") |
| + try await outbox.load() |
| + faulting.crash(after: 1) // step 1 (the write) happens; the first remove does not |
| + do { |
| + try await outbox.replace(Array(ids[0...2]), with: bytes("abc")) |
| + XCTFail("Expected the simulated crash") |
| + } catch is SimulatedCrash {} |
| + |
| + let relaunched = Outbox(store: faulting.committed, account: "a") |
| + let lane = await relaunched.entries(lane: "doc:1") |
| + XCTAssertEqual(lane.map(\.payload), [bytes("abc"), bytes("b"), bytes("c"), bytes("d")], |
| + "Duplicates only: the merged head, then what it merged, then the rest in order") |
| + } |
| + |
| + func testAckDiscardAndPurgeRemoveFiles() async throws { |
| + let disk = MemoryBlobStore() |
| + let outbox = Outbox(store: disk, account: "a") |
| + let one = try await outbox.enqueue(lane: "doc:a", kind: "u", payload: bytes("1")) |
| + try await outbox.enqueue(lane: "doc:b", kind: "u", payload: bytes("2")) |
| + try await outbox.enqueue(lane: "doc:c", kind: "u", payload: bytes("3")) |
| + try await outbox.ack([one.id, UUID()]) |
| + try await outbox.discard(lane: "doc:b") |
| + XCTAssertEqual(disk.contents.count, 1) |
| + try await outbox.purge() |
| + XCTAssertEqual(disk.contents.count, 0) |
| + let summary = await outbox.summary |
| + XCTAssertTrue(summary.isEmpty) |
| + } |
| + |
| + func testAnUnavailableStoreIsNeverAnEmptyStore() async throws { |
| + let base = MemoryBlobStore() |
| + try await Outbox(store: base, account: "a").enqueue(lane: "doc:1", kind: "u", payload: bytes("kept")) |
| + let faulting = FaultingBlobStore(base) |
| + faulting.unavailable = true |
| + let outbox = Outbox(store: faulting, account: "a") |
| + await assertThrows(BlobStoreError.unavailable) { try await outbox.load() } |
| + await assertThrows(BlobStoreError.unavailable) { try await outbox.enqueue(lane: "doc:1", kind: "u", payload: Data()) } |
| + let summary = await outbox.summary |
| + XCTAssertTrue(summary.unavailable) |
| + XCTAssertEqual(base.contents.count, 1, "Nothing was deleted while locked") |
| + XCTAssertFalse(faulting.operations.contains { if case .remove = $0 { return true } else { return false } }) |
| + |
| + faulting.unavailable = false |
| + try await outbox.load() |
| + let lane = await outbox.entries(lane: "doc:1") |
| + XCTAssertEqual(lane.map(\.payload), [bytes("kept")]) |
| + let after = await outbox.summary |
| + XCTAssertFalse(after.unavailable) |
| + } |
| + |
| + func testOneCorruptEntryIsRemovedAloneAndReported() async throws { |
| + let base = MemoryBlobStore() |
| + let setup = Outbox(store: base, account: "a") |
| + for text in ["1", "2", "3"] { try await setup.enqueue(lane: "doc:1", kind: "u", payload: bytes(text)) } |
| + let outbox = Outbox(store: CorruptingStore(base, corrupt: ["outbox/0000000000000002.entry"]), account: "a") |
| + let lane = await outbox.entries(lane: "doc:1") |
| + XCTAssertEqual(lane.map(\.payload), [bytes("1"), bytes("3")]) |
| + XCTAssertEqual(base.contents.count, 2, "Only the corrupt file went") |
| + var summary = await outbox.summary |
| + XCTAssertTrue(summary.lostUnsent) |
| + await outbox.acknowledgeLoss() |
| + summary = await outbox.summary |
| + XCTAssertFalse(summary.lostUnsent, "Reported once") |
| + } |
| + |
| + func testALostKeyEmptiesTheOutboxAndSaysSoOnce() async throws { |
| + let base = MemoryBlobStore() |
| + try await Outbox(store: base, account: "a").enqueue(lane: "doc:1", kind: "u", payload: bytes("gone")) |
| + let outbox = Outbox(store: KeyLosingStore(base), account: "a") |
| + try await outbox.load() |
| + let summary = await outbox.summary |
| + XCTAssertTrue(summary.isEmpty) |
| + XCTAssertTrue(summary.lostUnsent) |
| + try await outbox.enqueue(lane: "doc:1", kind: "u", payload: bytes("new")) |
| + let lane = await outbox.entries(lane: "doc:1") |
| + XCTAssertEqual(lane.map(\.payload), [bytes("new")]) |
| + } |
| + |
| + func testChangesDeliverTheCurrentSummaryThenEachChange() async throws { |
| + let outbox = Outbox(store: MemoryBlobStore(), account: "a") |
| + let changes = await outbox.changes |
| + var iterator = changes.makeAsyncIterator() |
| + let first = await iterator.next() |
| + XCTAssertEqual(first?.pending, 0) |
| + try await outbox.enqueue(lane: "doc:1", kind: "u", payload: bytes("x")) |
| + let second = await iterator.next() |
| + XCTAssertEqual(second?.pending, 1) |
| + } |
| + |
| + /// Random enqueues, sends, acks, compactions and crashes. After every |
| + /// relaunch: every entry whose enqueue returned and was never acked is |
| + /// still there, in order, and each lane's content is a |
| + /// duplicates-allowed replay of what was enqueued. |
| + func testRandomCrashesNeverLoseOrReorderAnEntry() async throws { |
| + var generator = SeededGenerator(seed: 0x6b33) |
| + for _ in 0..<150 { |
| + var disk = MemoryBlobStore() |
| + var faulting = FaultingBlobStore(disk) |
| + var outbox = Outbox(store: faulting, account: "a") |
| + var expected: [String: [String]] = [:] // lane -> symbols not yet acked, in order |
| + var acked: Set<String> = [] |
| + var symbol = 0 |
| + for _ in 0..<40 { |
| + let lane = ["doc:a", "doc:b"][Int.random(in: 0...1, using: &generator)] |
| + if Int.random(in: 0..<12, using: &generator) == 0 { faulting.crash(after: Int.random(in: 0...2, using: &generator)) } |
| + do { |
| + switch Int.random(in: 0..<10, using: &generator) { |
| + case 0..<5: |
| + symbol += 1 |
| + let text = "s\(symbol)" |
| + try await outbox.enqueue(lane: lane, kind: "u", payload: bytes(text)) |
| + expected[lane, default: []].append(text) |
| + case 5..<7: |
| + let batch = try await outbox.beginSend(lane: lane, maxBytes: 8) |
| + guard !batch.isEmpty else { continue } |
| + // The server has the batch before the ack starts: a crash part |
| + // way through the ack loses nothing the server lacks. |
| + for entry in batch { |
| + let text = String(decoding: entry.payload, as: UTF8.self) |
| + for part in text.split(separator: "+") { acked.insert(String(part)) } |
| + } |
| + expected[lane] = expected[lane, default: []].filter { !acked.contains($0) } |
| + try await outbox.ack(batch.map(\.id)) |
| + case 7..<9: |
| + let run = await outbox.sendable(lane: lane, maxBytes: nil) |
| + guard run.count >= 2 else { continue } |
| + let take = Array(run.prefix(Int.random(in: 2...run.count, using: &generator))) |
| + let joined = take.map { String(decoding: $0.payload, as: UTF8.self) }.joined(separator: "+") |
| + try await outbox.replace(take.map(\.id), with: bytes(joined)) |
| + default: |
| + let batch = try await outbox.beginSend(lane: lane, maxBytes: nil) |
| + await outbox.endSend(batch.map(\.id)) |
| + } |
| + } catch is SimulatedCrash { |
| + disk = faulting.committed |
| + faulting = FaultingBlobStore(disk) |
| + outbox = Outbox(store: faulting, account: "a") |
| + } |
| + // The invariant, checked against a fresh reading of the disk. |
| + let reread = Outbox(store: MemoryBlobStore(faulting.committed.contents), account: "a") |
| + for (lane, symbols) in expected { |
| + let present = await reread.entries(lane: lane).flatMap { |
| + String(decoding: $0.payload, as: UTF8.self).split(separator: "+").map(String.init) |
| + } |
| + let unacked = present.filter { !acked.contains($0) } |
| + var seen: [String] = [] |
| + for item in unacked where seen.last != item && !seen.contains(item) { seen.append(item) } |
| + XCTAssertEqual(seen, symbols, "lane \(lane): lost or reordered an entry") |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +struct SeededGenerator: RandomNumberGenerator { |
| + private var state: UInt64 |
| + init(seed: UInt64) { state = seed } |
| + mutating func next() -> UInt64 { |
| + state &+= 0x9E3779B97F4A7C15 |
| + var z = state |
| + z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9 |
| + z = (z ^ (z >> 27)) &* 0x94D049BB133111EB |
| + return z ^ (z >> 31) |
| + } |
| +} |
| + |
| +func assertThrows<E: Error & Equatable>(_ expected: E, file: StaticString = #filePath, line: UInt = #line, |
| + _ body: () async throws -> some Any) async { |
| + do { |
| + _ = try await body() |
| + XCTFail("Expected \(expected)", file: file, line: line) |
| + } catch let error as E { |
| + XCTAssertEqual(error, expected, file: file, line: line) |
| + } catch { |
| + XCTFail("Expected \(expected), got \(error)", file: file, line: line) |
| + } |
| +} |
diff --git a/Tests/RastrilloNativeTests/SyncStatusTests.swift b/Tests/RastrilloNativeTests/SyncStatusTests.swift| new file mode 100644 |
| index 0000000..ca3c9c8 |
| --- /dev/null |
| +++ b/Tests/RastrilloNativeTests/SyncStatusTests.swift |
| @@ -0,0 +1,71 @@ |
| +import Foundation |
| +import RastrilloNative |
| +import XCTest |
| + |
| +private func summary(_ lanes: [String: (pending: Int, held: Int, inFlight: Int)], hold: Hold.Reason? = nil) -> OutboxSummary { |
| + var summary = OutboxSummary() |
| + for (name, counts) in lanes { |
| + var lane = OutboxSummary.Lane() |
| + lane.pending = counts.pending; lane.held = counts.held; lane.inFlight = counts.inFlight |
| + if counts.held > 0 { lane.firstHold = hold ?? .accessLost } |
| + summary.lanes[name] = lane |
| + } |
| + return summary |
| +} |
| + |
| +final class SyncStatusTests: XCTestCase { |
| + func testPriorityOrder() { |
| + let pending = summary(["doc:a": (2, 0, 0), "doc:b": (1, 0, 0)]) |
| + let held = summary(["doc:a": (1, 1, 0)], hold: .tooLarge) |
| + let flying = summary(["doc:a": (2, 0, 1)]) |
| + |
| + // 1. A hold beats everything, even an ended session. |
| + XCTAssertEqual(SyncStatus(summary: held, sending: true, sessionEnded: true, offline: true, backingOff: true), |
| + .failed(pending: 1, reason: .held(.tooLarge))) |
| + // A local failure is failed too. |
| + XCTAssertEqual(SyncStatus(summary: pending, sending: false, sessionEnded: false, offline: false, backingOff: false, localFailure: .cannotSaveOnDevice), |
| + .failed(pending: 2, reason: .cannotSaveOnDevice)) |
| + // 2. Session ended. |
| + XCTAssertEqual(SyncStatus(summary: pending, sending: false, sessionEnded: true, offline: true, backingOff: true), .needsSignIn(pending: 2)) |
| + // 3. Nothing to send. |
| + XCTAssertEqual(SyncStatus(summary: OutboxSummary(), sending: false, sessionEnded: false, offline: false, backingOff: false), .saved) |
| + // 4. In flight. |
| + XCTAssertEqual(SyncStatus(summary: flying, sending: true, sessionEnded: false, offline: true, backingOff: false), .saving) |
| + // 5. Offline. |
| + XCTAssertEqual(SyncStatus(summary: pending, sending: false, sessionEnded: false, offline: true, backingOff: true), .offline(pending: 2)) |
| + // 6. Backing off. |
| + XCTAssertEqual(SyncStatus(summary: pending, sending: false, sessionEnded: false, offline: false, backingOff: true), .retrying(pending: 2)) |
| + // Sendable, online, not yet started: about to save, never "saved". |
| + XCTAssertEqual(SyncStatus(summary: pending, sending: false, sessionEnded: false, offline: false, backingOff: false), .saving) |
| + } |
| + |
| + func testOfflineWithNothingPendingIsSilentlySaved() { |
| + let status = SyncStatus(summary: OutboxSummary(), sending: false, sessionEnded: false, offline: true, backingOff: false) |
| + XCTAssertEqual(status, .saved, "\"Offline · 0 pending\" must never appear") |
| + XCTAssertEqual(SyncStatus(summary: OutboxSummary(), sending: false, sessionEnded: true, offline: true, backingOff: false), .saved, |
| + "\"Sign in to send 0 pending\" must never appear either") |
| + } |
| + |
| + func testNCountsLanesByDefaultAndWhatTheAppChooses() { |
| + let lanes = summary(["doc:a": (5, 0, 0), "doc:b": (1, 0, 0)]) |
| + XCTAssertEqual(SyncStatus(summary: lanes, sending: false, sessionEnded: false, offline: true, backingOff: false), .offline(pending: 2)) |
| + XCTAssertEqual(SyncStatus(summary: lanes, sending: false, sessionEnded: false, offline: true, backingOff: false, count: { $0.pending }), |
| + .offline(pending: 6)) |
| + } |
| + |
| + func testSaveStateMappingStickinessAndAnnouncements() { |
| + XCTAssertEqual(SyncStatus.saved.saveState, .saved) |
| + XCTAssertEqual(SyncStatus.saving.saveState, .saving) |
| + XCTAssertEqual(SyncStatus.offline(pending: 1).saveState, .retrying, "The Slides precedent: offline is retrying") |
| + XCTAssertEqual(SyncStatus.retrying(pending: 1).saveState, .retrying) |
| + XCTAssertEqual(SyncStatus.needsSignIn(pending: 1).saveState, .failed) |
| + XCTAssertEqual(SyncStatus.failed(pending: 1, reason: .tooManyUnsent).saveState, .failed) |
| + XCTAssertTrue(SyncStatus.needsSignIn(pending: 1).isSticky) |
| + XCTAssertTrue(SyncStatus.failed(pending: 1, reason: .cannotSaveOnDevice).isSticky) |
| + XCTAssertFalse(SyncStatus.offline(pending: 1).isSticky) |
| + XCTAssertFalse(SyncStatus.saving.isAnnounced, "A saving flicker is never announced") |
| + XCTAssertTrue(SyncStatus.offline(pending: 1).isAnnounced) |
| + XCTAssertTrue(SyncStatus.needsSignIn(pending: 1).isAnnounced) |
| + XCTAssertTrue(SyncStatus.failed(pending: 1, reason: .tooManyUnsent).isAnnounced) |
| + } |
| +} |
diff --git a/Tests/RastrilloTestSupportTests/FaultingBlobStoreTests.swift b/Tests/RastrilloTestSupportTests/FaultingBlobStoreTests.swift| new file mode 100644 |
| index 0000000..d420876 |
| --- /dev/null |
| +++ b/Tests/RastrilloTestSupportTests/FaultingBlobStoreTests.swift |
| @@ -0,0 +1,48 @@ |
| +import Foundation |
| +import RastrilloNative |
| +import RastrilloTestSupport |
| +import XCTest |
| + |
| +final class FaultingBlobStoreTests: XCTestCase { |
| + func testACrashFreezesTheCommittedViewAndEverythingAfterFails() throws { |
| + let store = FaultingBlobStore(crashAt: 2) |
| + try store.write(Data("a".utf8), to: "x/1") |
| + try store.write(Data("b".utf8), to: "x/2") |
| + XCTAssertThrowsError(try store.write(Data("c".utf8), to: "x/3")) { XCTAssertEqual($0 as? SimulatedCrash, SimulatedCrash(operation: 2)) } |
| + XCTAssertThrowsError(try store.read("x/1"), "A dead process does nothing more") { XCTAssertTrue($0 is SimulatedCrash) } |
| + XCTAssertTrue(store.hasCrashed) |
| + XCTAssertEqual(store.committed.contents.keys.sorted(), ["x/1", "x/2"], "The crashed write never happened") |
| + XCTAssertEqual(store.operations, [.write("x/1"), .write("x/2")]) |
| + } |
| + |
| + func testCrashAfterCountsFromNow() throws { |
| + let store = FaultingBlobStore() |
| + try store.write(Data("a".utf8), to: "x/1") |
| + store.crash(after: 1) |
| + try store.remove("x/1") |
| + XCTAssertThrowsError(try store.list("x")) |
| + XCTAssertEqual(store.committed.contents, [:]) |
| + } |
| + |
| + func testUnavailableAnswersEveryOperationAndChangesNothing() throws { |
| + let store = FaultingBlobStore() |
| + try store.write(Data("a".utf8), to: "x/1") |
| + store.unavailable = true |
| + XCTAssertThrowsError(try store.read("x/1")) { XCTAssertEqual($0 as? BlobStoreError, .unavailable) } |
| + XCTAssertThrowsError(try store.remove("x/1")) { XCTAssertEqual($0 as? BlobStoreError, .unavailable) } |
| + XCTAssertThrowsError(try store.list("x")) { XCTAssertEqual($0 as? BlobStoreError, .unavailable) } |
| + store.unavailable = false |
| + XCTAssertEqual(try store.read("x/1"), Data("a".utf8)) |
| + } |
| + |
| + func testMemoryStoreListsOneLevelAndRemovesDirectories() throws { |
| + let store = MemoryBlobStore() |
| + try store.write(Data(), to: "outbox/1.entry") |
| + try store.write(Data(), to: "outbox/2.entry") |
| + try store.write(Data(), to: "cache/docs/a/replica") |
| + XCTAssertEqual(try store.list("outbox"), ["1.entry", "2.entry"]) |
| + XCTAssertEqual(try store.list("cache/docs"), []) |
| + try store.remove("cache") |
| + XCTAssertEqual(store.contents.keys.sorted(), ["outbox/1.entry", "outbox/2.entry"]) |
| + } |
| +} |