apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README

commit f87bc1bc95cc3b6d1d8309a6c5de5eb6b2d13cd0
parent d03a020cf684a868ec643e162acab241e9370dc4
Author: triesap <tyson@radroots.org>
Date:   Wed, 17 Jun 2026 17:09:52 -0700

apple-kit: add background transfer contracts

- add transfer request, snapshot, progress, and store contracts
- add file-root and staged-blob resolver support
- persist transfer snapshots through AppleKit file roots
- cover fake transfer behavior and recovery tests

Diffstat:
ASources/RadrootsKit/RadrootsBackgroundTransfer.swift | 405+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MSources/RadrootsKit/RadrootsFileAccess.swift | 14+++++++++-----
ASources/RadrootsKitTesting/RadrootsBackgroundTransferTesting.swift | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTestingTests/RadrootsBackgroundTransferTestingTests.swift | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsBackgroundTransferTests.swift | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 822 insertions(+), 5 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransfer.swift b/Sources/RadrootsKit/RadrootsBackgroundTransfer.swift @@ -0,0 +1,405 @@ +import Foundation + +public enum RadrootsBackgroundTransferError: Error, Equatable, Sendable { + case invalidRequest(String) + case unavailable(String) + case transferFailure(String) + case persistenceFailure(String) +} + +extension RadrootsBackgroundTransferError: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidRequest(let message): + message + case .unavailable(let message): + message + case .transferFailure(let message): + message + case .persistenceFailure(let message): + message + } + } +} + +public struct RadrootsBackgroundTransferIdentifier: Sendable, Equatable, Hashable, Comparable, Codable { + public let rawValue: String + + public init(_ value: String) throws { + self.rawValue = try RadrootsBackgroundTransferValidation.normalizedIdentifier(value) + } + + public static func generated() -> Self { + Self(validatedRawValue: UUID().uuidString.lowercased()) + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + private init(validatedRawValue: String) { + self.rawValue = validatedRawValue + } +} + +public enum RadrootsBackgroundTransferMethod: String, Sendable, Equatable, Hashable, Codable, CaseIterable { + case get = "GET" + case post = "POST" + case put = "PUT" +} + +public enum RadrootsBackgroundTransferLocalFile: Sendable, Equatable, Hashable, Codable { + case file(RadrootsFileReference) + case stagedBlob(RadrootsStagedBlobReference) +} + +public enum RadrootsBackgroundTransferOperation: Sendable, Equatable, Hashable, Codable { + case download(destination: RadrootsBackgroundTransferLocalFile) + case upload(source: RadrootsBackgroundTransferLocalFile) +} + +public enum RadrootsBackgroundTransferState: String, Sendable, Equatable, Hashable, Codable, CaseIterable { + case queued + case running + case completed + case failed + case cancelled +} + +public struct RadrootsBackgroundTransferRequest: Sendable, Equatable, Hashable, Codable { + public let identifier: RadrootsBackgroundTransferIdentifier + public let remoteURL: URL + public let method: RadrootsBackgroundTransferMethod + public let operation: RadrootsBackgroundTransferOperation + public let headers: [String: String] + public let metadata: [String: String] + + public init( + identifier: RadrootsBackgroundTransferIdentifier = .generated(), + remoteURL: URL, + method: RadrootsBackgroundTransferMethod, + operation: RadrootsBackgroundTransferOperation, + headers: [String: String] = [:], + metadata: [String: String] = [:] + ) throws { + try RadrootsBackgroundTransferValidation.validate( + remoteURL: remoteURL, + method: method, + operation: operation, + headers: headers, + metadata: metadata + ) + self.identifier = identifier + self.remoteURL = remoteURL + self.method = method + self.operation = operation + self.headers = headers + self.metadata = metadata + } +} + +public struct RadrootsBackgroundTransferHandle: Sendable, Equatable, Hashable, Codable { + public let identifier: RadrootsBackgroundTransferIdentifier + public let request: RadrootsBackgroundTransferRequest + + public init(request: RadrootsBackgroundTransferRequest) { + self.identifier = request.identifier + self.request = request + } +} + +public struct RadrootsBackgroundTransferProgress: Sendable, Equatable, Hashable, Codable { + public let bytesTransferred: Int64 + public let totalBytesExpected: Int64? + + public init(bytesTransferred: Int64, totalBytesExpected: Int64? = nil) throws { + guard bytesTransferred >= 0 else { + throw RadrootsBackgroundTransferError.invalidRequest("background transfer bytes transferred cannot be negative") + } + if let totalBytesExpected { + guard totalBytesExpected >= 0 else { + throw RadrootsBackgroundTransferError.invalidRequest("background transfer expected byte count cannot be negative") + } + guard totalBytesExpected >= bytesTransferred else { + throw RadrootsBackgroundTransferError.invalidRequest("background transfer expected byte count cannot be less than transferred bytes") + } + } + self.bytesTransferred = bytesTransferred + self.totalBytesExpected = totalBytesExpected + } + + public static let zero = RadrootsBackgroundTransferProgress(validatedBytesTransferred: 0, totalBytesExpected: nil) + + private init(validatedBytesTransferred: Int64, totalBytesExpected: Int64?) { + self.bytesTransferred = validatedBytesTransferred + self.totalBytesExpected = totalBytesExpected + } +} + +public struct RadrootsBackgroundTransferSnapshot: Sendable, Equatable, Hashable, Codable { + public let identifier: RadrootsBackgroundTransferIdentifier + public let request: RadrootsBackgroundTransferRequest + public let state: RadrootsBackgroundTransferState + public let progress: RadrootsBackgroundTransferProgress + public let errorMessage: String? + public let updatedAt: Date + + public init( + request: RadrootsBackgroundTransferRequest, + state: RadrootsBackgroundTransferState = .queued, + progress: RadrootsBackgroundTransferProgress = .zero, + errorMessage: String? = nil, + updatedAt: Date = Date() + ) throws { + guard updatedAt.timeIntervalSinceReferenceDate.isFinite else { + throw RadrootsBackgroundTransferError.invalidRequest("background transfer updated date must be finite") + } + self.identifier = request.identifier + self.request = request + self.state = state + self.progress = progress + self.errorMessage = try RadrootsBackgroundTransferValidation.normalizedOptionalMessage(errorMessage) + self.updatedAt = updatedAt + } +} + +public protocol RadrootsBackgroundTransferStore: Sendable { + func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] + func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws + func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws + func removeAllSnapshots() async throws +} + +public protocol RadrootsBackgroundTransfer: Sendable { + func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle + func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws + func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? + func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] +} + +public protocol RadrootsBackgroundTransferFileResolver: Sendable { + func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL +} + +public struct RadrootsAppleBackgroundTransferFileResolver: RadrootsBackgroundTransferFileResolver, Sendable { + private let roots: RadrootsAppleFileRoots + + public init(roots: RadrootsAppleFileRoots) { + self.roots = roots + } + + public func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL { + switch file { + case .file(let reference): + try roots.resolvedURL(for: reference) + case .stagedBlob(let blob): + try roots.stagedBlobURL(for: blob) + } + } +} + +public final class RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferStore, @unchecked Sendable { + private let roots: RadrootsAppleFileRoots + private let fileManager: FileManager + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + public init(roots: RadrootsAppleFileRoots, fileManager: FileManager = .default) { + self.roots = roots + self.fileManager = fileManager + self.encoder = JSONEncoder() + self.decoder = JSONDecoder() + self.encoder.outputFormatting = [.sortedKeys] + } + + public func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { + let url = try storeURL() + guard fileManager.fileExists(atPath: url.path) else { + return [] + } + let data = try Data(contentsOf: url) + return try decoder.decode([RadrootsBackgroundTransferSnapshot].self, from: data) + .sorted { left, right in + left.identifier < right.identifier + } + } + + public func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws { + var snapshots = try await loadSnapshots() + snapshots.removeAll { $0.identifier == snapshot.identifier } + snapshots.append(snapshot) + try write(snapshots.sorted { left, right in + left.identifier < right.identifier + }) + } + + public func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws { + var snapshots = try await loadSnapshots() + snapshots.removeAll { $0.identifier == identifier } + try write(snapshots) + } + + public func removeAllSnapshots() async throws { + let url = try storeURL() + guard fileManager.fileExists(atPath: url.path) else { + return + } + try fileManager.removeItem(at: url) + } + + private func write(_ snapshots: [RadrootsBackgroundTransferSnapshot]) throws { + let url = try storeURL() + try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let data = try encoder.encode(snapshots) + try data.write(to: url, options: [.atomic]) + } + + private func storeURL() throws -> URL { + try roots.resolvedURL( + for: RadrootsFileReference( + scope: .cache, + relativePath: "background_transfers/transfers.json" + ) + ) + } +} + +public struct RadrootsUnavailableBackgroundTransfer: RadrootsBackgroundTransfer, Sendable { + private let reason: String + + public init(reason: String = "background transfer is unavailable on this platform") { + let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines) + self.reason = trimmedReason.isEmpty ? "background transfer is unavailable on this platform" : trimmedReason + } + + public func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle { + throw RadrootsBackgroundTransferError.unavailable(reason) + } + + public func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { + throw RadrootsBackgroundTransferError.unavailable(reason) + } + + public func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? { + throw RadrootsBackgroundTransferError.unavailable(reason) + } + + public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { + throw RadrootsBackgroundTransferError.unavailable(reason) + } +} + +public enum RadrootsBackgroundTransferValidation { + public static func normalizedIdentifier(_ value: String) throws -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { + throw RadrootsBackgroundTransferError.invalidRequest("background transfer identifier must not be empty") + } + guard trimmed.count <= 128 else { + throw RadrootsBackgroundTransferError.invalidRequest("background transfer identifier is too long") + } + guard trimmed.range( + of: "^[a-z0-9][a-z0-9._-]*[a-z0-9]$|^[a-z0-9]$", + options: .regularExpression + ) != nil else { + throw RadrootsBackgroundTransferError.invalidRequest("background transfer identifier must use lowercase safe identifier characters") + } + guard !trimmed.contains("..") else { + throw RadrootsBackgroundTransferError.invalidRequest("background transfer identifier cannot contain empty path components") + } + return trimmed + } + + public static func validate( + remoteURL: URL, + method: RadrootsBackgroundTransferMethod, + operation: RadrootsBackgroundTransferOperation, + headers: [String: String], + metadata: [String: String] + ) throws { + try validate(remoteURL: remoteURL) + try validate(method: method, operation: operation) + try validate(headers: headers) + try validate(metadata: metadata) + } + + public static func normalizedOptionalMessage(_ value: String?) throws -> String? { + guard let value else { + return nil + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + guard trimmed.count <= 240 else { + throw RadrootsBackgroundTransferError.invalidRequest("background transfer message is too long") + } + guard doesNotContainControlCharacters(trimmed) else { + throw RadrootsBackgroundTransferError.invalidRequest("background transfer message cannot contain control characters") + } + return trimmed + } + + private static func validate(remoteURL: URL) throws { + guard let components = URLComponents(url: remoteURL, resolvingAgainstBaseURL: false), + components.scheme?.lowercased() == "https", + components.host?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, + components.user == nil, + components.password == nil else { + throw RadrootsBackgroundTransferError.invalidRequest("background transfer remote URL must use https with a host and no credentials") + } + } + + private static func validate( + method: RadrootsBackgroundTransferMethod, + operation: RadrootsBackgroundTransferOperation + ) throws { + switch operation { + case .download: + guard method == .get else { + throw RadrootsBackgroundTransferError.invalidRequest("background download transfers must use GET") + } + case .upload: + guard method == .post || method == .put else { + throw RadrootsBackgroundTransferError.invalidRequest("background upload transfers must use POST or PUT") + } + } + } + + private static func validate(headers: [String: String]) throws { + guard headers.count <= 32 else { + throw RadrootsBackgroundTransferError.invalidRequest("background transfer header count is too large") + } + for (key, value) in headers { + try validateSafeText(key, field: "background transfer header name", maximumLength: 80) + try validateSafeText(value, field: "background transfer header value", maximumLength: 500) + } + } + + private static func validate(metadata: [String: String]) throws { + guard metadata.count <= 32 else { + throw RadrootsBackgroundTransferError.invalidRequest("background transfer metadata count is too large") + } + for (key, value) in metadata { + try validateSafeText(key, field: "background transfer metadata key", maximumLength: 80) + try validateSafeText(value, field: "background transfer metadata value", maximumLength: 500) + } + } + + private static func validateSafeText(_ value: String, field: String, maximumLength: Int) throws { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw RadrootsBackgroundTransferError.invalidRequest("\(field) must not be empty") + } + guard trimmed.count <= maximumLength else { + throw RadrootsBackgroundTransferError.invalidRequest("\(field) is too long") + } + guard doesNotContainControlCharacters(trimmed) else { + throw RadrootsBackgroundTransferError.invalidRequest("\(field) cannot contain control characters") + } + } + + private static func doesNotContainControlCharacters(_ value: String) -> Bool { + value.unicodeScalars.allSatisfy { !CharacterSet.controlCharacters.contains($0) } + } +} diff --git a/Sources/RadrootsKit/RadrootsFileAccess.swift b/Sources/RadrootsKit/RadrootsFileAccess.swift @@ -1,13 +1,13 @@ import Foundation -public enum RadrootsFileScope: Sendable, Equatable, CaseIterable { +public enum RadrootsFileScope: Sendable, Equatable, CaseIterable, Codable { case data case cache case temporary case logs } -public struct RadrootsFileReference: Sendable, Equatable, Hashable { +public struct RadrootsFileReference: Sendable, Equatable, Hashable, Codable { public let scope: RadrootsFileScope public let relativePath: String @@ -39,7 +39,7 @@ public struct RadrootsFileEntry: Sendable, Equatable, Hashable { } } -public struct RadrootsStagedBlobReference: Sendable, Equatable, Hashable { +public struct RadrootsStagedBlobReference: Sendable, Equatable, Hashable, Codable { public let blobID: String public let sizeBytes: Int public let mediaType: String? @@ -413,8 +413,7 @@ public final class RadrootsAppleFileAccess: RadrootsFileAccess { } private func stagedBlobURL(for blob: RadrootsStagedBlobReference) throws -> URL { - let normalizedBlobID = try RadrootsStagedBlobReference.normalizedBlobID(blob.blobID) - return roots.stagedBlobsRoot.appendingPathComponent(normalizedBlobID).standardizedFileURL + try roots.stagedBlobURL(for: blob) } private var preparedExportsRoot: URL { @@ -616,6 +615,11 @@ public struct RadrootsAppleFileRoots: Sendable, Equatable { return candidateURL } + public func stagedBlobURL(for blob: RadrootsStagedBlobReference) throws -> URL { + let normalizedBlobID = try RadrootsStagedBlobReference.normalizedBlobID(blob.blobID) + return stagedBlobsRoot.appendingPathComponent(normalizedBlobID).standardizedFileURL + } + public static func normalizedAppIdentifier(_ appIdentifier: String) throws -> String { let trimmed = appIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { diff --git a/Sources/RadrootsKitTesting/RadrootsBackgroundTransferTesting.swift b/Sources/RadrootsKitTesting/RadrootsBackgroundTransferTesting.swift @@ -0,0 +1,124 @@ +import Foundation +import RadrootsKit + +public actor RadrootsInMemoryBackgroundTransferStore: RadrootsBackgroundTransferStore { + private var snapshotsByIdentifier: [RadrootsBackgroundTransferIdentifier: RadrootsBackgroundTransferSnapshot] + + public init(snapshots: [RadrootsBackgroundTransferSnapshot] = []) { + self.snapshotsByIdentifier = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.identifier, $0) }) + } + + public func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { + snapshotsByIdentifier.values.sorted { left, right in + left.identifier < right.identifier + } + } + + public func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws { + snapshotsByIdentifier[snapshot.identifier] = snapshot + } + + public func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws { + snapshotsByIdentifier.removeValue(forKey: identifier) + } + + public func removeAllSnapshots() async throws { + snapshotsByIdentifier.removeAll() + } +} + +public actor RadrootsFakeBackgroundTransfer: RadrootsBackgroundTransfer { + private let store: any RadrootsBackgroundTransferStore + private var enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> + private var enqueuedRequestsValue: [RadrootsBackgroundTransferRequest] + private var cancelledIdentifiersValue: [RadrootsBackgroundTransferIdentifier] + private let updatedAt: Date + + public init( + store: any RadrootsBackgroundTransferStore = RadrootsInMemoryBackgroundTransferStore(), + enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> = .success(()), + updatedAt: Date = Date(timeIntervalSince1970: 0) + ) { + self.store = store + self.enqueueOutcome = enqueueOutcome + self.enqueuedRequestsValue = [] + self.cancelledIdentifiersValue = [] + self.updatedAt = updatedAt + } + + public func setEnqueueOutcome(_ outcome: Result<Void, RadrootsBackgroundTransferError>) { + enqueueOutcome = outcome + } + + public func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle { + enqueuedRequestsValue.append(request) + switch enqueueOutcome { + case .success: + let snapshot = try RadrootsBackgroundTransferSnapshot( + request: request, + state: .queued, + updatedAt: updatedAt + ) + try await store.saveSnapshot(snapshot) + return RadrootsBackgroundTransferHandle(request: request) + case .failure(let error): + throw error + } + } + + public func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { + cancelledIdentifiersValue.append(identifier) + if let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) { + let snapshot = try RadrootsBackgroundTransferSnapshot( + request: existing.request, + state: .cancelled, + progress: existing.progress, + updatedAt: updatedAt + ) + try await store.saveSnapshot(snapshot) + } + } + + public func complete(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { + guard let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) else { + throw RadrootsBackgroundTransferError.transferFailure("background transfer snapshot not found") + } + let snapshot = try RadrootsBackgroundTransferSnapshot( + request: existing.request, + state: .completed, + progress: existing.progress, + updatedAt: updatedAt + ) + try await store.saveSnapshot(snapshot) + } + + public func fail(_ identifier: RadrootsBackgroundTransferIdentifier, message: String) async throws { + guard let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) else { + throw RadrootsBackgroundTransferError.transferFailure("background transfer snapshot not found") + } + let snapshot = try RadrootsBackgroundTransferSnapshot( + request: existing.request, + state: .failed, + progress: existing.progress, + errorMessage: message, + updatedAt: updatedAt + ) + try await store.saveSnapshot(snapshot) + } + + public func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? { + try await store.loadSnapshots().first { $0.identifier == identifier } + } + + public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { + try await store.loadSnapshots() + } + + public var enqueuedRequests: [RadrootsBackgroundTransferRequest] { + enqueuedRequestsValue + } + + public var cancelledIdentifiers: [RadrootsBackgroundTransferIdentifier] { + cancelledIdentifiersValue + } +} diff --git a/Tests/RadrootsKitTestingTests/RadrootsBackgroundTransferTestingTests.swift b/Tests/RadrootsKitTestingTests/RadrootsBackgroundTransferTestingTests.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing +import RadrootsKit +import RadrootsKitTesting + +@Test func inMemoryBackgroundTransferStorePersistsSnapshotsInIdentifierOrder() async throws { + let first = try RadrootsBackgroundTransferSnapshot( + request: testTransferRequest(identifier: "field.transfer.b") + ) + let second = try RadrootsBackgroundTransferSnapshot( + request: testTransferRequest(identifier: "field.transfer.a") + ) + let store = RadrootsInMemoryBackgroundTransferStore() + + try await store.saveSnapshot(first) + try await store.saveSnapshot(second) + + #expect(try await store.loadSnapshots().map(\.identifier.rawValue) == [ + "field.transfer.a", + "field.transfer.b" + ]) + + try await store.removeSnapshot(for: second.identifier) + #expect(try await store.loadSnapshots() == [first]) + + try await store.removeAllSnapshots() + #expect(try await store.loadSnapshots().isEmpty) +} + +@Test func fakeBackgroundTransferEnqueuesSnapshotsAndHandlesCancellation() async throws { + let transfer = RadrootsFakeBackgroundTransfer( + updatedAt: Date(timeIntervalSince1970: 42) + ) + let request = try testTransferRequest(identifier: "field.transfer.enqueue") + + let handle = try await transfer.enqueue(request) + + #expect(handle.identifier == request.identifier) + #expect(await transfer.enqueuedRequests == [request]) + #expect(try await transfer.snapshot(for: request.identifier)?.state == .queued) + #expect(try await transfer.snapshot(for: request.identifier)?.updatedAt == Date(timeIntervalSince1970: 42)) + + try await transfer.cancel(request.identifier) + + #expect(await transfer.cancelledIdentifiers == [request.identifier]) + #expect(try await transfer.snapshot(for: request.identifier)?.state == .cancelled) +} + +@Test func fakeBackgroundTransferCanCompleteAndFailSnapshots() async throws { + let transfer = RadrootsFakeBackgroundTransfer() + let completed = try testTransferRequest(identifier: "field.transfer.completed") + let failed = try testTransferRequest(identifier: "field.transfer.failed") + + _ = try await transfer.enqueue(completed) + _ = try await transfer.enqueue(failed) + try await transfer.complete(completed.identifier) + try await transfer.fail(failed.identifier, message: "network unavailable") + + #expect(try await transfer.snapshot(for: completed.identifier)?.state == .completed) + let failedSnapshot = try await transfer.snapshot(for: failed.identifier) + #expect(failedSnapshot?.state == .failed) + #expect(failedSnapshot?.errorMessage == "network unavailable") +} + +@Test func fakeBackgroundTransferCanReturnEnqueueFailures() async throws { + let transfer = RadrootsFakeBackgroundTransfer( + enqueueOutcome: .failure(.transferFailure("queue unavailable")) + ) + let request = try testTransferRequest(identifier: "field.transfer.failure") + + await #expect(throws: RadrootsBackgroundTransferError.transferFailure("queue unavailable")) { + _ = try await transfer.enqueue(request) + } + #expect(await transfer.enqueuedRequests == [request]) + #expect(try await transfer.snapshots().isEmpty) +} + +private func testTransferRequest(identifier: String) throws -> RadrootsBackgroundTransferRequest { + try RadrootsBackgroundTransferRequest( + identifier: RadrootsBackgroundTransferIdentifier(identifier), + remoteURL: URL(string: "https://radroots.org/\(identifier).json")!, + method: .get, + operation: .download( + destination: .file(RadrootsFileReference(scope: .cache, relativePath: "\(identifier).json")) + ) + ) +} diff --git a/Tests/RadrootsKitTests/RadrootsBackgroundTransferTests.swift b/Tests/RadrootsKitTests/RadrootsBackgroundTransferTests.swift @@ -0,0 +1,197 @@ +import Foundation +import Testing +@testable import RadrootsKit + +@Test func backgroundTransferIdentifierNormalizesAndRejectsUnsafeValues() throws { + let identifier = try RadrootsBackgroundTransferIdentifier(" FIELD-IOS.TRANSFER_1 ") + + #expect(identifier.rawValue == "field-ios.transfer_1") + + #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer identifier must not be empty")) { + _ = try RadrootsBackgroundTransferIdentifier(" ") + } + #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer identifier must use lowercase safe identifier characters")) { + _ = try RadrootsBackgroundTransferIdentifier("/escape") + } + #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer identifier cannot contain empty path components")) { + _ = try RadrootsBackgroundTransferIdentifier("field..transfer") + } +} + +@Test func backgroundTransferRequestValidatesUrlMethodAndHeaders() throws { + let destination = RadrootsBackgroundTransferLocalFile.file( + RadrootsFileReference(scope: .cache, relativePath: "downloads/relay.json") + ) + let request = try RadrootsBackgroundTransferRequest( + identifier: RadrootsBackgroundTransferIdentifier("field.transfer.download"), + remoteURL: URL(string: "https://radroots.org/relay.json")!, + method: .get, + operation: .download(destination: destination), + headers: ["Accept": "application/json"], + metadata: ["purpose": "diagnostics"] + ) + + #expect(request.method == .get) + #expect(request.operation == .download(destination: destination)) + #expect(request.headers["Accept"] == "application/json") + + #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer remote URL must use https with a host and no credentials")) { + _ = try RadrootsBackgroundTransferRequest( + remoteURL: URL(string: "http://radroots.org/relay.json")!, + method: .get, + operation: .download(destination: destination) + ) + } + #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background download transfers must use GET")) { + _ = try RadrootsBackgroundTransferRequest( + remoteURL: URL(string: "https://radroots.org/relay.json")!, + method: .post, + operation: .download(destination: destination) + ) + } + #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer header value cannot contain control characters")) { + _ = try RadrootsBackgroundTransferRequest( + remoteURL: URL(string: "https://radroots.org/relay.json")!, + method: .get, + operation: .download(destination: destination), + headers: ["Accept": "application/json\ntext/plain"] + ) + } +} + +@Test func backgroundTransferUploadRequiresUploadMethod() throws { + let source = RadrootsBackgroundTransferLocalFile.stagedBlob( + try RadrootsStagedBlobReference(blobID: "upload", sizeBytes: 12) + ) + + let request = try RadrootsBackgroundTransferRequest( + remoteURL: URL(string: "https://radroots.org/upload")!, + method: .put, + operation: .upload(source: source) + ) + + #expect(request.operation == .upload(source: source)) + + #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background upload transfers must use POST or PUT")) { + _ = try RadrootsBackgroundTransferRequest( + remoteURL: URL(string: "https://radroots.org/upload")!, + method: .get, + operation: .upload(source: source) + ) + } +} + +@Test func backgroundTransferProgressAndSnapshotValidateBounds() throws { + let request = try testDownloadRequest(identifier: "field.transfer.snapshot") + let progress = try RadrootsBackgroundTransferProgress(bytesTransferred: 5, totalBytesExpected: 10) + let snapshot = try RadrootsBackgroundTransferSnapshot( + request: request, + state: .running, + progress: progress, + errorMessage: " running ", + updatedAt: Date(timeIntervalSince1970: 1) + ) + + #expect(snapshot.identifier == request.identifier) + #expect(snapshot.state == .running) + #expect(snapshot.progress == progress) + #expect(snapshot.errorMessage == "running") + + #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer expected byte count cannot be less than transferred bytes")) { + _ = try RadrootsBackgroundTransferProgress(bytesTransferred: 10, totalBytesExpected: 5) + } + #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer updated date must be finite")) { + _ = try RadrootsBackgroundTransferSnapshot( + request: request, + updatedAt: Date(timeIntervalSinceReferenceDate: .infinity) + ) + } +} + +@Test func appleBackgroundTransferFileResolverUsesOnlyFileRootsAndStagedBlobs() throws { + let roots = try testBackgroundTransferRoots() + let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) + let file = RadrootsBackgroundTransferLocalFile.file( + RadrootsFileReference(scope: .data, relativePath: "exports/diagnostics.json") + ) + let blob = try RadrootsStagedBlobReference(blobID: "blob-1", sizeBytes: 2) + + #expect(try resolver.resolve(file) == roots.dataRoot.appendingPathComponent("exports/diagnostics.json").standardizedFileURL) + #expect(try resolver.resolve(.stagedBlob(blob)) == roots.stagedBlobsRoot.appendingPathComponent("blob-1").standardizedFileURL) + + #expect(throws: RadrootsAppleFileError.invalidRequest("file relative path must not be absolute")) { + _ = try resolver.resolve(.file(RadrootsFileReference(scope: .data, relativePath: "/tmp/escape"))) + } + #expect(throws: RadrootsAppleFileError.invalidRequest("staged blob id contains invalid characters")) { + _ = try resolver.resolve(.stagedBlob(RadrootsStagedBlobReference(blobID: "../escape", sizeBytes: 1))) + } +} + +@Test func appleBackgroundTransferStorePersistsAndRecoversSnapshots() async throws { + let roots = try testBackgroundTransferRoots() + let store = RadrootsAppleBackgroundTransferStore(roots: roots) + let first = try RadrootsBackgroundTransferSnapshot( + request: testDownloadRequest(identifier: "field.transfer.b"), + updatedAt: Date(timeIntervalSince1970: 2) + ) + let second = try RadrootsBackgroundTransferSnapshot( + request: testDownloadRequest(identifier: "field.transfer.a"), + state: .running, + updatedAt: Date(timeIntervalSince1970: 3) + ) + + try await store.saveSnapshot(first) + try await store.saveSnapshot(second) + + let recoveredStore = RadrootsAppleBackgroundTransferStore(roots: roots) + #expect(try await recoveredStore.loadSnapshots().map(\.identifier.rawValue) == [ + "field.transfer.a", + "field.transfer.b" + ]) + + try await recoveredStore.removeSnapshot(for: second.identifier) + #expect(try await recoveredStore.loadSnapshots().map(\.identifier.rawValue) == ["field.transfer.b"]) + + try await recoveredStore.removeAllSnapshots() + #expect(try await recoveredStore.loadSnapshots().isEmpty) +} + +@Test func unavailableBackgroundTransferThrowsTypedErrors() async throws { + let transfer = RadrootsUnavailableBackgroundTransfer(reason: "missing background transfer support") + let request = try testDownloadRequest(identifier: "field.transfer.unavailable") + + await #expect(throws: RadrootsBackgroundTransferError.unavailable("missing background transfer support")) { + _ = try await transfer.enqueue(request) + } + await #expect(throws: RadrootsBackgroundTransferError.unavailable("missing background transfer support")) { + try await transfer.cancel(request.identifier) + } + await #expect(throws: RadrootsBackgroundTransferError.unavailable("missing background transfer support")) { + _ = try await transfer.snapshot(for: request.identifier) + } + await #expect(throws: RadrootsBackgroundTransferError.unavailable("missing background transfer support")) { + _ = try await transfer.snapshots() + } +} + +private func testDownloadRequest(identifier: String) throws -> RadrootsBackgroundTransferRequest { + try RadrootsBackgroundTransferRequest( + identifier: RadrootsBackgroundTransferIdentifier(identifier), + remoteURL: URL(string: "https://radroots.org/\(identifier).json")!, + method: .get, + operation: .download( + destination: .file(RadrootsFileReference(scope: .cache, relativePath: "\(identifier).json")) + ) + ) +} + +private func testBackgroundTransferRoots() throws -> RadrootsAppleFileRoots { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("radroots-background-transfer-\(UUID().uuidString)", isDirectory: true) + return try RadrootsAppleFileRoots( + appIdentifier: "org.radroots.tests", + dataRoot: root.appendingPathComponent("data", isDirectory: true), + cacheRoot: root.appendingPathComponent("cache", isDirectory: true), + temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true) + ) +}