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 }