apple_kit

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

RadrootsBackgroundTransferTests.swift (9177B)


      1 import Foundation
      2 import Testing
      3 @testable import RadrootsKit
      4 
      5 @Test func backgroundTransferIdentifierNormalizesAndRejectsUnsafeValues() throws {
      6     let identifier = try RadrootsBackgroundTransferIdentifier(" FIELD-IOS.TRANSFER_1 ")
      7 
      8     #expect(identifier.rawValue == "field-ios.transfer_1")
      9 
     10     #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer identifier must not be empty")) {
     11         _ = try RadrootsBackgroundTransferIdentifier(" ")
     12     }
     13     #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer identifier must use lowercase safe identifier characters")) {
     14         _ = try RadrootsBackgroundTransferIdentifier("/escape")
     15     }
     16     #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer identifier cannot contain empty path components")) {
     17         _ = try RadrootsBackgroundTransferIdentifier("field..transfer")
     18     }
     19 }
     20 
     21 @Test func backgroundTransferRequestValidatesUrlMethodAndHeaders() throws {
     22     let destination = RadrootsBackgroundTransferLocalFile.file(
     23         RadrootsFileReference(scope: .cache, relativePath: "downloads/relay.json")
     24     )
     25     let request = try RadrootsBackgroundTransferRequest(
     26         identifier: RadrootsBackgroundTransferIdentifier("field.transfer.download"),
     27         remoteURL: URL(string: "https://radroots.org/relay.json")!,
     28         method: .get,
     29         operation: .download(destination: destination),
     30         headers: ["Accept": "application/json"],
     31         metadata: ["purpose": "diagnostics"]
     32     )
     33 
     34     #expect(request.method == .get)
     35     #expect(request.operation == .download(destination: destination))
     36     #expect(request.headers["Accept"] == "application/json")
     37 
     38     #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer remote URL must use https with a host and no credentials")) {
     39         _ = try RadrootsBackgroundTransferRequest(
     40             remoteURL: URL(string: "http://radroots.org/relay.json")!,
     41             method: .get,
     42             operation: .download(destination: destination)
     43         )
     44     }
     45     #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background download transfers must use GET")) {
     46         _ = try RadrootsBackgroundTransferRequest(
     47             remoteURL: URL(string: "https://radroots.org/relay.json")!,
     48             method: .post,
     49             operation: .download(destination: destination)
     50         )
     51     }
     52     #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer header value cannot contain control characters")) {
     53         _ = try RadrootsBackgroundTransferRequest(
     54             remoteURL: URL(string: "https://radroots.org/relay.json")!,
     55             method: .get,
     56             operation: .download(destination: destination),
     57             headers: ["Accept": "application/json\ntext/plain"]
     58         )
     59     }
     60 }
     61 
     62 @Test func backgroundTransferUploadRequiresUploadMethod() throws {
     63     let source = RadrootsBackgroundTransferLocalFile.stagedBlob(
     64         try RadrootsStagedBlobReference(blobID: "upload", sizeBytes: 12)
     65     )
     66 
     67     let request = try RadrootsBackgroundTransferRequest(
     68         remoteURL: URL(string: "https://radroots.org/upload")!,
     69         method: .put,
     70         operation: .upload(source: source)
     71     )
     72 
     73     #expect(request.operation == .upload(source: source))
     74 
     75     #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background upload transfers must use POST or PUT")) {
     76         _ = try RadrootsBackgroundTransferRequest(
     77             remoteURL: URL(string: "https://radroots.org/upload")!,
     78             method: .get,
     79             operation: .upload(source: source)
     80         )
     81     }
     82 }
     83 
     84 @Test func backgroundTransferProgressAndSnapshotValidateBounds() throws {
     85     let request = try testDownloadRequest(identifier: "field.transfer.snapshot")
     86     let progress = try RadrootsBackgroundTransferProgress(bytesTransferred: 5, totalBytesExpected: 10)
     87     let snapshot = try RadrootsBackgroundTransferSnapshot(
     88         request: request,
     89         state: .running,
     90         progress: progress,
     91         errorMessage: " running ",
     92         updatedAt: Date(timeIntervalSince1970: 1)
     93     )
     94 
     95     #expect(snapshot.identifier == request.identifier)
     96     #expect(snapshot.state == .running)
     97     #expect(snapshot.progress == progress)
     98     #expect(snapshot.errorMessage == "running")
     99 
    100     #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer expected byte count cannot be less than transferred bytes")) {
    101         _ = try RadrootsBackgroundTransferProgress(bytesTransferred: 10, totalBytesExpected: 5)
    102     }
    103     #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer updated date must be finite")) {
    104         _ = try RadrootsBackgroundTransferSnapshot(
    105             request: request,
    106             updatedAt: Date(timeIntervalSinceReferenceDate: .infinity)
    107         )
    108     }
    109 }
    110 
    111 @Test func appleBackgroundTransferFileResolverUsesOnlyFileRootsAndStagedBlobs() throws {
    112     let roots = try testBackgroundTransferRoots()
    113     let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots)
    114     let file = RadrootsBackgroundTransferLocalFile.file(
    115         RadrootsFileReference(scope: .data, relativePath: "exports/diagnostics.json")
    116     )
    117     let blob = try RadrootsStagedBlobReference(blobID: "blob-1", sizeBytes: 2)
    118 
    119     #expect(try resolver.resolve(file) == roots.dataRoot.appendingPathComponent("exports/diagnostics.json").standardizedFileURL)
    120     #expect(try resolver.resolve(.stagedBlob(blob)) == roots.stagedBlobsRoot.appendingPathComponent("blob-1").standardizedFileURL)
    121 
    122     #expect(throws: RadrootsAppleFileError.invalidRequest("file relative path must not be absolute")) {
    123         _ = try resolver.resolve(.file(RadrootsFileReference(scope: .data, relativePath: "/tmp/escape")))
    124     }
    125     #expect(throws: RadrootsAppleFileError.invalidRequest("staged blob id contains invalid characters")) {
    126         _ = try resolver.resolve(.stagedBlob(RadrootsStagedBlobReference(blobID: "../escape", sizeBytes: 1)))
    127     }
    128 }
    129 
    130 @Test func appleBackgroundTransferStorePersistsAndRecoversSnapshots() async throws {
    131     let roots = try testBackgroundTransferRoots()
    132     let store = RadrootsAppleBackgroundTransferStore(roots: roots)
    133     let first = try RadrootsBackgroundTransferSnapshot(
    134         request: testDownloadRequest(identifier: "field.transfer.b"),
    135         updatedAt: Date(timeIntervalSince1970: 2)
    136     )
    137     let second = try RadrootsBackgroundTransferSnapshot(
    138         request: testDownloadRequest(identifier: "field.transfer.a"),
    139         state: .running,
    140         updatedAt: Date(timeIntervalSince1970: 3)
    141     )
    142 
    143     try await store.saveSnapshot(first)
    144     try await store.saveSnapshot(second)
    145 
    146     let recoveredStore = RadrootsAppleBackgroundTransferStore(roots: roots)
    147     #expect(try await recoveredStore.loadSnapshots().map(\.identifier.rawValue) == [
    148         "field.transfer.a",
    149         "field.transfer.b"
    150     ])
    151 
    152     try await recoveredStore.removeSnapshot(for: second.identifier)
    153     #expect(try await recoveredStore.loadSnapshots().map(\.identifier.rawValue) == ["field.transfer.b"])
    154 
    155     try await recoveredStore.removeAllSnapshots()
    156     #expect(try await recoveredStore.loadSnapshots().isEmpty)
    157 }
    158 
    159 @Test func unavailableBackgroundTransferThrowsTypedErrors() async throws {
    160     let transfer = RadrootsUnavailableBackgroundTransfer(reason: "missing background transfer support")
    161     let request = try testDownloadRequest(identifier: "field.transfer.unavailable")
    162 
    163     await #expect(throws: RadrootsBackgroundTransferError.unavailable("missing background transfer support")) {
    164         _ = try await transfer.enqueue(request)
    165     }
    166     await #expect(throws: RadrootsBackgroundTransferError.unavailable("missing background transfer support")) {
    167         try await transfer.cancel(request.identifier)
    168     }
    169     await #expect(throws: RadrootsBackgroundTransferError.unavailable("missing background transfer support")) {
    170         _ = try await transfer.snapshot(for: request.identifier)
    171     }
    172     await #expect(throws: RadrootsBackgroundTransferError.unavailable("missing background transfer support")) {
    173         _ = try await transfer.snapshots()
    174     }
    175 }
    176 
    177 private func testDownloadRequest(identifier: String) throws -> RadrootsBackgroundTransferRequest {
    178     try RadrootsBackgroundTransferRequest(
    179         identifier: RadrootsBackgroundTransferIdentifier(identifier),
    180         remoteURL: URL(string: "https://radroots.org/\(identifier).json")!,
    181         method: .get,
    182         operation: .download(
    183             destination: .file(RadrootsFileReference(scope: .cache, relativePath: "\(identifier).json"))
    184         )
    185     )
    186 }
    187 
    188 private func testBackgroundTransferRoots() throws -> RadrootsAppleFileRoots {
    189     let root = FileManager.default.temporaryDirectory
    190         .appendingPathComponent("radroots-background-transfer-\(UUID().uuidString)", isDirectory: true)
    191     return try RadrootsAppleFileRoots(
    192         appIdentifier: "org.radroots.tests",
    193         dataRoot: root.appendingPathComponent("data", isDirectory: true),
    194         cacheRoot: root.appendingPathComponent("cache", isDirectory: true),
    195         temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true)
    196     )
    197 }