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 }