TeraBackgroundUploadFixtures.swift (11867B)
1 import CryptoKit 2 import RadrootsKit 3 @testable import TeraApp 4 import XCTest 5 6 final class BackgroundUploadFixture: @unchecked Sendable { 7 let draftID: String 8 let media: TeraPreparedMedia 9 private let root: URL 10 private let roots: RadrootsAppleFileRoots 11 12 init(draftID: String = String(repeating: "1", count: 32)) throws { 13 self.draftID = draftID 14 let bytes = Data("radroots-background-upload".utf8) 15 let digest = SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined() 16 root = FileManager.default.temporaryDirectory 17 .appendingPathComponent("radroots-background-tests-\(UUID().uuidString)", isDirectory: true) 18 roots = try RadrootsAppleFileRoots( 19 appIdentifier: "org.radroots.background-tests", 20 dataRoot: root.appendingPathComponent("data", isDirectory: true), 21 cacheRoot: root.appendingPathComponent("cache", isDirectory: true), 22 temporaryRoot: root.appendingPathComponent("temporary", isDirectory: true) 23 ) 24 try FileManager.default.createDirectory( 25 at: roots.stagedBlobsRoot, 26 withIntermediateDirectories: true 27 ) 28 try bytes.write(to: roots.stagedBlobsRoot.appendingPathComponent(digest)) 29 media = TeraPreparedMedia( 30 opaqueReference: "media:\(digest)", 31 remoteURL: "http://127.0.0.1:3000/\(digest).png", 32 sha256: digest, 33 mediaType: "image/png", 34 byteSize: UInt64(bytes.count), 35 width: 2, 36 height: 2, 37 alt: "Background upload", 38 preparedAtUnixSeconds: 1_800_000_000 39 ) 40 } 41 42 func remove() { 43 try? FileManager.default.removeItem(at: root) 44 } 45 46 var stagedFileURL: URL { 47 roots.stagedBlobsRoot.appendingPathComponent(media.sha256) 48 } 49 50 var nativeStore: RadrootsAppleBackgroundTransferStore { 51 RadrootsAppleBackgroundTransferStore(roots: roots) 52 } 53 54 func coordinator(transfer: any RadrootsBackgroundTransfer) -> TeraAddMediaCoordinator { 55 TeraAddMediaCoordinator( 56 roots: roots, 57 picker: BackgroundMediaPicker(), 58 preparer: RadrootsAppleMediaPreparer(roots: roots), 59 transfer: transfer 60 ) 61 } 62 63 func job(revision: UInt64, operation: String) -> TeraNativeUploadJob { 64 TeraNativeUploadJob( 65 operationID: operation, 66 draft: draft(revision: revision, stage: .uploading), 67 remoteURL: media.remoteURL!, 68 uploadURL: "http://127.0.0.1:3000/upload", 69 authorizationHeader: "Nostr test-authorization", 70 expectedSHA256: media.sha256, 71 mediaType: media.mediaType, 72 byteSize: media.byteSize 73 ) 74 } 75 76 func draft(revision: UInt64, stage: TeraDraftMediaStage) -> TeraDraftStatus { 77 var form = TeraAddForm.empty(.createPhotoUpdate) 78 form.content = "Background transfer" 79 form.media = [media] 80 return TeraDraftStatus( 81 id: draftID, 82 revision: revision, 83 authorPublicKey: String(repeating: "a", count: 64), 84 kind: .add, 85 commandType: .createPhotoUpdate, 86 form: form, 87 state: stage == .verified ? .readyToSign : .mediaUploading, 88 cardID: String(repeating: "c", count: 64), 89 operationID: String(repeating: "d", count: 32), 90 createdAtUnixMilliseconds: 1_800_000_000_000, 91 updatedAtUnixMilliseconds: 1_800_000_000_001, 92 media: [ 93 TeraDraftMediaStatus( 94 url: media.remoteURL!, 95 stage: stage, 96 uploadAttempts: stage == .verified ? 1 : 0, 97 verifiedAtUnixMilliseconds: stage == .verified ? 1_800_000_000_001 : nil, 98 possibleOrphan: false, 99 orphanReasonCode: nil, 100 orphanRecordedAtUnixMilliseconds: nil, 101 uploadURL: "http://127.0.0.1:3000/upload" 102 ), 103 ], 104 settlement: nil, 105 isRevision: false 106 ) 107 } 108 109 func request( 110 job: TeraNativeUploadJob, 111 remoteURL: String? = nil 112 ) throws -> RadrootsBackgroundTransferRequest { 113 let blob = try RadrootsStagedBlobReference( 114 blobID: media.sha256, 115 sizeBytes: Int(media.byteSize), 116 mediaType: media.mediaType, 117 filenameHint: "\(media.sha256).png" 118 ) 119 return try RadrootsBackgroundTransferRequest( 120 identifier: RadrootsBackgroundTransferIdentifier(job.transferIdentifier), 121 remoteURL: URL(string: remoteURL ?? job.uploadURL)!, 122 method: .put, 123 operation: .upload(source: .stagedBlob(blob)), 124 headers: [:], 125 metadata: [:], 126 networkPolicy: .simulatorLoopbackHTTP, 127 responsePolicy: .boundedJSON(), 128 expectedSourceSHA256: media.sha256 129 ) 130 } 131 } 132 133 extension TeraNativeUploadJob { 134 var transferIdentifier: String { 135 "radroots.add.\(draft.id).\(draft.revision).\(operationID)" 136 } 137 } 138 139 struct BackgroundMediaPicker: RadrootsMediaPicker { 140 func currentSupport() async throws -> RadrootsMediaPickerSupport { 141 try RadrootsMediaPickerSupport( 142 importAvailable: false, 143 cameraCaptureAvailable: false, 144 supportedImportKinds: [], 145 supportedCaptureKinds: [], 146 multipleSelectionSupported: false 147 ) 148 } 149 150 func importMedia(_: RadrootsMediaImportRequest) async throws -> RadrootsMediaImportResult { 151 throw RadrootsCaptureIntakeError.unavailable 152 } 153 154 func captureMedia(_: RadrootsMediaCaptureRequest) async throws -> RadrootsMediaCaptureResult { 155 throw RadrootsCaptureIntakeError.unavailable 156 } 157 } 158 159 actor BackgroundTransferHarness: RadrootsBackgroundTransfer { 160 private var discoveryPause: ResourceTestPause? 161 private var retryPause: ResourceTestPause? 162 private var discoveryCallback: (@Sendable () async -> Void)? 163 private(set) var discoveryCount = 0 164 private var values: [RadrootsBackgroundTransferIdentifier: RadrootsBackgroundTransferSnapshot] = 165 [:] 166 private let enqueueState: RadrootsBackgroundTransferState 167 private let pause: BackgroundTransferPause 168 private var pauseReleased: Bool 169 private(set) var isPaused = false 170 private(set) var enqueueCount = 0 171 private(set) var retryCount = 0 172 private(set) var cancelCount = 0 173 private(set) var acceptedSettlementCount = 0 174 private(set) var snapshotCount = 0 175 private var settlementFailure: Bool? 176 177 func failNextSettlement(afterWrite: Bool) { 178 settlementFailure = afterWrite 179 } 180 181 init( 182 enqueueState: RadrootsBackgroundTransferState = .awaitingVerification, 183 pause: BackgroundTransferPause = .none 184 ) { 185 self.enqueueState = enqueueState 186 self.pause = pause 187 pauseReleased = pause == .none 188 } 189 190 var state: RadrootsBackgroundTransferState? { 191 values.values.first?.state 192 } 193 194 func pauseDiscovery(_ pause: ResourceTestPause) { 195 discoveryPause = pause 196 } 197 198 func pauseRetry(_ pause: ResourceTestPause) { 199 retryPause = pause 200 } 201 202 func onDiscovery(_ callback: @escaping @Sendable () async -> Void) { 203 discoveryCallback = callback 204 } 205 206 var counts: BackgroundTransferCounts { 207 BackgroundTransferCounts( 208 enqueue: enqueueCount, retry: retryCount, cancel: cancelCount, 209 acceptedSettlement: acceptedSettlementCount 210 ) 211 } 212 213 func seed( 214 request: RadrootsBackgroundTransferRequest, 215 state: RadrootsBackgroundTransferState 216 ) throws { 217 values[request.identifier] = try snapshot(request: request, state: state) 218 } 219 220 func setState(_ state: RadrootsBackgroundTransferState) throws { 221 for (identifier, value) in values { 222 values[identifier] = try snapshot(request: value.request, state: state) 223 } 224 } 225 226 func removeAll() { 227 values.removeAll() 228 } 229 230 func releasePause() { 231 pauseReleased = true 232 } 233 234 func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws 235 -> RadrootsBackgroundTransferHandle 236 { 237 enqueueCount += 1 238 values[request.identifier] = try snapshot( 239 request: persisted(request), 240 state: enqueueState 241 ) 242 return RadrootsBackgroundTransferHandle(request: request) 243 } 244 245 func retry(_ request: RadrootsBackgroundTransferRequest) async throws 246 -> RadrootsBackgroundTransferHandle 247 { 248 retryCount += 1 249 let admissionPause = retryPause 250 retryPause = nil 251 await admissionPause?.wait() 252 values[request.identifier] = try snapshot( 253 request: persisted(request), 254 state: .awaitingVerification 255 ) 256 return RadrootsBackgroundTransferHandle(request: request) 257 } 258 259 func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { 260 cancelCount += 1 261 if let value = values[identifier] { 262 values[identifier] = try snapshot(request: value.request, state: .cancelled) 263 } 264 } 265 266 func expire(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { 267 if let value = values[identifier] { 268 values[identifier] = try snapshot(request: value.request, state: .expired) 269 } 270 } 271 272 func settle( 273 _ identifier: RadrootsBackgroundTransferIdentifier, 274 verification: RadrootsBackgroundTransferVerification 275 ) async throws { 276 guard let value = values[identifier] else { 277 throw RadrootsBackgroundTransferError.transferFailure 278 } 279 switch verification { 280 case .accepted: 281 let failure = settlementFailure 282 settlementFailure = nil 283 if failure == false { 284 throw RadrootsBackgroundTransferError.transferFailure 285 } 286 acceptedSettlementCount += 1 287 values[identifier] = try snapshot(request: value.request, state: .completed) 288 if failure == true { 289 throw RadrootsBackgroundTransferError.transferFailure 290 } 291 case let .rejected(failure): 292 values[identifier] = try RadrootsBackgroundTransferSnapshot( 293 request: value.request, 294 state: .failed, 295 failure: failure 296 ) 297 } 298 } 299 300 func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws 301 -> RadrootsBackgroundTransferSnapshot? 302 { 303 snapshotCount += 1 304 try await waitIfPaused(at: .snapshot) 305 return values[identifier] 306 } 307 308 func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { 309 discoveryCount += 1 310 let admissionPause = discoveryPause 311 discoveryPause = nil 312 await admissionPause?.wait() 313 let callback = discoveryCallback 314 discoveryCallback = nil 315 await callback?() 316 try await waitIfPaused(at: .discovery) 317 return values.values.sorted { $0.identifier < $1.identifier } 318 } 319 320 func handleEventsForBackgroundURLSession( 321 identifier _: String, 322 completionHandler: @escaping @Sendable () -> Void 323 ) async { 324 completionHandler() 325 } 326 327 private func persisted( 328 _ request: RadrootsBackgroundTransferRequest 329 ) throws -> RadrootsBackgroundTransferRequest { 330 try RadrootsBackgroundTransferRequest( 331 identifier: request.identifier, 332 remoteURL: request.remoteURL, 333 method: request.method, 334 operation: request.operation, 335 headers: [:], 336 metadata: [:], 337 networkPolicy: request.networkPolicy, 338 responsePolicy: request.responsePolicy, 339 expectedSourceSHA256: request.expectedSourceSHA256, 340 maximumTransferBytes: request.maximumTransferBytes 341 ) 342 } 343 344 private func snapshot( 345 request: RadrootsBackgroundTransferRequest, 346 state: RadrootsBackgroundTransferState 347 ) throws -> RadrootsBackgroundTransferSnapshot { 348 try RadrootsBackgroundTransferSnapshot( 349 request: request, 350 state: state, 351 response: [.awaitingVerification, .completed].contains(state) 352 ? RadrootsBackgroundTransferResponse( 353 statusCode: 200, 354 mediaType: "application/json", 355 body: Data("{}".utf8) 356 ) : nil, 357 possibleRemoteOrphan: false, 358 updatedAt: Date(timeIntervalSince1970: 1_800_000_000) 359 ) 360 } 361 362 private func waitIfPaused(at point: BackgroundTransferPause) async throws { 363 guard pause == point, !pauseReleased else { return } 364 isPaused = true 365 defer { isPaused = false } 366 while !pauseReleased { 367 try await Task.sleep(nanoseconds: 1_000_000) 368 } 369 } 370 } 371 372 enum BackgroundTransferPause: Sendable { 373 case none 374 case discovery 375 case snapshot 376 } 377 378 struct BackgroundTransferCounts: Sendable { 379 let enqueue: Int 380 let retry: Int 381 let cancel: Int 382 let acceptedSettlement: Int 383 }