field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

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 }