field_ios

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

TeraComposerPersistenceFFITests.swift (12039B)


      1 import Foundation
      2 @testable import TeraApp
      3 import TeraKitBindings
      4 import XCTest
      5 
      6 final class TeraComposerPersistenceFFITests: XCTestCase {
      7   private let scope = TeraComposerScope(
      8     authorPublicKey: "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", localNetworkID: "nearby"
      9   )
     10 
     11   func testProductionClientPreservesIncompleteFieldsExactReceiptsAndRelaunch() async throws {
     12     let fixture = try MediaOwnershipFixture()
     13     defer { fixture.remove() }
     14     let signer = ComposerForbiddenSigner()
     15     let configuration = configuration(fixture, signer: signer)
     16     let client = TeraRuntimeClient.production()
     17     _ = try await client.start(configuration: configuration)
     18     let id = try await client.reserveComposerID()
     19     XCTAssertEqual(id.count, 32)
     20     var form = partialForm(fixture)
     21     let first = try await client.saveComposer(request: TeraComposerSaveRequest(
     22       scope: scope, id: id, expectedRevision: nil, editSequence: .max - 1, form: form
     23     ))
     24     XCTAssertEqual(first.draft.scope, scope)
     25     XCTAssertEqual(first.draft.id, id)
     26     XCTAssertEqual(first.draft.revision, 1)
     27     XCTAssertEqual(first.draft.editSequence, .max - 1)
     28     XCTAssertEqual(first.draft.form, form)
     29     XCTAssertFalse(first.replayed)
     30     form.content = "newest incomplete"
     31     let request = TeraComposerSaveRequest(scope: scope, id: id, expectedRevision: 1, editSequence: .max, form: form)
     32     let saved = try await client.saveComposer(request: request)
     33     XCTAssertEqual(saved.draft.revision, 2)
     34     XCTAssertEqual(saved.draft.editSequence, .max)
     35     do {
     36       _ = try await client.saveComposer(request: request)
     37       XCTFail("A stale revision must not overwrite the durable head.")
     38     } catch let TeraRuntimeClientError.add(failure) {
     39       XCTAssertEqual(failure.code, "composer_revision_conflict")
     40       XCTAssertEqual(failure.recoveryActions, ["reload_composer"])
     41     }
     42     try await assertInventory(client, id: id)
     43     _ = try await client.stop()
     44     try await assertStopped(client, id: id)
     45     _ = try await client.start(configuration: configuration)
     46     let reopened = try await client.loadComposer(scope: scope, id: id)
     47     XCTAssertEqual(reopened, saved.draft)
     48     let foreign = TeraComposerScope(authorPublicKey: scope.authorPublicKey, localNetworkID: "elsewhere")
     49     do {
     50       _ = try await client.loadComposer(scope: foreign, id: id)
     51       XCTFail("A foreign context must not reopen the composer.")
     52     } catch let TeraRuntimeClientError.add(failure) {
     53       XCTAssertEqual(failure.code, "composer_scope_mismatch")
     54     }
     55     _ = try await client.stop()
     56     let signingRequests = await signer.requests
     57     XCTAssertEqual(signingRequests, 0)
     58   }
     59 
     60   func testGeneratedBoundaryRejectsNestedVersionsAndPreservesPartialMediaMetadata() async throws {
     61     let fixture = try MediaOwnershipFixture()
     62     defer { fixture.remove() }
     63     let runtime = try await fixture.runtime()
     64     let form = partialForm(fixture)
     65     XCTAssertEqual(try form.generatedValue.composerAppValue, form)
     66     let request = try TeraComposerSaveRequest(scope: scope, id: TeraGeneratedComposer.reserveID(),
     67                                               expectedRevision: nil, editSequence: 1, form: form).generatedValue
     68     var wrongRequest = request
     69     wrongRequest.schemaVersion = 2
     70     var wrongScope = request
     71     wrongScope.scope.schemaVersion = 2
     72     var wrongForm = request
     73     wrongForm.form.schemaVersion = 2
     74     var wrongMedia = request
     75     wrongMedia.form.media[0].schemaVersion = 2
     76     for invalid in [wrongRequest, wrongScope, wrongForm, wrongMedia] {
     77       do {
     78         _ = try await runtime.composerSave(request: invalid)
     79         XCTFail("Unsupported versions must fail before persistence.")
     80       } catch let TeraAppError.Failure(report) {
     81         XCTAssertEqual(report.schemaVersion, 1)
     82         XCTAssertEqual(report.code, "composer_schema_unsupported")
     83         XCTAssertFalse(report.retryable)
     84       }
     85     }
     86     XCTAssertThrowsError(try wrongForm.form.composerAppValue)
     87     XCTAssertThrowsError(try wrongMedia.form.composerAppValue)
     88     let empty = try await runtime.composerList(schemaVersion: 1, scope: scope.generatedValue, limit: 10, cursor: nil)
     89     XCTAssertTrue(empty.entries.isEmpty)
     90     _ = try await runtime.shutdown()
     91   }
     92 
     93   @MainActor
     94   func testAddSavePersistsIncompleteEditingWithoutPreparingOrSigningAnOperation() async throws {
     95     let fixture = try MediaOwnershipFixture()
     96     defer { fixture.remove() }
     97     let signer = ComposerForbiddenSigner()
     98     let configuration = configuration(fixture, signer: signer)
     99     let client = TeraRuntimeClient.production()
    100     let snapshot = try await client.start(configuration: configuration)
    101     let store = TeraAddStore(runtimeClient: client)
    102     store.configure(snapshot: snapshot)
    103     await store.start()
    104     store.selectType(.createEvent)
    105     store.updateForm(\.eventStartDate, "2026-09-")
    106     store.updateForm(\.eventEndDate, "")
    107     store.updateForm(\.title, "")
    108     store.updateForm(\.content, "  unfinished event  ")
    109     await store.save()
    110     let saved = try XCTUnwrap(store.savedComposer)
    111     XCTAssertEqual(saved.form.eventStartDate, "2026-09-")
    112     XCTAssertEqual(saved.form.content, "  unfinished event  ")
    113     XCTAssertEqual(store.composerState, .saved)
    114     XCTAssertEqual(saved.scope.localNetworkID, "default")
    115     XCTAssertNil(store.activeDraft)
    116     let operations = try await client.draftHeads()
    117     XCTAssertTrue(operations.isEmpty)
    118     store.updateForm(\.content, "new unsaved edit")
    119     XCTAssertNil(store.message)
    120     XCTAssertEqual(store.composerState, .unsaved)
    121     store.stop()
    122     _ = try await client.stop()
    123     _ = try await client.start(configuration: configuration)
    124     let recovered = try await client.loadComposer(scope: saved.scope, id: saved.id)
    125     XCTAssertEqual(recovered, saved)
    126     let signingRequests = await signer.requests
    127     XCTAssertEqual(signingRequests, 0)
    128     _ = try await client.stop()
    129   }
    130 
    131   private func partialForm(_ fixture: MediaOwnershipFixture) -> TeraComposerForm {
    132     var editing = TeraAddForm(commandType: .createEvent)
    133     editing.content = "  private incomplete\n\u{0}é  "
    134     editing.eventTiming = .allDay
    135     editing.eventStartDate = "2026-09-"
    136     editing.eventEndDate = ""
    137     editing.eventTimezone = "Mars/unfinished"
    138     editing.eventStartUnixSeconds = .max
    139     editing.priceAmount = "12."
    140     editing.identifier = "partial-id"
    141     editing.title = ""
    142     editing.summary = "unfinished summary"
    143     editing.location = "unfinished place"
    144     editing.eventEndUnixSeconds = 0
    145     editing.currency = "u"
    146     editing.unit = "b"
    147     editing.quantity = "-"
    148     editing.foodPublishedAtUnixSeconds = .max
    149     editing.foodStatus = "unknown"
    150     editing.media = [fixture.media]
    151     return TeraComposerForm(editing: editing)
    152   }
    153 
    154   @MainActor
    155   func testStartupRecoveryPagesBeyondOneHundredAndReopensExactIncompleteRevision() async throws {
    156     let fixture = try OfflineMediaFixture()
    157     defer { fixture.remove() }
    158     let signer = ComposerForbiddenSigner()
    159     let configuration = configuration(fixture.runtime, signer: signer)
    160     let client = TeraRuntimeClient.production()
    161     _ = try await client.start(configuration: configuration)
    162     let scope = TeraComposerScope(authorPublicKey: scope.authorPublicKey, localNetworkID: "default")
    163     let form = try await durablePartialForm(fixture)
    164     var ids = Set<String>()
    165     var saved: TeraComposerDraft?
    166     for _ in 0 ..< 101 {
    167       let id = try await client.reserveComposerID()
    168       ids.insert(id)
    169       saved = try await client.saveComposer(request: TeraComposerSaveRequest(
    170         scope: scope, id: id, expectedRevision: nil, editSequence: 17, form: form
    171       )).draft
    172     }
    173     let selected = try XCTUnwrap(saved)
    174     _ = try await client.stop()
    175     let snapshot = try await client.start(configuration: configuration)
    176     let store = TeraAddStore(runtimeClient: client, media: fixture.coordinator())
    177     store.configure(snapshot: snapshot)
    178     await store.start()
    179     try await assertRecoveryPages(store.recovery, ids: ids)
    180     let applied = await store.reopenSaved(.composer(selected.id))
    181     XCTAssertTrue(applied)
    182     XCTAssertEqual(TeraComposerForm(editing: store.form), form)
    183     XCTAssertEqual(store.savedComposer, selected)
    184     XCTAssertEqual(store.composerState, .saved)
    185     XCTAssertTrue(store.form.media.allSatisfy { $0.remoteURL == nil })
    186     XCTAssertNil(store.activeDraft)
    187     store.updateForm(\.content, "continued incomplete editing")
    188     await store.save()
    189     let updated = try XCTUnwrap(store.savedComposer)
    190     XCTAssertEqual(updated.id, selected.id)
    191     XCTAssertEqual(updated.revision, 2)
    192     XCTAssertEqual(updated.editSequence, 18)
    193     let loaded = try await client.loadComposer(scope: scope, id: selected.id)
    194     XCTAssertEqual(loaded, updated)
    195     let operations = try await client.legacyDraftPage()
    196     XCTAssertTrue(operations.entries.isEmpty)
    197     store.stop()
    198     _ = try await client.stop()
    199     let signingRequests = await signer.requests
    200     XCTAssertEqual(signingRequests, 0)
    201   }
    202 
    203   @MainActor
    204   private func durablePartialForm(_ fixture: OfflineMediaFixture) async throws -> TeraComposerForm {
    205     var form = partialForm(fixture.runtime)
    206     var editing = TeraAddForm(commandType: .createEvent)
    207     editing.media = try await fixture.coordinator().importImages(limit: 1)
    208     form.media = TeraComposerForm(editing: editing).media
    209     return form
    210   }
    211 
    212   @MainActor
    213   private func assertRecoveryPages(_ recovery: TeraDraftRecoveryStore, ids: Set<String>) async throws {
    214     await TeraScopeFixtures.eventually { !recovery.isLoading }
    215     XCTAssertNil(recovery.composerError)
    216     XCTAssertEqual(recovery.composers.count, 100)
    217     let first = recovery.composers.compactMap { entry -> String? in
    218       if case let .draft(summary) = entry {
    219         return summary.id
    220       }; return nil
    221     }
    222     recovery.moreComposers()
    223     await TeraScopeFixtures.eventually { !recovery.isLoading }
    224     XCTAssertEqual(recovery.composers.count, 1)
    225     guard case let .draft(last) = try XCTUnwrap(recovery.composers.first) else { return XCTFail("Missing final page") }
    226     XCTAssertEqual(Set(first + [last.id]), ids)
    227     XCTAssertNil(recovery.composerCursor)
    228   }
    229 
    230   private func assertInventory(_ client: TeraRuntimeClient, id: String) async throws {
    231     let page = try await client.listComposers(scope: scope, limit: 1)
    232     XCTAssertEqual(page.scope, scope)
    233     XCTAssertNil(page.nextCursor)
    234     guard case let .draft(summary) = try XCTUnwrap(page.entries.first) else {
    235       return XCTFail("The saved composer must have a summary.")
    236     }
    237     XCTAssertEqual(summary.id, id)
    238     XCTAssertEqual(summary.revision, 2)
    239     XCTAssertEqual(summary.editSequence, .max)
    240     let legacy = try await client.draftHeads()
    241     XCTAssertTrue(legacy.isEmpty)
    242   }
    243 
    244   private func assertStopped(_ client: TeraRuntimeClient, id: String) async throws {
    245     do {
    246       _ = try await client.loadComposer(scope: scope, id: id)
    247       XCTFail("Stopped clients must refuse composer operations.")
    248     } catch {
    249       XCTAssertEqual(error as? TeraRuntimeClientError, .notRunning)
    250     }
    251   }
    252 
    253   private func configuration(_ fixture: MediaOwnershipFixture, signer: ComposerForbiddenSigner) -> TeraRuntimeLaunchConfiguration {
    254     TeraRuntimeLaunchConfiguration(
    255       applicationSupportDirectory: fixture.root.path, publicKeyHex: scope.authorPublicKey,
    256       sourceGenerationHex: String(repeating: "04", count: 32), sourceGenerationCreatedAtUnixMilliseconds: 1_800_000_000_000,
    257       protectedData: .available, networkProfile: .publicNetwork, writableRelays: ["wss://relay.example"], blossom: nil,
    258       app: TeraRuntimeAppMetadata(bundleIdentifier: "test.composer", version: "1", buildNumber: "1", buildSHA: nil),
    259       signerGeneration: "composer-test", signer: signer, adoptBootstrapSettings: false
    260     )
    261   }
    262 }
    263 
    264 actor ComposerForbiddenSigner: TeraRuntimeSigner {
    265   private(set) var requests = 0
    266   func availability() -> TeraRuntimeSignerAvailability {
    267     .ready
    268   }
    269 
    270   func sign(_: TeraRuntimeSigningRequest) -> TeraRuntimeSigningOutcome {
    271     requests += 1
    272     return .failed
    273   }
    274 }