RadrootsBackgroundTransferReceiptTests.swift (13427B)
1 import Darwin 2 import Foundation 3 @testable import RadrootsKit 4 import RadrootsKitTesting 5 import Testing 6 7 @Test func appleBackgroundTransferCoordinatorPersistsBoundedDescriptorResponse() async throws { 8 let roots = try appleTransferRoots() 9 let store = RadrootsInMemoryBackgroundTransferStore() 10 let coordinator = RadrootsTransferCoordinator( 11 sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, 12 fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots), 13 now: { Date(timeIntervalSince1970: 710) } 14 ) 15 let body = Data(#"{"url":"https://cdn.radroots.org/a.png"}"#.utf8) 16 let request = try appleUploadRequest( 17 identifier: "field.transfer.upload.descriptor", 18 responsePolicy: .boundedJSON(maximumBodyBytes: 1024) 19 ) 20 try await store.saveSnapshot( 21 RadrootsBackgroundTransferSnapshot(request: request, state: .running) 22 ) 23 24 await coordinator.complete( 25 identifier: request.identifier, 26 completion: RadrootsTransferCompletion( 27 platformError: nil, 28 stagedDownloadResult: nil, 29 httpResult: RadrootsBackgroundHTTPResult( 30 statusCode: 200, mediaType: "application/json", body: body, bodyExceeded: false 31 ), 32 bytesTransferred: 10, 33 totalBytesExpected: 10 34 ) 35 ) 36 37 let snapshot = try #require(try await store.loadSnapshots().first) 38 #expect(snapshot.state == .awaitingVerification) 39 #expect(snapshot.response?.body == body) 40 #expect(snapshot.response?.mediaType == "application/json") 41 #expect(!snapshot.possibleRemoteOrphan) 42 43 await coordinator.complete( 44 identifier: request.identifier, 45 completion: RadrootsTransferCompletion( 46 platformError: nil, 47 stagedDownloadResult: nil, 48 httpResult: RadrootsBackgroundHTTPResult( 49 statusCode: 500, mediaType: nil, body: nil, bodyExceeded: false 50 ), 51 bytesTransferred: 10, 52 totalBytesExpected: 10 53 ) 54 ) 55 #expect(try await store.loadSnapshots().first?.state == .awaitingVerification) 56 } 57 58 @Test func appleBackgroundTransferCoordinatorRejectsEncodedDescriptorResponse() async throws { 59 let roots = try appleTransferRoots() 60 let store = RadrootsInMemoryBackgroundTransferStore() 61 let coordinator = RadrootsTransferCoordinator( 62 sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, 63 fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots) 64 ) 65 let request = try appleUploadRequest( 66 identifier: "field.transfer.upload.encoded", 67 responsePolicy: .boundedJSON(maximumBodyBytes: 1024) 68 ) 69 try await store.saveSnapshot( 70 RadrootsBackgroundTransferSnapshot(request: request, state: .running) 71 ) 72 73 await coordinator.complete( 74 identifier: request.identifier, 75 completion: RadrootsTransferCompletion( 76 platformError: nil, 77 stagedDownloadResult: nil, 78 httpResult: RadrootsBackgroundHTTPResult( 79 statusCode: 200, mediaType: "application/json", body: Data("{}".utf8), 80 contentEncoding: "gzip", bodyExceeded: false 81 ), 82 bytesTransferred: 10, 83 totalBytesExpected: 10 84 ) 85 ) 86 87 let snapshot = try #require(try await store.loadSnapshots().first) 88 #expect(snapshot.state == .failed) 89 #expect(snapshot.failure == .responseContentEncoding) 90 #expect(snapshot.possibleRemoteOrphan) 91 } 92 93 @Test func appleBackgroundTransferCoordinatorRejectsStatusAndOversizedResponse() async throws { 94 let roots = try appleTransferRoots() 95 let store = RadrootsInMemoryBackgroundTransferStore() 96 let coordinator = RadrootsTransferCoordinator( 97 sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, 98 fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots), 99 now: { Date(timeIntervalSince1970: 720) } 100 ) 101 let statusRequest = try appleUploadRequest(identifier: "field.transfer.upload.status") 102 try await store.saveSnapshot( 103 RadrootsBackgroundTransferSnapshot(request: statusRequest, state: .running) 104 ) 105 await coordinator.complete( 106 identifier: statusRequest.identifier, 107 completion: RadrootsTransferCompletion( 108 platformError: nil, 109 stagedDownloadResult: nil, 110 httpResult: RadrootsBackgroundHTTPResult( 111 statusCode: 503, mediaType: nil, body: nil, bodyExceeded: false 112 ), 113 bytesTransferred: 10, 114 totalBytesExpected: 10 115 ) 116 ) 117 #expect(try await store.loadSnapshots().first?.failure == .httpStatus) 118 119 let bodyRequest = try appleUploadRequest( 120 identifier: "field.transfer.upload.oversized", 121 responsePolicy: .boundedJSON(maximumBodyBytes: 32) 122 ) 123 try await store.saveSnapshot( 124 RadrootsBackgroundTransferSnapshot(request: bodyRequest, state: .running) 125 ) 126 await coordinator.complete( 127 identifier: bodyRequest.identifier, 128 completion: RadrootsTransferCompletion( 129 platformError: CancellationError(), 130 stagedDownloadResult: nil, 131 httpResult: RadrootsBackgroundHTTPResult( 132 statusCode: 200, mediaType: "application/json", body: nil, bodyExceeded: true 133 ), 134 bytesTransferred: 10, 135 totalBytesExpected: 10 136 ) 137 ) 138 let oversized = try #require( 139 try await store.loadSnapshots().first { $0.identifier == bodyRequest.identifier } 140 ) 141 #expect(oversized.state == .failed) 142 #expect(oversized.failure == .responseTooLarge) 143 #expect(oversized.possibleRemoteOrphan) 144 } 145 146 @Test func appleBackgroundTransferCoordinatorDoesNotResurrectCancelledTransfer() async throws { 147 let roots = try appleTransferRoots() 148 let store = RadrootsInMemoryBackgroundTransferStore() 149 let coordinator = RadrootsTransferCoordinator( 150 sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, 151 fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots) 152 ) 153 let request = try appleUploadRequest(identifier: "field.transfer.upload.cancelled") 154 try await store.saveSnapshot( 155 RadrootsBackgroundTransferSnapshot(request: request, state: .cancelled) 156 ) 157 158 await coordinator.complete( 159 identifier: request.identifier, 160 completion: RadrootsTransferCompletion( 161 platformError: nil, 162 stagedDownloadResult: nil, 163 httpResult: successfulHTTPResult(), 164 bytesTransferred: 10, 165 totalBytesExpected: 10 166 ) 167 ) 168 169 #expect(try await store.loadSnapshots().first?.state == .cancelled) 170 } 171 172 @Test func appleBackgroundTransferRetainsReceiptWhileProtectedDataIsLocked() async throws { 173 let roots = try appleTransferRoots() 174 let protectedData = RadrootsProtectedDataProbe(state: .available) 175 let store = RadrootsAppleBackgroundTransferStore( 176 roots: roots, protectedData: RadrootsProtectedDataProvider { protectedData.state } 177 ) 178 let request = try appleUploadRequest(identifier: "field.transfer.upload.locked") 179 try await store.saveSnapshot( 180 RadrootsBackgroundTransferSnapshot(request: request, state: .running) 181 ) 182 let coordinator = RadrootsTransferCoordinator( 183 sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, 184 fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots) 185 ) 186 187 protectedData.state = .locked 188 let pending = Task { 189 await coordinator.complete( 190 identifier: request.identifier, 191 completion: RadrootsTransferCompletion(platformError: nil, stagedDownloadResult: nil, 192 httpResult: successfulHTTPResult(), bytesTransferred: 10, 193 totalBytesExpected: 10) 194 ) 195 } 196 for _ in 0 ..< 100 { 197 if await coordinator.hasPendingReceipts { 198 break 199 } 200 try await Task.sleep(for: .milliseconds(10)) 201 } 202 #expect(await coordinator.hasPendingReceipts) 203 let completion = RadrootsCompletionProbe() 204 await coordinator.handleBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") { 205 completion.markCompleted() 206 } 207 await coordinator.finishBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") 208 #expect(!completion.completed) 209 protectedData.state = .available 210 await pending.value 211 #expect(completion.completed) 212 let reopened = RadrootsAppleBackgroundTransferStore(roots: roots) 213 let recovered = try #require(try await reopened.loadSnapshots().first) 214 #expect(recovered.state == .awaitingVerification) 215 #expect(recovered.response?.statusCode == 200) 216 #expect(!recovered.possibleRemoteOrphan) 217 } 218 219 @Test func appleBackgroundTransferCoordinatorInvokesStoredCompletionHandlerAfterFinishedEvents() 220 async throws { 221 let roots = try appleTransferRoots() 222 let store = RadrootsInMemoryBackgroundTransferStore() 223 let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) 224 let coordinator = RadrootsTransferCoordinator( 225 sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, 226 fileResolver: resolver 227 ) 228 let completion = RadrootsCompletionProbe() 229 let secondCompletion = RadrootsCompletionProbe() 230 let unrelated = RadrootsCompletionProbe() 231 232 await coordinator.handleBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") { 233 completion.markCompleted() 234 } 235 #expect(!completion.completed) 236 await coordinator.handleBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") { 237 secondCompletion.markCompleted() 238 } 239 240 await coordinator.handleBackgroundEvents(identifier: "other.session") { 241 unrelated.markCompleted() 242 } 243 #expect(unrelated.completed) 244 245 await coordinator.finishBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") 246 #expect(completion.completed) 247 #expect(secondCompletion.completed) 248 await coordinator.finishBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") 249 #expect(completion.completionCount == 1) 250 #expect(secondCompletion.completionCount == 1) 251 } 252 253 @Test func appleBackgroundTransferCoordinatorClaimsFinishBeforeHandlerExactlyOnce() async throws { 254 let roots = try appleTransferRoots() 255 let coordinator = RadrootsTransferCoordinator( 256 sessionIdentifier: "org.radroots.field-ios.background.transfer", 257 store: RadrootsInMemoryBackgroundTransferStore(), 258 fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots) 259 ) 260 let completion = RadrootsCompletionProbe() 261 262 await coordinator.finishBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") 263 await coordinator.handleBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") { 264 completion.markCompleted() 265 } 266 267 #expect(completion.completionCount == 1) 268 } 269 270 @Test func appleBackgroundTransferRequiresExplicitVerificationSettlement() async throws { 271 let request = try appleUploadRequest(identifier: "field.transfer.settlement") 272 let response = try RadrootsBackgroundTransferResponse(statusCode: 200, mediaType: nil, body: nil) 273 let store = try RadrootsInMemoryBackgroundTransferStore( 274 snapshots: [ 275 RadrootsBackgroundTransferSnapshot( 276 request: request, 277 state: .awaitingVerification, 278 response: response 279 ) 280 ] 281 ) 282 let transfer = RadrootsAppleBackgroundTransfer( 283 store: store, 284 adapters: RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 800)).adapters() 285 ) 286 287 try await transfer.settle(request.identifier, verification: .accepted) 288 289 #expect(try await store.loadSnapshots().first?.state == .completed) 290 #expect(try await store.loadSnapshots().first?.response == response) 291 await #expect( 292 throws: RadrootsBackgroundTransferError.invalidRequest 293 ) { 294 try await transfer.settle(request.identifier, verification: .accepted) 295 } 296 } 297 298 @Test func appleBackgroundTransferRejectedVerificationRemainsRecoverableWithoutClaimingSuccess() 299 async throws { 300 let request = try appleUploadRequest(identifier: "field.transfer.rejected") 301 let store = try RadrootsInMemoryBackgroundTransferStore( 302 snapshots: [ 303 RadrootsBackgroundTransferSnapshot( 304 request: request, 305 state: .awaitingVerification, 306 response: RadrootsBackgroundTransferResponse(statusCode: 200, mediaType: nil, body: nil) 307 ) 308 ] 309 ) 310 let transfer = RadrootsAppleBackgroundTransfer( 311 store: store, 312 adapters: RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 850)).adapters() 313 ) 314 315 try await transfer.settle( 316 request.identifier, 317 verification: .rejected(failure: .verificationRejected) 318 ) 319 320 let snapshot = try #require(try await store.loadSnapshots().first) 321 #expect(snapshot.state == .failed) 322 #expect(snapshot.failure == .verificationRejected) 323 #expect(snapshot.possibleRemoteOrphan) 324 #expect(snapshot.response == nil) 325 }