apple_kit

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

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 }