RadrootsAppleBackgroundTransferTests.swift (14413B)
1 import Foundation 2 import Testing 3 import RadrootsKitTesting 4 @testable import RadrootsKit 5 6 @Test func appleBackgroundTransferPersistsRunningSnapshotAfterEnqueue() async throws { 7 let store = RadrootsInMemoryBackgroundTransferStore() 8 let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 100)) 9 let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) 10 let request = try appleTransferRequest(identifier: "field.transfer.enqueue") 11 12 let handle = try await transfer.enqueue(request) 13 14 #expect(handle.identifier == request.identifier) 15 #expect(await probe.enqueuedRequests == [request]) 16 let snapshot = try await transfer.snapshot(for: request.identifier) 17 #expect(snapshot?.state == .running) 18 #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 100)) 19 } 20 21 @Test func appleBackgroundTransferRecordsFailedSnapshotWhenAdapterRejectsEnqueue() async throws { 22 let store = RadrootsInMemoryBackgroundTransferStore() 23 let probe = RadrootsAppleBackgroundTransferProbe( 24 now: Date(timeIntervalSince1970: 200), 25 enqueueOutcome: .failure(.transferFailure("adapter rejected transfer")) 26 ) 27 let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) 28 let request = try appleTransferRequest(identifier: "field.transfer.failed") 29 30 await #expect(throws: RadrootsBackgroundTransferError.transferFailure("adapter rejected transfer")) { 31 _ = try await transfer.enqueue(request) 32 } 33 34 let snapshot = try await transfer.snapshot(for: request.identifier) 35 #expect(snapshot?.state == .failed) 36 #expect(snapshot?.errorMessage == "adapter rejected transfer") 37 #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 200)) 38 } 39 40 @Test func appleBackgroundTransferCancelsThroughAdapterAndUpdatesStore() async throws { 41 let store = RadrootsInMemoryBackgroundTransferStore() 42 let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 300)) 43 let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) 44 let request = try appleTransferRequest(identifier: "field.transfer.cancel") 45 46 _ = try await transfer.enqueue(request) 47 try await transfer.cancel(request.identifier) 48 49 #expect(await probe.cancelledIdentifiers == [request.identifier]) 50 #expect(try await transfer.snapshot(for: request.identifier)?.state == .cancelled) 51 #expect(try await transfer.snapshot(for: request.identifier)?.updatedAt == Date(timeIntervalSince1970: 300)) 52 } 53 54 @Test func appleBackgroundTransferReconcilesQueuedSnapshotsWithActiveRecoveredTasks() async throws { 55 let request = try appleTransferRequest(identifier: "field.transfer.recovered") 56 let queued = try RadrootsBackgroundTransferSnapshot( 57 request: request, 58 state: .queued, 59 updatedAt: Date(timeIntervalSince1970: 1) 60 ) 61 let store = RadrootsInMemoryBackgroundTransferStore(snapshots: [queued]) 62 let probe = RadrootsAppleBackgroundTransferProbe( 63 now: Date(timeIntervalSince1970: 400), 64 activeIdentifiers: [request.identifier] 65 ) 66 let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) 67 68 let snapshots = try await transfer.snapshots() 69 70 #expect(snapshots.count == 1) 71 #expect(snapshots.first?.state == .running) 72 #expect(snapshots.first?.updatedAt == Date(timeIntervalSince1970: 400)) 73 #expect(try await store.loadSnapshots().first?.state == .running) 74 } 75 76 @Test func appleBackgroundTransferForwardsBackgroundCompletionHandlers() async throws { 77 let probe = RadrootsAppleBackgroundTransferProbe() 78 let transfer = RadrootsAppleBackgroundTransfer( 79 store: RadrootsInMemoryBackgroundTransferStore(), 80 adapters: probe.adapters() 81 ) 82 let completion = RadrootsCompletionProbe() 83 84 await transfer.handleEventsForBackgroundURLSession(identifier: "org.radroots.field-ios.background.transfer") { 85 completion.markCompleted() 86 } 87 88 #expect(await probe.handledBackgroundEventIdentifiers == ["org.radroots.field-ios.background.transfer"]) 89 #expect(completion.completed) 90 } 91 92 @Test func appleBackgroundTransferCoordinatorMovesCompletedDownloadToDestination() async throws { 93 let roots = try appleTransferRoots() 94 let store = RadrootsInMemoryBackgroundTransferStore() 95 let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) 96 let coordinator = RadrootsAppleBackgroundTransferCoordinator( 97 sessionIdentifier: "org.radroots.field-ios.background.transfer", 98 store: store, 99 fileResolver: resolver, 100 now: { Date(timeIntervalSince1970: 500) } 101 ) 102 let request = try appleTransferRequest(identifier: "field.transfer.completed") 103 let running = try RadrootsBackgroundTransferSnapshot( 104 request: request, 105 state: .running, 106 updatedAt: Date(timeIntervalSince1970: 1) 107 ) 108 try await store.saveSnapshot(running) 109 let stagingRoot = roots.temporaryRoot.appendingPathComponent("background-transfer-tests", isDirectory: true) 110 try FileManager.default.createDirectory(at: stagingRoot, withIntermediateDirectories: true) 111 let stagedFile = stagingRoot.appendingPathComponent("download.bin") 112 let payload = Data("downloaded".utf8) 113 try payload.write(to: stagedFile) 114 115 await coordinator.complete( 116 identifier: request.identifier, 117 platformError: nil, 118 stagedDownloadResult: .file(stagedFile), 119 bytesTransferred: 0, 120 totalBytesExpected: nil 121 ) 122 123 let snapshot = try await store.loadSnapshots().first 124 let destination = try resolver.resolve(.file(RadrootsFileReference(scope: .cache, relativePath: "field.transfer.completed.json"))) 125 #expect(snapshot?.state == .completed) 126 #expect(snapshot?.progress.bytesTransferred == Int64(payload.count)) 127 #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 500)) 128 #expect(try Data(contentsOf: destination) == payload) 129 #expect(!FileManager.default.fileExists(atPath: stagedFile.path)) 130 } 131 132 @Test func appleBackgroundTransferCoordinatorRecordsFailedDownloadSnapshot() async throws { 133 let roots = try appleTransferRoots() 134 let store = RadrootsInMemoryBackgroundTransferStore() 135 let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) 136 let coordinator = RadrootsAppleBackgroundTransferCoordinator( 137 sessionIdentifier: "org.radroots.field-ios.background.transfer", 138 store: store, 139 fileResolver: resolver, 140 now: { Date(timeIntervalSince1970: 600) } 141 ) 142 let request = try appleTransferRequest(identifier: "field.transfer.download.failed") 143 try await store.saveSnapshot( 144 try RadrootsBackgroundTransferSnapshot( 145 request: request, 146 state: .running, 147 updatedAt: Date(timeIntervalSince1970: 1) 148 ) 149 ) 150 151 await coordinator.complete( 152 identifier: request.identifier, 153 platformError: nil, 154 stagedDownloadResult: .failure("temporary file unavailable"), 155 bytesTransferred: 0, 156 totalBytesExpected: nil 157 ) 158 159 let snapshot = try await store.loadSnapshots().first 160 #expect(snapshot?.state == .failed) 161 #expect(snapshot?.errorMessage == "temporary file unavailable") 162 #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 600)) 163 } 164 165 @Test func appleBackgroundTransferCoordinatorCompletesUploadWithProgress() async throws { 166 let roots = try appleTransferRoots() 167 let store = RadrootsInMemoryBackgroundTransferStore() 168 let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) 169 let coordinator = RadrootsAppleBackgroundTransferCoordinator( 170 sessionIdentifier: "org.radroots.field-ios.background.transfer", 171 store: store, 172 fileResolver: resolver, 173 now: { Date(timeIntervalSince1970: 700) } 174 ) 175 let request = try appleUploadRequest(identifier: "field.transfer.upload.completed") 176 try await store.saveSnapshot( 177 try RadrootsBackgroundTransferSnapshot( 178 request: request, 179 state: .running, 180 updatedAt: Date(timeIntervalSince1970: 1) 181 ) 182 ) 183 184 await coordinator.updateProgress( 185 identifier: request.identifier, 186 bytesTransferred: 4, 187 totalBytesExpected: 10 188 ) 189 await coordinator.complete( 190 identifier: request.identifier, 191 platformError: nil, 192 stagedDownloadResult: nil, 193 bytesTransferred: 10, 194 totalBytesExpected: 10 195 ) 196 197 let snapshot = try await store.loadSnapshots().first 198 #expect(snapshot?.state == .completed) 199 #expect(snapshot?.progress.bytesTransferred == 10) 200 #expect(snapshot?.progress.totalBytesExpected == 10) 201 #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 700)) 202 } 203 204 @Test func appleBackgroundTransferCoordinatorInvokesStoredCompletionHandlerAfterFinishedEvents() async throws { 205 let roots = try appleTransferRoots() 206 let store = RadrootsInMemoryBackgroundTransferStore() 207 let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) 208 let coordinator = RadrootsAppleBackgroundTransferCoordinator( 209 sessionIdentifier: "org.radroots.field-ios.background.transfer", 210 store: store, 211 fileResolver: resolver 212 ) 213 let completion = RadrootsCompletionProbe() 214 let unrelated = RadrootsCompletionProbe() 215 216 await coordinator.handleBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") { 217 completion.markCompleted() 218 } 219 #expect(!completion.completed) 220 221 await coordinator.handleBackgroundEvents(identifier: "other.session") { 222 unrelated.markCompleted() 223 } 224 #expect(unrelated.completed) 225 226 await coordinator.finishBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") 227 #expect(completion.completed) 228 } 229 230 private actor RadrootsAppleBackgroundTransferProbe { 231 private let nowValue: Date 232 private let enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> 233 private var activeIdentifiersValue: Set<RadrootsBackgroundTransferIdentifier> 234 private var enqueuedRequestsValue: [RadrootsBackgroundTransferRequest] 235 private var cancelledIdentifiersValue: [RadrootsBackgroundTransferIdentifier] 236 private var handledBackgroundEventIdentifiersValue: [String] 237 238 init( 239 now: Date = Date(timeIntervalSince1970: 0), 240 enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> = .success(()), 241 activeIdentifiers: Set<RadrootsBackgroundTransferIdentifier> = [] 242 ) { 243 self.nowValue = now 244 self.enqueueOutcome = enqueueOutcome 245 self.activeIdentifiersValue = activeIdentifiers 246 self.enqueuedRequestsValue = [] 247 self.cancelledIdentifiersValue = [] 248 self.handledBackgroundEventIdentifiersValue = [] 249 } 250 251 nonisolated func adapters() -> RadrootsAppleBackgroundTransferAdapters { 252 RadrootsAppleBackgroundTransferAdapters( 253 now: { 254 self.nowValue 255 }, 256 enqueue: { request in 257 try await self.enqueue(request) 258 }, 259 cancel: { identifier in 260 await self.cancel(identifier) 261 }, 262 activeTransferIdentifiers: { 263 await self.activeIdentifiers() 264 }, 265 handleBackgroundEvents: { identifier, completionHandler in 266 await self.handleBackgroundEvents(identifier: identifier, completionHandler: completionHandler) 267 } 268 ) 269 } 270 271 private func enqueue(_ request: RadrootsBackgroundTransferRequest) throws { 272 enqueuedRequestsValue.append(request) 273 switch enqueueOutcome { 274 case .success: 275 activeIdentifiersValue.insert(request.identifier) 276 case .failure(let error): 277 throw error 278 } 279 } 280 281 private func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) { 282 cancelledIdentifiersValue.append(identifier) 283 activeIdentifiersValue.remove(identifier) 284 } 285 286 private func activeIdentifiers() -> Set<RadrootsBackgroundTransferIdentifier> { 287 activeIdentifiersValue 288 } 289 290 private func handleBackgroundEvents( 291 identifier: String, 292 completionHandler: @escaping @Sendable () -> Void 293 ) { 294 handledBackgroundEventIdentifiersValue.append(identifier) 295 completionHandler() 296 } 297 298 var enqueuedRequests: [RadrootsBackgroundTransferRequest] { 299 enqueuedRequestsValue 300 } 301 302 var cancelledIdentifiers: [RadrootsBackgroundTransferIdentifier] { 303 cancelledIdentifiersValue 304 } 305 306 var handledBackgroundEventIdentifiers: [String] { 307 handledBackgroundEventIdentifiersValue 308 } 309 } 310 311 private final class RadrootsCompletionProbe: @unchecked Sendable { 312 private var completedValue = false 313 314 func markCompleted() { 315 completedValue = true 316 } 317 318 var completed: Bool { 319 completedValue 320 } 321 } 322 323 private func appleTransferRequest(identifier: String) throws -> RadrootsBackgroundTransferRequest { 324 try RadrootsBackgroundTransferRequest( 325 identifier: RadrootsBackgroundTransferIdentifier(identifier), 326 remoteURL: URL(string: "https://radroots.org/\(identifier).json")!, 327 method: .get, 328 operation: .download( 329 destination: .file(RadrootsFileReference(scope: .cache, relativePath: "\(identifier).json")) 330 ) 331 ) 332 } 333 334 private func appleUploadRequest(identifier: String) throws -> RadrootsBackgroundTransferRequest { 335 try RadrootsBackgroundTransferRequest( 336 identifier: RadrootsBackgroundTransferIdentifier(identifier), 337 remoteURL: URL(string: "https://radroots.org/\(identifier).json")!, 338 method: .put, 339 operation: .upload( 340 source: .file(RadrootsFileReference(scope: .cache, relativePath: "\(identifier).json")) 341 ) 342 ) 343 } 344 345 private func appleTransferRoots() throws -> RadrootsAppleFileRoots { 346 let root = FileManager.default.temporaryDirectory 347 .appendingPathComponent("radroots-apple-background-transfer-\(UUID().uuidString)", isDirectory: true) 348 return try RadrootsAppleFileRoots( 349 appIdentifier: "org.radroots.tests", 350 dataRoot: root.appendingPathComponent("data", isDirectory: true), 351 cacheRoot: root.appendingPathComponent("cache", isDirectory: true), 352 temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true) 353 ) 354 }