TeraOfflineMediaTests.swift (12583B)
1 import CryptoKit 2 import RadrootsKit 3 @testable import TeraApp 4 import UIKit 5 import XCTest 6 7 @MainActor 8 final class TeraOfflineMediaTests: XCTestCase { 9 func testUnobservedServiceKeepsLocalImportAndIncompleteMetadataSaveAvailable() async throws { 10 let fixture = try OfflineMediaFixture() 11 defer { fixture.remove() } 12 let client = TeraRuntimeClient.production() 13 let signer = ComposerForbiddenSigner() 14 let configuration = fixture.configuration(signer) 15 let snapshot = try await client.start(configuration: configuration) 16 // A nil launch override selects the public default; it is not an uplink probe. 17 XCTAssertNotNil(snapshot.blossomConfiguration) 18 let store = TeraAddStore(runtimeClient: client, media: fixture.coordinator()) 19 store.configure(snapshot: snapshot) 20 await store.start() 21 store.selectType(.createPhotoUpdate) 22 XCTAssertEqual(store.mediaSupport, .init(library: true, camera: true)) 23 await store.importPhotos() 24 let photo = try XCTUnwrap(store.form.media.first) 25 XCTAssertNil(photo.remoteURL) 26 XCTAssertEqual(photo.alt, "") 27 let bytes = try Data(contentsOf: fixture.roots.stagedBlobsRoot.appendingPathComponent(photo.sha256)) 28 XCTAssertEqual(SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined(), photo.sha256) 29 XCTAssertEqual(UInt64(bytes.count), photo.byteSize) 30 store.updateForm(\.content, " unfinished photo update ") 31 await store.save() 32 let saved = try XCTUnwrap(store.savedComposer) 33 XCTAssertEqual(saved.form.media.first?.alt, "") 34 XCTAssertEqual(saved.form.media.first?.opaqueReference, photo.opaqueReference) 35 await store.submit() 36 XCTAssertEqual(store.submissions.failureCode, "invalid_media_reference") 37 XCTAssertNil(store.activeDraft) 38 let operations = try await client.legacyDraftPage() 39 XCTAssertTrue(operations.entries.isEmpty) 40 let afterSave = try await client.snapshot() 41 XCTAssertEqual(afterSave.blossomEvidence, snapshot.blossomEvidence) 42 store.stop() 43 _ = try await client.stop() 44 try await assertRelaunch(fixture, client: client, configuration: configuration, saved: saved) 45 let signs = await signer.requests 46 let transfers = await fixture.transfer.enqueueCount 47 XCTAssertEqual(signs, 0) 48 XCTAssertEqual(transfers, 0) 49 let requests = await fixture.picker.importRequests 50 XCTAssertEqual(requests.count, 1) 51 XCTAssertEqual(requests.first?.destinationScope, .cache) 52 XCTAssertEqual(requests.first?.selectionLimit, 20) 53 } 54 55 func testAbsentServiceSnapshotKeepsLocalIntakeAndSaveWithoutProbing() async throws { 56 let backend = try TeraScopeBackend() 57 let original = await backend.value 58 let snapshot = TeraRuntimeSnapshot(identity: original.identity, relay: original.relay, 59 blossomConfiguration: nil, blossomEvidence: nil, 60 crateName: original.crateName, crateVersion: original.crateVersion, isClosed: false) 61 await backend.configure(snapshot) 62 let client = try await TeraScopeFixtures.client(backend) 63 let store = TeraAddStore(runtimeClient: client, media: AddMediaHarness()) 64 store.configure(snapshot: snapshot) 65 await store.start() 66 store.selectType(.createPhotoUpdate) 67 await store.checkPhotoService() 68 XCTAssertEqual(store.mediaSupport, .init(library: true, camera: true)) 69 await store.importPhotos() 70 let photo = try XCTUnwrap(store.form.media.first) 71 store.updateMediaAlt(id: photo.id, alt: "") 72 await store.save() 73 XCTAssertEqual(store.savedComposer?.form.media.first?.alt, "") 74 XCTAssertTrue(store.form.media.allSatisfy { $0.remoteURL == nil }) 75 let counts = await backend.counts 76 XCTAssertNil(counts[.probe]) 77 XCTAssertNil(counts[.save]) 78 store.stop() 79 _ = try await client.stop() 80 } 81 82 func testFailedServiceProbeDoesNotDisablePickerOrPersistAnUploadURL() async throws { 83 let backend = try TeraScopeBackend() 84 let client = try await TeraScopeFixtures.client(backend) 85 let store = TeraAddStore(runtimeClient: client, media: AddMediaHarness()) 86 store.configure(snapshot: TeraScopeFixtures.snapshot()) 87 await store.start() 88 store.selectType(.createPhotoUpdate) 89 let failure = await backend.pause(.probe, fails: true) 90 let probe = Task { await store.checkPhotoService() } 91 await failure.entered.wait() 92 await failure.resume.open() 93 await probe.value 94 XCTAssertEqual(store.mediaSupport, .init(library: true, camera: true)) 95 XCTAssertNotNil(store.message) 96 await store.importPhotos() 97 let photo = try XCTUnwrap(store.form.media.first) 98 store.updateMediaAlt(id: photo.id, alt: " unfinished description\n") 99 await store.save() 100 XCTAssertEqual(store.savedComposer?.form.media.first?.alt, " unfinished description\n") 101 XCTAssertTrue(store.form.media.allSatisfy { $0.remoteURL == nil }) 102 let counts = await backend.counts 103 XCTAssertEqual(counts[.probe], 1) 104 XCTAssertNil(counts[.save]) 105 store.stop() 106 _ = try await client.stop() 107 } 108 109 func testCameraIntakeRemainsLocalAndDeniedPermissionPreservesExistingEditing() async throws { 110 let fixture = try OfflineMediaFixture() 111 defer { fixture.remove() } 112 let backend = try TeraScopeBackend() 113 let client = try await TeraScopeFixtures.client(backend) 114 let store = TeraAddStore(runtimeClient: client, media: fixture.coordinator()) 115 store.configure(snapshot: TeraScopeFixtures.snapshot()) 116 await store.start() 117 store.selectType(.createPhotoUpdate) 118 await store.capturePhoto() 119 let photo = try XCTUnwrap(store.form.media.first) 120 XCTAssertNil(photo.remoteURL) 121 XCTAssertEqual(photo.alt, "") 122 let editing = store.form 123 await fixture.picker.denyPermission() 124 await store.importPhotos() 125 XCTAssertEqual(store.form, editing) 126 XCTAssertNotNil(store.message) 127 XCTAssertNil(store.activeDraft) 128 let requests = await fixture.picker.captureRequests 129 XCTAssertEqual(requests.count, 1) 130 XCTAssertEqual(requests.first?.destinationScope, .cache) 131 let transfers = await fixture.transfer.enqueueCount 132 XCTAssertEqual(transfers, 0) 133 store.stop() 134 _ = try await client.stop() 135 } 136 137 func testUnavailablePickerIsNotEnabledByConfiguredOrReachableService() async throws { 138 let fixture = try OfflineMediaFixture() 139 defer { fixture.remove() } 140 await fixture.picker.setUnavailable() 141 let backend = try TeraScopeBackend() 142 let client = try await TeraScopeFixtures.client(backend) 143 let store = TeraAddStore(runtimeClient: client, media: fixture.coordinator()) 144 store.configure(snapshot: TeraScopeFixtures.snapshot()) 145 await store.start() 146 XCTAssertEqual(store.mediaSupport, .unavailable) 147 await store.checkPhotoService() 148 XCTAssertEqual(store.mediaSupport, .unavailable) 149 store.stop() 150 _ = try await client.stop() 151 } 152 153 private func assertRelaunch(_ fixture: OfflineMediaFixture, client: TeraRuntimeClient, 154 configuration: TeraRuntimeLaunchConfiguration, saved: TeraComposerDraft) async throws 155 { 156 let snapshot = try await client.start(configuration: configuration) 157 let coordinator = fixture.coordinator() 158 let store = TeraAddStore(runtimeClient: client, media: coordinator) 159 store.configure(snapshot: snapshot) 160 await store.start() 161 let reopened = await store.reopenSaved(.composer(saved.id)) 162 XCTAssertTrue(reopened) 163 XCTAssertEqual(store.savedComposer, saved) 164 XCTAssertEqual(TeraComposerForm(editing: store.form), saved.form) 165 let opened = try await coordinator.open(store.form.media) 166 opened.close() 167 let photo = try XCTUnwrap(store.form.media.first) 168 store.updateMediaAlt(id: photo.id, alt: "Carrots prepared for the market") 169 await store.save() 170 XCTAssertEqual(store.savedComposer?.id, saved.id) 171 XCTAssertEqual(store.savedComposer?.revision, saved.revision + 1) 172 XCTAssertEqual(store.savedComposer?.form.media.first?.alt, "Carrots prepared for the market") 173 let loaded = try await client.loadComposer(scope: saved.scope, id: saved.id) 174 XCTAssertEqual(loaded, store.savedComposer) 175 store.stop() 176 _ = try await client.stop() 177 } 178 } 179 180 @MainActor 181 struct OfflineMediaFixture { 182 let runtime: MediaOwnershipFixture 183 let roots: RadrootsAppleFileRoots 184 let picker: OfflineMediaPicker 185 let transfer = BackgroundTransferHarness() 186 187 init(image: Data? = nil, filename: String = "input.png", mediaType: String = "image/png") throws { 188 runtime = try MediaOwnershipFixture() 189 roots = try TeraDurableMediaRoots.selectingStaging(in: RadrootsAppleFileRoots(appIdentifier: "test.offline-media", 190 dataRoot: runtime.root.appendingPathComponent("media/data"), 191 cacheRoot: runtime.root.appendingPathComponent("media/cache"), 192 temporaryRoot: runtime.root.appendingPathComponent("media/temporary"))) 193 try FileManager.default.createDirectory(at: roots.cacheRoot, withIntermediateDirectories: true) 194 let bytes = image ?? UIGraphicsImageRenderer(size: CGSize(width: 2, height: 2)).pngData { context in 195 UIColor.green.setFill() 196 context.fill(CGRect(x: 0, y: 0, width: 2, height: 2)) 197 } 198 try bytes.write(to: roots.cacheRoot.appendingPathComponent(filename)) 199 picker = OfflineMediaPicker(byteSize: UInt64(bytes.count), filename: filename, mediaType: mediaType) 200 } 201 202 func coordinator(cameraAccess: @escaping @Sendable () -> TeraCameraAccess = { .authorized }) -> TeraAddMediaCoordinator { 203 TeraAddMediaCoordinator(roots: roots, picker: picker, preparer: RadrootsAppleMediaPreparer(roots: roots), 204 transfer: transfer, clock: .fixed(unixSeconds: 1_800_000_000), cameraAccess: cameraAccess) 205 } 206 207 func configuration(_ signer: ComposerForbiddenSigner) -> TeraRuntimeLaunchConfiguration { 208 TeraRuntimeLaunchConfiguration( 209 applicationSupportDirectory: runtime.root.path, 210 publicKeyHex: "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", 211 sourceGenerationHex: String(repeating: "04", count: 32), sourceGenerationCreatedAtUnixMilliseconds: 1_800_000_000_000, 212 protectedData: .available, networkProfile: .publicNetwork, writableRelays: [], blossom: nil, 213 app: TeraRuntimeAppMetadata(bundleIdentifier: "test.offline-media", version: "1", buildNumber: "1", buildSHA: nil), 214 signerGeneration: "offline-media-test", signer: signer, adoptBootstrapSettings: false 215 ) 216 } 217 218 func remove() { 219 runtime.remove() 220 } 221 } 222 223 actor OfflineMediaPicker: RadrootsMediaPicker { 224 let byteSize: UInt64 225 let filename: String 226 let mediaType: String 227 private var denied = false 228 private var available = true 229 private(set) var importRequests: [RadrootsMediaImportRequest] = [] 230 private(set) var captureRequests: [RadrootsMediaCaptureRequest] = [] 231 232 init(byteSize: UInt64, filename: String = "input.png", mediaType: String = "image/png") { 233 self.byteSize = byteSize 234 self.filename = filename 235 self.mediaType = mediaType 236 } 237 238 func denyPermission() { 239 denied = true 240 } 241 242 func setUnavailable() { 243 available = false 244 } 245 246 func currentSupport() throws -> RadrootsMediaPickerSupport { 247 try RadrootsMediaPickerSupport(importAvailable: available, cameraCaptureAvailable: available, 248 supportedImportKinds: [.image], supportedCaptureKinds: [.image], multipleSelectionSupported: true) 249 } 250 251 func importMedia(_ request: RadrootsMediaImportRequest) throws -> RadrootsMediaImportResult { 252 importRequests.append(request) 253 return try RadrootsMediaImportResult(items: [asset(.libraryImport)]) 254 } 255 256 func captureMedia(_ request: RadrootsMediaCaptureRequest) throws -> RadrootsMediaCaptureResult { 257 captureRequests.append(request) 258 return try RadrootsMediaCaptureResult(item: asset(.cameraCapture)) 259 } 260 261 private func asset(_ source: RadrootsMediaSource) throws -> RadrootsMediaAsset { 262 guard !denied else { throw RadrootsCaptureIntakeError.permissionDenied } 263 guard available else { throw RadrootsCaptureIntakeError.unavailable } 264 return try RadrootsMediaAsset(source: source, kind: .image, file: RadrootsFileReference(scope: .cache, relativePath: filename), 265 mediaType: mediaType, suggestedFilename: filename, sizeBytes: byteSize, 266 pixelWidth: 2, pixelHeight: 2, capturedAt: Date(timeIntervalSince1970: 1_800_000_000)) 267 } 268 }