field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

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 }