field_ios

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

TeraSubmissionOperationFFITests.swift (12413B)


      1 import Foundation
      2 @testable import TeraApp
      3 import TeraKitBindings
      4 import XCTest
      5 
      6 final class TeraSubmissionOperationFFITests: XCTestCase {
      7   private let scope = TeraComposerScope(
      8     authorPublicKey: "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", localNetworkID: "nearby"
      9   )
     10 
     11   @MainActor
     12   func testNewerEditingSavesAcrossBlockedAndFailedMediaPreparationWithoutChangingReservedSource() async throws {
     13     let fixture = try MediaOwnershipFixture()
     14     defer { fixture.remove() }
     15     let client = TeraRuntimeClient.production()
     16     let configuration = configuration(fixture, signer: ComposerForbiddenSigner())
     17     let snapshot = try await client.start(configuration: configuration)
     18     let pause = ResourceTestPause()
     19     let media = AddMediaHarness(openPause: pause, failOpen: true)
     20     let store = TeraAddStore(runtimeClient: client, media: media)
     21     store.configure(snapshot: snapshot)
     22     await store.start()
     23     store.selectType(.createPhotoUpdate)
     24     store.updateForm(\.content, "original reserved photo")
     25     store.updateForm(\.media, [fixture.media])
     26     let submit = Task { await store.submit() }
     27     await pause.entered.wait()
     28     let request = try XCTUnwrap(store.submissions.request)
     29     let reserved = try await client.reserveSubmission(request: request)
     30     XCTAssertEqual(reserved.captured.form.content, "original reserved photo")
     31     store.updateForm(\.content, "editing during blocked preparation")
     32     XCTAssertTrue(store.canSave)
     33     await store.save()
     34     XCTAssertEqual(store.savedComposer?.form.content, "editing during blocked preparation")
     35     await pause.resume.open()
     36     await submit.value
     37     XCTAssertEqual(store.submissions.request, request)
     38     XCTAssertNil(store.submissions.status)
     39     store.updateForm(\.content, "editing after preparation failed")
     40     XCTAssertTrue(store.canSave)
     41     await store.save()
     42     let editing = try XCTUnwrap(store.savedComposer)
     43     XCTAssertNotEqual(editing.id, request.composerID)
     44     XCTAssertEqual(editing.form.content, "editing after preparation failed")
     45     let original = try await client.loadComposer(scope: request.scope, id: request.composerID)
     46     XCTAssertEqual(original, reserved.captured)
     47     store.stop()
     48     _ = try await client.stop()
     49     _ = try await client.start(configuration: configuration)
     50     let restored = try await client.loadComposer(scope: editing.scope, id: editing.id)
     51     let replay = try await client.reserveSubmission(request: request)
     52     XCTAssertEqual(restored, editing)
     53     XCTAssertEqual(restored.form.content, "editing after preparation failed")
     54     XCTAssertEqual(replay.captured, reserved.captured)
     55     XCTAssertTrue(replay.replayed)
     56     // The original source head still satisfies the production prepare CAS.
     57     let file = try fixture.original()
     58     defer { try? file.close() }
     59     let handle = try TeraPreparedMediaHandle(media: fixture.media, fileDescriptor: UInt64(file.fileDescriptor))
     60     let committed = try await client.prepareSubmission(request: request, media: [handle])
     61     XCTAssertEqual(committed.captured, reserved.captured)
     62     XCTAssertEqual(committed.request, request)
     63     _ = try await client.stop()
     64   }
     65 
     66   func testAllFiveProductionCapturesReplayAcrossEditingAndSQLiteReconstruction() async throws {
     67     let fixture = try MediaOwnershipFixture()
     68     defer { fixture.remove() }
     69     let runtime = try await fixture.runtime()
     70     try await runtime.configureSimulatorRelays(loopbackRelays: ["ws://127.0.0.1:19999"])
     71     let backend = TeraGeneratedRuntimeBackend(runtime: runtime)
     72     let file = try fixture.original()
     73     defer { try? file.close() }
     74     let media = try TeraPreparedMediaHandle(media: fixture.media, fileDescriptor: UInt64(file.fileDescriptor))
     75     var committed: [TeraSubmissionStatus] = []
     76     for type in TeraAddCommandType.allCases {
     77       let form = form(type, media: fixture.media)
     78       let id = try await backend.reserveComposerID()
     79       let saved = try await backend.saveComposer(request: TeraComposerSaveRequest(
     80         scope: scope, id: id, expectedRevision: nil, editSequence: 1, form: form
     81       ))
     82       let request = try await TeraSubmissionRequest(commandID: backend.reserveSubmissionID(),
     83                                                     scope: scope, composerID: id, expectedRevision: saved.draft.revision)
     84       let handles = type == .createPhotoUpdate ? [media] : []
     85       async let left = backend.prepareSubmission(request: request, media: handles)
     86       async let right = backend.prepareSubmission(request: request, media: handles)
     87       let (first, duplicate) = try await (left, right)
     88       XCTAssertEqual(first, duplicate)
     89       XCTAssertEqual(first.captured, saved.draft)
     90       XCTAssertEqual(first.state, type == .createPhotoUpdate ? .mediaPreparing : .readyToSign)
     91       XCTAssertEqual(first.settlement.signed, 0)
     92       var editing = form
     93       editing.content = "newer incomplete editing"
     94       _ = try await backend.saveComposer(request: TeraComposerSaveRequest(
     95         scope: scope, id: id, expectedRevision: 1, editSequence: 2, form: editing
     96       ))
     97       let replay = try await backend.prepareSubmission(request: request, media: [])
     98       XCTAssertEqual(replay, first, "Committed replay must not reopen local media")
     99       committed.append(first)
    100     }
    101     let legacy = try await backend.draftHeads(limit: 100)
    102     XCTAssertTrue(legacy.isEmpty)
    103     let page = try await backend.listSubmissions(scope: scope, limit: 100, cursor: nil)
    104     XCTAssertEqual(page.entries.count, 5)
    105     XCTAssertEqual(Set(committed.map(\.operationID)).count, 5)
    106     _ = try await runtime.shutdown()
    107     try await assertReconstruction(fixture, committed: committed)
    108   }
    109 
    110   func testProductionClientRetainsOriginalQueuedStatusAfterUnavailableSignerAndRestart() async throws {
    111     let fixture = try MediaOwnershipFixture()
    112     defer { fixture.remove() }
    113     let signer = ComposerForbiddenSigner()
    114     let client = TeraRuntimeClient.production()
    115     let configuration = configuration(fixture, signer: signer)
    116     _ = try await client.start(configuration: configuration)
    117     let source = try await client.saveComposer(request: TeraComposerSaveRequest(
    118       scope: scope, id: client.reserveComposerID(), expectedRevision: nil, editSequence: 1,
    119       form: form(.createUpdate, media: fixture.media)
    120     ))
    121     let request = try await TeraSubmissionRequest(commandID: client.reserveSubmissionID(),
    122                                                   scope: scope, composerID: source.draft.id, expectedRevision: 1)
    123     let prepared = try await client.prepareSubmission(request: request, media: [])
    124     do {
    125       _ = try await client.advanceSubmission(request: request, expectedRevision: prepared.revision)
    126       XCTFail("The fixture refuses every signing request")
    127     } catch {}
    128     let queued = try await client.submissionStatus(request: request)
    129     XCTAssertEqual(queued.operationID, prepared.operationID)
    130     XCTAssertEqual(queued.captured, prepared.captured)
    131     XCTAssertEqual(queued.settlement.signed, 0)
    132     _ = try await client.stop()
    133     _ = try await client.start(configuration: configuration)
    134     let replay = try await client.recoverSubmission(request: request)
    135     XCTAssertEqual(replay, queued)
    136     _ = try await client.stop()
    137   }
    138 
    139   func testGeneratedOperationRejectsWrongCaptureRequestSchemaAndSettlementVersion() async throws {
    140     let fixture = try MediaOwnershipFixture()
    141     defer { fixture.remove() }
    142     let runtime = try await fixture.runtime()
    143     try await runtime.configureSimulatorRelays(loopbackRelays: ["ws://127.0.0.1:19999"])
    144     let source = try TeraComposerSaveRequest(scope: scope, id: composerReserveId().id,
    145                                              expectedRevision: nil, editSequence: 1, form: form(.createUpdate, media: fixture.media))
    146     _ = try await runtime.composerSave(request: source.generatedValue)
    147     let request = try TeraSubmissionRequest(commandID: submissionReserveId().id,
    148                                             scope: scope, composerID: source.id, expectedRevision: 1)
    149     let wire = try await runtime.submissionPrepare(request: request.generatedValue, media: [])
    150     _ = try TeraGeneratedSubmission.operation(wire, expected: request)
    151     var wrong = wire
    152     wrong.captured.revision = 2
    153     XCTAssertThrowsError(try TeraGeneratedSubmission.operation(wrong, expected: request))
    154     wrong = wire
    155     wrong.request.commandId = String(repeating: "b", count: 32)
    156     XCTAssertThrowsError(try TeraGeneratedSubmission.operation(wrong, expected: request))
    157     wrong = wire
    158     wrong.schemaVersion = 2
    159     XCTAssertThrowsError(try TeraGeneratedSubmission.operation(wrong, expected: request))
    160     wrong = wire
    161     wrong.settlement.schemaVersion = 2
    162     XCTAssertThrowsError(try TeraGeneratedSubmission.operation(wrong, expected: request))
    163     _ = try await runtime.shutdown()
    164   }
    165 
    166   private func assertReconstruction(_ fixture: MediaOwnershipFixture, committed: [TeraSubmissionStatus]) async throws {
    167     let reopened = try await fixture.runtime()
    168     try await reopened.configureSimulatorRelays(loopbackRelays: ["ws://127.0.0.1:19998"])
    169     let recovered = TeraGeneratedRuntimeBackend(runtime: reopened)
    170     for original in committed {
    171       let recoveredStatus = try await recovered.recoverSubmission(request: original.request)
    172       let status = try XCTUnwrap(recoveredStatus)
    173       XCTAssertEqual(status.request, original.request)
    174       XCTAssertEqual(status.operationID, original.operationID)
    175       XCTAssertEqual(status.intentID, original.intentID)
    176       XCTAssertEqual(status.captured, original.captured)
    177       XCTAssertEqual(status.revision, original.revision)
    178       XCTAssertEqual(status.media, original.media)
    179       XCTAssertEqual(status.committedAtUnixMilliseconds, original.committedAtUnixMilliseconds)
    180       XCTAssertTrue(status.delivery.isStopped)
    181       XCTAssertEqual(status.state, .cancelled)
    182       XCTAssertEqual(status.settlement.signed, 0)
    183       try await reopened.configureSimulatorRelays(loopbackRelays: ["ws://127.0.0.1:19999", "ws://127.0.0.1:19998"])
    184       let afterReaddition = try await recovered.recoverSubmission(request: original.request)
    185       XCTAssertEqual(afterReaddition, status)
    186       do {
    187         _ = try await recovered.advanceSubmission(request: original.request, expectedRevision: status.revision)
    188         XCTFail("Re-adding a removed relay must not revive stopped work")
    189       } catch {}
    190       let editing = try await recovered.loadComposer(scope: scope, id: original.captured.id)
    191       XCTAssertEqual(editing.form.content, "newer incomplete editing")
    192       XCTAssertEqual(editing.revision, 2)
    193     }
    194     _ = try await reopened.shutdown()
    195   }
    196 
    197   private func form(_ type: TeraAddCommandType, media: TeraPreparedMedia) -> TeraComposerForm {
    198     var value = TeraAddForm.empty(type)
    199     value.content = "PRIVATE captured publication"
    200     switch type {
    201     case .createUpdate, .createAsk: break
    202     case .createPhotoUpdate: value.media = [media]
    203     case .createEvent:
    204       value.identifier = "fixture-market"
    205       value.title = "Market"
    206       value.location = "Town square"
    207       value.eventTiming = .allDay
    208       value.eventStartDate = "2026-10-01"
    209       value.eventEndDate = "2026-10-02"
    210       value.eventStartUnixSeconds = nil
    211       value.eventEndUnixSeconds = nil
    212       value.eventTimezone = nil
    213     case .createFoodAvailability:
    214       value.identifier = "fixture-carrots"
    215       value.title = "Carrots"
    216       value.summary = "Fresh carrots"
    217       value.location = "Town square"
    218       value.priceAmount = "3.25"
    219       value.currency = "CAD"
    220       value.unit = "lb"
    221     }
    222     return TeraComposerForm(editing: value)
    223   }
    224 
    225   private func configuration(_ fixture: MediaOwnershipFixture, signer: ComposerForbiddenSigner) -> TeraRuntimeLaunchConfiguration {
    226     TeraRuntimeLaunchConfiguration(applicationSupportDirectory: fixture.root.path, publicKeyHex: scope.authorPublicKey,
    227                                    sourceGenerationHex: String(repeating: "04", count: 32), sourceGenerationCreatedAtUnixMilliseconds: 1_800_000_000_000,
    228                                    protectedData: .available, networkProfile: .publicNetwork, writableRelays: ["wss://relay.example"], blossom: nil,
    229                                    app: TeraRuntimeAppMetadata(bundleIdentifier: "test.submission", version: "1", buildNumber: "1", buildSHA: nil),
    230                                    signerGeneration: "submission-test", signer: signer, adoptBootstrapSettings: false)
    231   }
    232 }