TeraAddStoreTests.swift (43091B)
1 import CryptoKit 2 import RadrootsKit 3 @testable import TeraApp 4 import XCTest 5 6 final class TeraAddStoreTests: XCTestCase { 7 func testBackgroundUploadResumesReceiptRetriesAndCompletedStateWithoutDuplicateEnqueue() 8 async throws 9 { 10 let fixture = try BackgroundUploadFixture() 11 defer { fixture.remove() } 12 let transfer = BackgroundTransferHarness() 13 let coordinator = fixture.coordinator(transfer: transfer) 14 15 let first = try await coordinator.uploadInBackground( 16 job: fixture.job(revision: 2, operation: String(repeating: "a", count: 32)), 17 media: fixture.media 18 ) 19 XCTAssertEqual(first.expectedRevision, 2) 20 let snapshots = try await transfer.snapshots() 21 let snapshot = try XCTUnwrap(snapshots.first) 22 XCTAssertEqual(snapshot.request.remoteURL.absoluteString, "http://127.0.0.1:3000/upload") 23 var counts = await transfer.counts 24 XCTAssertEqual(counts.enqueue, 1) 25 26 let replayed = try await coordinator.uploadInBackground( 27 job: fixture.job(revision: 3, operation: String(repeating: "b", count: 32)), 28 media: fixture.media 29 ) 30 XCTAssertEqual(replayed.identifier, first.identifier) 31 XCTAssertEqual(replayed.expectedRevision, 2) 32 counts = await transfer.counts 33 XCTAssertEqual(counts.enqueue, 1) 34 35 try await coordinator.settleBackgroundUpload(identifier: replayed.identifier, accepted: true) 36 let completed = try await coordinator.uploadInBackground( 37 job: fixture.job(revision: 4, operation: String(repeating: "c", count: 32)), 38 media: fixture.media 39 ) 40 XCTAssertEqual(completed.identifier, first.identifier) 41 counts = await transfer.counts 42 XCTAssertEqual(counts.enqueue, 1) 43 try await coordinator.settleBackgroundUpload(identifier: completed.identifier, accepted: true) 44 counts = await transfer.counts 45 XCTAssertEqual(counts.acceptedSettlement, 1) 46 47 try await transfer.setState(.interrupted) 48 let retried = try await coordinator.uploadInBackground( 49 job: fixture.job(revision: 5, operation: String(repeating: "d", count: 32)), 50 media: fixture.media 51 ) 52 XCTAssertEqual(retried.identifier, first.identifier) 53 counts = await transfer.counts 54 XCTAssertEqual(counts.retry, 1) 55 XCTAssertEqual(counts.enqueue, 1) 56 } 57 58 func testBackgroundUploadRejectsMismatchedAndAmbiguousPersistedRequests() async throws { 59 let fixture = try BackgroundUploadFixture() 60 defer { fixture.remove() } 61 let transfer = BackgroundTransferHarness() 62 let coordinator = fixture.coordinator(transfer: transfer) 63 let job = fixture.job(revision: 2, operation: String(repeating: "a", count: 32)) 64 let request = try fixture.request(job: job) 65 let mismatched = try fixture.request( 66 job: job, 67 remoteURL: "http://127.0.0.1:3000/not-the-authorized-object.png" 68 ) 69 try await transfer.seed(request: mismatched, state: .running) 70 71 do { 72 _ = try await coordinator.uploadInBackground(job: job, media: fixture.media) 73 XCTFail("expected persisted-request mismatch") 74 } catch let failure as TeraRuntimeFailure { 75 XCTAssertEqual(failure.code, "ios.add.background_upload_mismatch") 76 } 77 var counts = await transfer.counts 78 XCTAssertEqual(counts.enqueue, 0) 79 80 await transfer.removeAll() 81 let future = try fixture.request( 82 job: fixture.job(revision: 3, operation: String(repeating: "f", count: 32)) 83 ) 84 try await transfer.seed(request: future, state: .running) 85 do { 86 _ = try await coordinator.uploadInBackground(job: job, media: fixture.media) 87 XCTFail("expected future transfer identity rejection") 88 } catch let failure as TeraRuntimeFailure { 89 XCTAssertEqual(failure.code, "ios.add.background_upload_mismatch") 90 } 91 92 await transfer.removeAll() 93 try await transfer.seed(request: request, state: .running) 94 let second = try fixture.request( 95 job: fixture.job(revision: 1, operation: String(repeating: "e", count: 32)) 96 ) 97 try await transfer.seed(request: second, state: .running) 98 do { 99 _ = try await coordinator.uploadInBackground(job: job, media: fixture.media) 100 XCTFail("expected ambiguous persisted state") 101 } catch let failure as TeraRuntimeFailure { 102 XCTAssertEqual(failure.code, "ios.add.background_upload_ambiguous") 103 } 104 counts = await transfer.counts 105 XCTAssertEqual(counts.enqueue, 0) 106 } 107 108 func testBackgroundUploadCancellationLeavesDurableWorkForRelaunch() async throws { 109 let fixture = try BackgroundUploadFixture() 110 defer { fixture.remove() } 111 let transfer = BackgroundTransferHarness(enqueueState: .running) 112 let coordinator = fixture.coordinator(transfer: transfer) 113 let firstJob = fixture.job(revision: 2, operation: String(repeating: "a", count: 32)) 114 115 let task = Task { 116 try await coordinator.uploadInBackground(job: firstJob, media: fixture.media) 117 } 118 let reachedSnapshot = await Self.waitUntil { await transfer.snapshotCount > 0 } 119 XCTAssertTrue(reachedSnapshot) 120 task.cancel() 121 do { 122 _ = try await task.value 123 XCTFail("expected cancellation") 124 } catch is CancellationError {} 125 126 var counts = await transfer.counts 127 XCTAssertEqual(counts.cancel, 0) 128 XCTAssertEqual(counts.enqueue, 1) 129 try await transfer.setState(.awaitingVerification) 130 let replayed = try await coordinator.uploadInBackground( 131 job: fixture.job(revision: 3, operation: String(repeating: "b", count: 32)), 132 media: fixture.media 133 ) 134 XCTAssertEqual(replayed.identifier, firstJob.transferIdentifier) 135 counts = await transfer.counts 136 XCTAssertEqual(counts.enqueue, 1) 137 } 138 139 func testBackgroundUploadCancellationBeforeEnqueueHasNoTransferSideEffect() async throws { 140 let fixture = try BackgroundUploadFixture() 141 defer { fixture.remove() } 142 let transfer = BackgroundTransferHarness(pause: .discovery) 143 let coordinator = fixture.coordinator(transfer: transfer) 144 let task = Task { 145 try await coordinator.uploadInBackground( 146 job: fixture.job(revision: 2, operation: String(repeating: "a", count: 32)), 147 media: fixture.media 148 ) 149 } 150 let reachedDiscovery = await Self.waitUntil { await transfer.isPaused } 151 XCTAssertTrue(reachedDiscovery) 152 task.cancel() 153 do { 154 _ = try await task.value 155 XCTFail("expected cancellation") 156 } catch is CancellationError {} 157 158 let counts = await transfer.counts 159 let state = await transfer.state 160 XCTAssertEqual(counts.enqueue, 0) 161 XCTAssertNil(state) 162 } 163 164 func testBackgroundUploadCancellationAtReceiptBoundaryReplaysOnRelaunch() async throws { 165 let fixture = try BackgroundUploadFixture() 166 defer { fixture.remove() } 167 let transfer = BackgroundTransferHarness(pause: .snapshot) 168 let coordinator = fixture.coordinator(transfer: transfer) 169 let firstJob = fixture.job(revision: 2, operation: String(repeating: "a", count: 32)) 170 let task = Task { 171 try await coordinator.uploadInBackground(job: firstJob, media: fixture.media) 172 } 173 let reachedReceipt = await Self.waitUntil { await transfer.isPaused } 174 XCTAssertTrue(reachedReceipt) 175 task.cancel() 176 do { 177 _ = try await task.value 178 XCTFail("expected cancellation") 179 } catch is CancellationError {} 180 181 var counts = await transfer.counts 182 XCTAssertEqual(counts.enqueue, 1) 183 XCTAssertEqual(counts.cancel, 0) 184 await transfer.releasePause() 185 let replayed = try await coordinator.uploadInBackground( 186 job: fixture.job(revision: 3, operation: String(repeating: "b", count: 32)), 187 media: fixture.media 188 ) 189 XCTAssertEqual(replayed.identifier, firstJob.transferIdentifier) 190 counts = await transfer.counts 191 XCTAssertEqual(counts.enqueue, 1) 192 } 193 194 @MainActor 195 func testProductSurfaceSnapshotAndSchemaInventoryAreExact() async throws { 196 XCTAssertEqual( 197 TeraProductSurfaceContract.snapshot, 198 "today=update,photoUpdate,ask,event,foodAvailability|add=createUpdate,createPhotoUpdate,createAsk,createEvent,createFoodAvailability|support=context_picker,search,me,settings" 199 ) 200 201 let validBackend = AddBackend() 202 let validClient = try await Self.startedClient(validBackend) 203 let validStore = TeraAddStore(runtimeClient: validClient) 204 await validStore.configure(snapshot: validBackend.snapshot()) 205 await validStore.start() 206 XCTAssertEqual(validStore.state, .ready) 207 XCTAssertEqual(validStore.schemas.map(\.commandType), TeraAddCommandType.allCases) 208 XCTAssertTrue(validStore.isProductReady) 209 210 let unicodeIdentifierStore = TeraAddStore( 211 runtimeClient: validClient, 212 initialType: .createFoodAvailability, 213 identifier: { String(repeating: "٠", count: 16) } 214 ) 215 XCTAssertNil(unicodeIdentifierStore.form.identifier) 216 _ = try await validClient.stop() 217 218 let exact = TeraAddSchemaFixtures.schemas() 219 var missingField = exact 220 let food = exact[4] 221 missingField[4] = TeraAddSchema( 222 schemaVersion: food.schemaVersion, 223 commandType: food.commandType, 224 label: food.label, 225 fields: Array(food.fields.dropLast()) 226 ) 227 let invalidInventories = [ 228 Array(exact.reversed()), 229 Array(exact.dropLast()), 230 exact + [exact[0]], 231 missingField, 232 ] 233 for inventory in invalidInventories { 234 let invalidBackend = AddBackend(schemas: inventory) 235 let invalidClient = try await Self.startedClient(invalidBackend) 236 let invalidStore = TeraAddStore(runtimeClient: invalidClient) 237 await invalidStore.configure(snapshot: invalidBackend.snapshot()) 238 await invalidStore.start() 239 XCTAssertEqual( 240 invalidStore.state, 241 .failed(TeraUserMessages.text(.addOperationFailed)) 242 ) 243 XCTAssertFalse(invalidStore.isProductReady) 244 XCTAssertFalse(invalidStore.canSave) 245 XCTAssertFalse(invalidStore.canSubmit) 246 _ = try await invalidClient.stop() 247 } 248 } 249 250 @MainActor 251 func testOfflineSubmitPersistsQueuedSnapshotForRetryAndReopen() async throws { 252 let backend = AddBackend(advanceOffline: true) 253 let client = try await Self.startedClient(backend) 254 let store = TeraAddStore(runtimeClient: client) 255 await store.configure(snapshot: backend.snapshot()) 256 await store.start() 257 store.updateForm(\.content, "Saved while the farm is offline") 258 await store.submit() 259 let queued = try XCTUnwrap(store.submissions.status) 260 XCTAssertEqual(queued.state, .queued) 261 XCTAssertEqual(queued.captured.form.content, "Saved while the farm is offline") 262 XCTAssertTrue(store.submissions.message?.contains("Original request retained") == true) 263 store.updateForm(\.content, "Newer editing") 264 let page = try await client.listSubmissions(scope: queued.request.scope) 265 guard case let .submission(summary) = page.entries.first else { return XCTFail("Missing submission") } 266 await store.submissions.select(summary) 267 XCTAssertEqual(store.form.content, "Newer editing") 268 XCTAssertEqual(store.submissions.status, queued) 269 XCTAssertTrue(store.isFormEditable) 270 _ = try await client.stop() 271 } 272 273 @MainActor 274 func testRevisionUsesLosslessRustOwnedReplacementPlan() async throws { 275 let backend = AddBackend(advanceOffline: true) 276 let client = try await Self.startedClient(backend) 277 let store = TeraAddStore(runtimeClient: client) 278 await store.configure(snapshot: backend.snapshot()) 279 await store.start() 280 store.selectType(.createFoodAvailability) 281 configure(store, type: .createFoodAvailability) 282 let legacy = try await backend.saveAddIntent(input: TeraAddRuntimeInput(form: store.form, media: []), 283 existingDraftID: nil, expectedRevision: nil) 284 store.reopen(legacy) 285 await TeraScopeFixtures.eventually { !store.protection.isWorking } 286 await store.submit() 287 let source = try XCTUnwrap(store.activeDraft) 288 289 await store.retractAndRevise(Self.card(localOperationID: source.id)) 290 291 XCTAssertNil(store.activeDraft) 292 XCTAssertEqual(store.form, source.form) 293 store.updateForm(\.content, "Corrected harvest details") 294 await store.submit() 295 XCTAssertTrue(store.activeDraft?.isRevision == true) 296 XCTAssertEqual(store.activeDraft?.state, .complete) 297 let revisionPlanCount = await backend.revisionPlanCount() 298 XCTAssertEqual(revisionPlanCount, 1) 299 XCTAssertNil(store.drafts.first(where: { $0.kind == .retraction })) 300 _ = try await client.stop() 301 } 302 303 @MainActor 304 func testRetractionUsesTypedTargetAndCompletesThroughTheDurableOutbox() async throws { 305 let backend = AddBackend() 306 let client = try await Self.startedClient(backend) 307 let store = TeraAddStore( 308 runtimeClient: client, 309 identifier: { String(repeating: "f", count: 32) }, 310 clock: .fixed(unixSeconds: 1_800_000_200) 311 ) 312 await store.configure(snapshot: backend.snapshot()) 313 await store.start() 314 315 await store.retract(Self.card()) 316 317 XCTAssertEqual(store.activeDraft?.id, String(repeating: "f", count: 32)) 318 XCTAssertEqual(store.activeDraft?.kind, .retraction) 319 XCTAssertEqual(store.activeDraft?.state, .complete) 320 XCTAssertFalse(store.isFormEditable) 321 XCTAssertFalse(store.canSave) 322 let recordedRetraction = await backend.lastRetraction() 323 let retraction = try XCTUnwrap(recordedRetraction) 324 XCTAssertEqual(retraction.commandType, .createFoodAvailability) 325 XCTAssertEqual(retraction.targetKind, 30402) 326 XCTAssertEqual(retraction.targetCardID, String(repeating: "c", count: 64)) 327 XCTAssertEqual(retraction.targetEventID, String(repeating: "e", count: 64)) 328 XCTAssertEqual(retraction.targetAddress, Self.card().sourceAddress) 329 XCTAssertEqual(retraction.reason, "Removed by author.") 330 _ = try await client.stop() 331 } 332 333 @MainActor 334 func testStopFencesSaveQueueAndAdvanceAndPermitsCleanRestart() async throws { 335 for phase in AddDelayPhase.allCases { 336 let backend = AddBackend(delayedPhase: phase) 337 let client = try await Self.startedClient(backend) 338 let store = TeraAddStore(runtimeClient: client) 339 await store.configure(snapshot: backend.snapshot()) 340 await store.start() 341 store.updateForm(\.content, "Restart after \(phase)") 342 343 let submit = Task { await store.submit() } 344 try await Task.sleep(nanoseconds: 2_000_000) 345 let visibleAtStop = store.submissions.status 346 store.stop() 347 await submit.value 348 349 XCTAssertEqual(store.submissions.status, visibleAtStop) 350 XCTAssertFalse(store.isWorking) 351 352 await store.start() 353 XCTAssertEqual(store.state, .ready) 354 await store.submit() 355 XCTAssertEqual(store.submissions.status?.state, .complete) 356 XCTAssertFalse(store.submissions.isWorking) 357 let count = await backend.submissionBackend.operationCount 358 XCTAssertEqual(count, 1) 359 _ = try await client.stop() 360 } 361 } 362 363 @MainActor 364 func testStopDuringBackgroundUploadPermitsDurableRestart() async throws { 365 let backend = AddBackend() 366 let client = try await Self.startedClient(backend) 367 let store = TeraAddStore( 368 runtimeClient: client, 369 media: AddMediaHarness(delayFirstUpload: true) 370 ) 371 await store.configure(snapshot: backend.snapshot()) 372 await store.start() 373 store.selectType(.createPhotoUpdate) 374 store.updateForm(\.content, "Restart the background upload") 375 await store.importPhotos() 376 377 let submit = Task { await store.submit() } 378 try await Task.sleep(nanoseconds: 2_000_000) 379 let visibleAtStop = store.submissions.status 380 store.stop() 381 await submit.value 382 383 XCTAssertEqual(store.submissions.status, visibleAtStop) 384 XCTAssertFalse(store.isWorking) 385 386 await store.start() 387 await store.submit() 388 XCTAssertEqual(store.submissions.status?.state, .complete) 389 XCTAssertEqual(store.submissions.status?.media.first?.progress.stage, .verified) 390 _ = try await client.stop() 391 } 392 393 @MainActor 394 func testCancellationAfterDurableRustVerificationDoesNotRejectReceipt() async throws { 395 let backend = AddBackend(delayAfterBackgroundCompletion: true) 396 let client = try await Self.startedClient(backend) 397 let media = AddMediaHarness() 398 let store = TeraAddStore(runtimeClient: client, media: media) 399 await store.configure(snapshot: backend.snapshot()) 400 await store.start() 401 store.selectType(.createPhotoUpdate) 402 store.updateForm(\.content, "Unknown Rust completion outcome") 403 await store.importPhotos() 404 405 let submit = Task { await store.submit() } 406 let persistedCompletion = await Self.waitUntil { 407 await backend.submissionBackend.completionPersisted 408 } 409 XCTAssertTrue(persistedCompletion) 410 store.stop() 411 await submit.value 412 413 var settlements = await media.settlementValues() 414 XCTAssertEqual(settlements, []) 415 await store.start() 416 XCTAssertEqual(store.submissions.status?.state, .readyToSign) 417 XCTAssertEqual(store.submissions.status?.media.first?.progress.stage, .verified) 418 settlements = await media.settlementValues() 419 let reconciliations = await media.reconciliationCount() 420 XCTAssertEqual(settlements, []) 421 XCTAssertEqual(reconciliations, 2) 422 _ = try await client.stop() 423 } 424 425 @MainActor 426 func testCancellationDuringSettlementReconcilesVerifiedDraftOnRelaunch() async throws { 427 let backend = AddBackend() 428 let client = try await Self.startedClient(backend) 429 let media = AddMediaHarness(delaySettlement: true) 430 let store = TeraAddStore(runtimeClient: client, media: media) 431 await store.configure(snapshot: backend.snapshot()) 432 await store.start() 433 store.selectType(.createPhotoUpdate) 434 store.updateForm(\.content, "Unknown settlement outcome") 435 await store.importPhotos() 436 437 let submit = Task { await store.submit() } 438 let reachedSettlement = await Self.waitUntil { await media.didBeginSettlement() } 439 XCTAssertTrue(reachedSettlement) 440 store.stop() 441 await submit.value 442 443 let settlements = await media.settlementValues() 444 XCTAssertEqual(settlements, []) 445 await store.start() 446 XCTAssertEqual(store.submissions.status?.media.first?.progress.stage, .verified) 447 let reconciliations = await media.reconciliationCount() 448 XCTAssertEqual(reconciliations, 2) 449 _ = try await client.stop() 450 } 451 452 @MainActor 453 func testPhotoServiceProbeSurfacesCanonicalEvidence() async throws { 454 let backend = AddBackend() 455 let client = try await Self.startedClient(backend) 456 let store = TeraAddStore(runtimeClient: client, media: AddMediaHarness()) 457 await store.configure(snapshot: backend.snapshot()) 458 459 await store.checkPhotoService() 460 461 XCTAssertEqual(store.blossomEvidence?.state, "reachable") 462 XCTAssertEqual(store.blossomEvidence?.configFingerprint, String(repeating: "f", count: 64)) 463 XCTAssertEqual(store.mediaSupport, .init(library: true, camera: true)) 464 XCTAssertEqual(store.message, "Photo service is reachable.") 465 _ = try await client.stop() 466 } 467 468 @MainActor 469 func testPhotoLimitRejectsTwentyFirstImportAndCapture() async throws { 470 let backend = AddBackend() 471 let client = try await Self.startedClient(backend) 472 let store = TeraAddStore(runtimeClient: client, media: AddMediaHarness()) 473 await store.configure(snapshot: backend.snapshot()) 474 await store.start() 475 store.selectType(.createPhotoUpdate) 476 477 for _ in 0 ..< 20 { 478 await store.importPhotos() 479 } 480 XCTAssertEqual(store.form.media.count, 20) 481 XCTAssertFalse(store.canAddMedia) 482 483 await store.importPhotos() 484 await store.capturePhoto() 485 XCTAssertEqual(store.form.media.count, 20) 486 _ = try await client.stop() 487 } 488 489 @MainActor 490 func testSuspendAndResumePreserveLateDurableDraftCompletion() async throws { 491 let backend = AddBackend(delayedPhase: .save) 492 let client = try await Self.startedClient(backend) 493 let store = TeraAddStore(runtimeClient: client) 494 await store.configure(snapshot: backend.snapshot()) 495 await store.start() 496 store.updateForm(\.content, "Background draft") 497 498 let save = Task { await store.save() } 499 try await Task.sleep(nanoseconds: 2_000_000) 500 store.suspend() 501 await store.start() 502 await save.value 503 504 XCTAssertEqual(store.savedComposer?.form.content, "Background draft") 505 XCTAssertEqual(store.message, "Draft saved on this device.") 506 XCTAssertFalse(store.isWorking) 507 _ = try await client.stop() 508 } 509 510 @MainActor 511 func testSubmitWithoutWritableRelayPreservesSourceBeforeAnyUpload() async throws { 512 let backend = AddBackend(includeWritableRelay: false) 513 let client = try await Self.startedClient(backend) 514 let store = TeraAddStore( 515 runtimeClient: client, 516 media: AddMediaHarness() 517 ) 518 await store.configure(snapshot: backend.snapshot()) 519 await store.start() 520 store.selectType(.createPhotoUpdate) 521 store.updateForm(\.content, "Carrots from today") 522 await store.importPhotos() 523 524 await store.submit() 525 526 XCTAssertNil(store.submissions.status) 527 let request = try XCTUnwrap(store.submissions.request) 528 let captured = try await client.loadComposer(scope: request.scope, id: request.composerID) 529 XCTAssertEqual(captured.form.content, "Carrots from today") 530 XCTAssertEqual(captured.revision, request.expectedRevision) 531 XCTAssertEqual(store.submissions.failureCode, "submission_policy_unavailable") 532 let uploads = await backend.submissionBackend.uploadCount 533 XCTAssertEqual(uploads, 0) 534 XCTAssertTrue(store.isFormEditable) 535 _ = try await client.stop() 536 } 537 538 @MainActor 539 func testSubmitRetainsRedactedFailureCodeForSupportAndAccessibility() async throws { 540 let failure = TeraRuntimeFailure( 541 schemaVersion: 1, 542 code: "media_handle_unavailable", 543 category: "validation", 544 retryable: false, 545 recoveryActions: [], 546 operationID: "add.save", 547 capabilityID: nil, 548 safeMessage: "The request is invalid." 549 ) 550 let backend = AddBackend(saveFailure: failure) 551 let client = try await Self.startedClient(backend) 552 let store = TeraAddStore(runtimeClient: client) 553 await store.configure(snapshot: backend.snapshot()) 554 await store.start() 555 store.updateForm(\.content, "Prepared media failure") 556 557 await store.submit() 558 559 XCTAssertTrue(store.submissions.message?.contains(TeraUserMessages.text(.addOperationFailed)) == true) 560 XCTAssertEqual(store.submissions.failureCode, failure.code) 561 XCTAssertNil(store.activeDraft) 562 _ = try await client.stop() 563 } 564 565 @MainActor 566 func configure(_ store: TeraAddStore, type: TeraAddCommandType) { 567 switch type { 568 case .createUpdate: 569 store.updateForm(\.content, "Harvest update") 570 case .createPhotoUpdate: 571 store.updateForm(\.content, "Carrots from today") 572 case .createAsk: 573 store.updateForm(\.content, "Who has seed potatoes?") 574 case .createEvent: 575 XCTAssertEqual(store.form.identifier?.count, 32) 576 XCTAssertNotNil(store.form.eventStartUnixSeconds) 577 XCTAssertNotNil(store.form.eventEndUnixSeconds) 578 XCTAssertNotNil(store.form.eventStartDate) 579 XCTAssertNotNil(store.form.eventEndDate) 580 store.updateForm(\.title, Optional("Market day")) 581 store.updateForm(\.eventTiming, Optional(TeraEventTiming.timed)) 582 store.updateForm(\.location, Optional("Town square")) 583 case .createFoodAvailability: 584 XCTAssertEqual(store.form.identifier?.count, 32) 585 store.updateForm(\.title, Optional("Carrots")) 586 store.updateForm(\.summary, Optional("Fresh carrots")) 587 store.updateForm(\.content, "Freshly picked") 588 store.updateForm(\.location, Optional("Town square")) 589 store.updateForm(\.priceAmount, Optional("3")) 590 store.updateForm(\.currency, Optional("CAD")) 591 store.updateForm(\.unit, Optional("lb")) 592 } 593 } 594 595 private static func waitUntil( 596 _ predicate: @escaping @Sendable () async -> Bool 597 ) async -> Bool { 598 for _ in 0 ..< 1000 { 599 if await predicate() { 600 return true 601 } 602 try? await Task.sleep(nanoseconds: 1_000_000) 603 } 604 return false 605 } 606 607 static func startedClient(_ backend: AddBackend) async throws -> TeraRuntimeClient { 608 let client = TeraRuntimeClient { _ in 609 await TeraRuntimeBackendStart(backend: backend, snapshot: backend.snapshot()) 610 } 611 _ = try await client.start(configuration: configuration()) 612 return client 613 } 614 615 static func configuration() -> TeraRuntimeLaunchConfiguration { 616 TeraRuntimeLaunchConfiguration( 617 applicationSupportDirectory: "/tmp/radroots-add-tests", 618 publicKeyHex: String(repeating: "ab", count: 32), 619 sourceGenerationHex: String(repeating: "cd", count: 32), 620 sourceGenerationCreatedAtUnixMilliseconds: 1, 621 protectedData: .available, 622 networkProfile: .simulator, 623 writableRelays: ["ws://127.0.0.1:7447"], 624 blossom: TeraBlossomEndpointConfiguration( 625 hostKind: .simulator, 626 endpointAuthority: .loopbackDevelopment, 627 primaryOrigin: "http://127.0.0.1:3000", 628 fallbackOrigins: [] 629 ), 630 app: TeraRuntimeAppMetadata( 631 bundleIdentifier: "org.radroots.add-tests", 632 version: "0.1.0-alpha", 633 buildNumber: "1", 634 buildSHA: nil 635 ), 636 signerGeneration: "add-tests", 637 signer: AddSigner(), 638 adoptBootstrapSettings: false 639 ) 640 } 641 642 static func card(localOperationID: String? = nil) -> TeraTodayCard { 643 TeraTodayCard( 644 id: String(repeating: "c", count: 64), 645 type: .foodAvailability, 646 sourceEventID: String(repeating: "e", count: 64), 647 sourceAddress: "30402:\(String(repeating: "ab", count: 32)):carrots", 648 authorPublicKey: String(repeating: "ab", count: 32), 649 contractID: "radroots.food_availability.v1", 650 title: "Carrots", 651 content: "Freshly picked", 652 authoredAtUnixSeconds: 1_800_000_000, 653 effectiveAtUnixSeconds: 1_800_000_000, 654 calendarTiming: nil, 655 location: "Town square", 656 priceAmount: "3", 657 priceCurrency: "CAD", 658 priceUnit: "lb", 659 quantity: "12", 660 foodSummary: "Fresh carrots", 661 foodPublishedAtUnixSeconds: 1_799_999_900, 662 foodStatus: "active", 663 contextRank: 1, 664 inclusionReason: "local", 665 media: [], 666 lifecycle: .active, 667 rankDigest: nil, 668 authorProfile: nil, 669 thread: [], 670 localOperationID: localOperationID, 671 localOperationState: nil 672 ) 673 } 674 } 675 676 enum AddDelayPhase: String, CaseIterable, Sendable { 677 case save 678 case queue 679 case advance 680 } 681 682 actor AddBackend: TeraRuntimeBackend { 683 private let composerStorage = ComposerTestStorage() 684 let submissionBackend: SubmissionTestBackend 685 private let savePause: ResourceTestPause? 686 private let advanceOffline: Bool 687 private let saveFailure: TeraRuntimeFailure? 688 private let includeWritableRelay: Bool 689 private let delayedPhase: AddDelayPhase? 690 private let delayAfterBackgroundCompletion: Bool 691 private let schemaInventory: [TeraAddSchema] 692 var values: [String: TeraDraftStatus] = [:] 693 private var uploadedMedia = false 694 var revisionPlans = 0 695 var failRevisionReceipt = false 696 func failNextRevisionReceipt() { 697 failRevisionReceipt = true 698 } 699 700 private var delayConsumed = false 701 private var backgroundCompletionPersisted = false 702 private var recordedRetraction: TeraRetractionDraftInput? 703 private var closed = false 704 705 init( 706 savePause: ResourceTestPause? = nil, 707 advanceOffline: Bool = false, 708 saveFailure: TeraRuntimeFailure? = nil, 709 includeWritableRelay: Bool = true, 710 delayedPhase: AddDelayPhase? = nil, 711 delayAfterBackgroundCompletion: Bool = false, 712 schemas: [TeraAddSchema] = TeraAddSchemaFixtures.schemas() 713 ) { 714 submissionBackend = SubmissionTestBackend(composer: composerStorage, writable: includeWritableRelay, 715 offline: advanceOffline, delayedPhase: delayedPhase, delayAfterCompletion: delayAfterBackgroundCompletion) 716 self.advanceOffline = advanceOffline 717 self.savePause = savePause 718 self.saveFailure = saveFailure 719 self.includeWritableRelay = includeWritableRelay 720 self.delayedPhase = delayedPhase 721 self.delayAfterBackgroundCompletion = delayAfterBackgroundCompletion 722 schemaInventory = schemas 723 } 724 725 func todayPage(request _: TeraTodayPageRequest) throws -> TeraTodayPage { 726 throw unsupported() 727 } 728 729 func refreshToday( 730 context _: TeraLocalNetwork, 731 nowUnixSeconds _: UInt64, 732 update _: TeraTodayProjectionUpdate, backfillCursor _: String? 733 ) throws -> TeraTodaySyncReceipt { 734 throw unsupported() 735 } 736 737 func addSchemas() -> [TeraAddSchema] { 738 schemaInventory 739 } 740 741 func saveAddIntent( 742 input: TeraAddRuntimeInput, 743 existingDraftID: String?, 744 expectedRevision: UInt64? 745 ) async throws -> TeraDraftStatus { 746 await savePause?.wait() 747 if let saveFailure { 748 throw saveFailure 749 } 750 try await delayOnce(at: .save) 751 let id = existingDraftID ?? String(format: "%032x", values.count + 1) 752 let savedMedia = input.form.media.map { 753 TeraPreparedMedia( 754 opaqueReference: $0.opaqueReference, 755 remoteURL: "http://127.0.0.1:3000/\($0.sha256).png", 756 sha256: $0.sha256, 757 mediaType: $0.mediaType, 758 byteSize: $0.byteSize, 759 width: $0.width, 760 height: $0.height, 761 alt: $0.alt, 762 preparedAtUnixSeconds: $0.preparedAtUnixSeconds 763 ) 764 } 765 var storedForm = input.form 766 storedForm.media = savedMedia 767 let status = makeStatus( 768 id: id, 769 revision: (expectedRevision ?? 0) + 1, 770 kind: .add, 771 commandType: input.form.commandType, 772 form: storedForm, 773 state: savedMedia.isEmpty ? .draft : .mediaPreparing, 774 updatedAt: 1_800_000_000_000 + UInt64(values.count), 775 media: savedMedia.map { 776 TeraDraftMediaStatus( 777 url: $0.remoteURL!, 778 stage: .pending, 779 uploadAttempts: 0, 780 verifiedAtUnixMilliseconds: nil, 781 possibleOrphan: false, 782 orphanReasonCode: nil, 783 orphanRecordedAtUnixMilliseconds: nil 784 ) 785 } 786 ) 787 values[id] = status 788 return status 789 } 790 791 func probeBlossom() -> TeraBlossomEvidence { 792 TeraBlossomEvidence( 793 schemaVersion: 2, 794 origin: "http://127.0.0.1:3000", 795 configFingerprint: String(repeating: "f", count: 64), 796 state: "reachable", 797 lastSuccessfulState: "probe", 798 transportSecurity: "loopback_plaintext", 799 observedAtUnixMilliseconds: 1_800_000_000_000, 800 httpStatus: 404, 801 errorCode: nil, 802 serverErrorCode: nil, 803 errorPhase: nil, 804 retryable: false, 805 possibleOrphan: false, 806 attempts: 1 807 ) 808 } 809 810 func saveRetractionDraft( 811 id: String, 812 input: TeraRetractionDraftInput, 813 authoredAtUnixSeconds _: UInt64, 814 persistedAtUnixMilliseconds: UInt64 815 ) -> TeraDraftStatus { 816 recordedRetraction = input 817 let status = makeStatus( 818 id: id, 819 revision: 1, 820 kind: .retraction, 821 commandType: input.commandType, 822 form: nil, 823 state: .draft, 824 updatedAt: persistedAtUnixMilliseconds, 825 media: [] 826 ) 827 values[id] = status 828 return status 829 } 830 831 func lastRetraction() -> TeraRetractionDraftInput? { 832 recordedRetraction 833 } 834 835 func draftStatus(id: String) throws -> TeraDraftStatus { 836 try storedDraft(id: id) 837 } 838 839 func draftHeads(limit: UInt16) -> [TeraDraftStatus] { 840 Array(values.values.prefix(Int(limit))) 841 } 842 843 func queueAddIntent( 844 id: String, 845 expectedRevision: UInt64 846 ) async throws -> TeraDraftStatus { 847 try await delayOnce(at: .queue) 848 guard includeWritableRelay else { 849 throw TeraRuntimeFailure( 850 schemaVersion: 1, 851 code: "writable_relay_unavailable", 852 category: "relay", 853 retryable: true, 854 recoveryActions: ["configure_relay", "retry"], 855 operationID: id, 856 capabilityID: "nostr_sink", 857 safeMessage: "No writable relay is configured." 858 ) 859 } 860 let current = try storedDraft(id: id) 861 guard current.revision == expectedRevision else { throw unsupported() } 862 let value = replacing( 863 current, revision: current.revision + 1, state: .queued, 864 updatedAt: current.updatedAtUnixMilliseconds + 1 865 ) 866 values[id] = value 867 return value 868 } 869 870 func recoverAddIntent(id: String) throws -> TeraDraftStatus { 871 let current = try draftStatus(id: id) 872 let value = replacing( 873 current, revision: current.revision + 1, state: .queued, 874 updatedAt: current.updatedAtUnixMilliseconds + 1 875 ) 876 values[id] = value 877 return value 878 } 879 880 func uploadAddMediaIntent(input: TeraBlossomUploadIntent) throws -> TeraDraftStatus { 881 uploadedMedia = true 882 let current = try storedDraft(id: input.draftID) 883 let verified = current.media.map { 884 TeraDraftMediaStatus( 885 url: $0.url, 886 stage: .verified, 887 uploadAttempts: $0.uploadAttempts + 1, 888 verifiedAtUnixMilliseconds: current.updatedAtUnixMilliseconds + 1, 889 possibleOrphan: false, 890 orphanReasonCode: nil, 891 orphanRecordedAtUnixMilliseconds: nil 892 ) 893 } 894 let value = replacing( 895 current, 896 revision: current.revision + 1, 897 state: .readyToSign, 898 updatedAt: current.updatedAtUnixMilliseconds + 1, 899 media: verified 900 ) 901 values[current.id] = value 902 return value 903 } 904 905 func prepareAddMediaBackground( 906 input: TeraBlossomUploadIntent 907 ) throws -> TeraNativeUploadJob { 908 let current = try draftStatus(id: input.draftID) 909 let uploading = replacing( 910 current, 911 revision: current.revision + 1, 912 state: .mediaUploading, 913 updatedAt: current.updatedAtUnixMilliseconds + 1 914 ) 915 values[current.id] = uploading 916 return TeraNativeUploadJob( 917 operationID: String(repeating: "a", count: 32), 918 draft: uploading, 919 remoteURL: "http://127.0.0.1:3000/\(input.media.media.sha256).png", 920 uploadURL: "http://127.0.0.1:3000/upload", 921 authorizationHeader: "Nostr test", 922 expectedSHA256: input.media.media.sha256, 923 mediaType: input.media.media.mediaType, 924 byteSize: input.media.media.byteSize 925 ) 926 } 927 928 func completeAddMediaBackground( 929 input: TeraNativeUploadCompletion 930 ) async throws -> TeraDraftStatus { 931 uploadedMedia = true 932 let current = try storedDraft(id: input.draftID) 933 let verified = current.media.map { 934 TeraDraftMediaStatus( 935 url: $0.url, 936 stage: .verified, 937 uploadAttempts: $0.uploadAttempts + 1, 938 verifiedAtUnixMilliseconds: current.updatedAtUnixMilliseconds + 1, 939 possibleOrphan: false, 940 orphanReasonCode: nil, 941 orphanRecordedAtUnixMilliseconds: nil 942 ) 943 } 944 let value = replacing( 945 current, 946 revision: current.revision + 1, 947 state: .readyToSign, 948 updatedAt: current.updatedAtUnixMilliseconds + 1, 949 media: verified 950 ) 951 values[current.id] = value 952 backgroundCompletionPersisted = true 953 if delayAfterBackgroundCompletion { 954 try await Task.sleep(nanoseconds: 50_000_000) 955 } 956 return value 957 } 958 959 func didPersistBackgroundCompletion() -> Bool { 960 backgroundCompletionPersisted 961 } 962 963 func didUploadMedia() -> Bool { 964 uploadedMedia 965 } 966 967 func revisionPlanCount() -> Int { 968 revisionPlans 969 } 970 971 func advanceDraft(id: String, expectedRevision: UInt64) async throws -> TeraDraftStatus { 972 try await delayOnce(at: .advance) 973 if advanceOffline { 974 throw TeraRuntimeFailure( 975 schemaVersion: 1, 976 code: "test.offline", 977 category: "relay", 978 retryable: true, 979 recoveryActions: ["retry"], 980 operationID: id, 981 capabilityID: "nostr_sink", 982 safeMessage: "The relay is offline." 983 ) 984 } 985 let current = try storedDraft(id: id) 986 let value = replacing( 987 current, revision: expectedRevision, state: .complete, 988 updatedAt: current.updatedAtUnixMilliseconds 989 ) 990 values[id] = value 991 return value 992 } 993 994 func cancelAddIntent( 995 id: String, 996 expectedRevision _: UInt64 997 ) throws -> TeraDraftStatus { 998 let current = try draftStatus(id: id) 999 let value = replacing( 1000 current, revision: current.revision + 1, state: .cancelled, 1001 updatedAt: current.updatedAtUnixMilliseconds + 1 1002 ) 1003 values[id] = value 1004 return value 1005 } 1006 1007 func subscribe( 1008 bufferCapacity _: Int, 1009 receive _: @escaping @Sendable (TeraRuntimeChange) async -> Void 1010 ) -> any TeraRuntimeSubscriptionToken { 1011 AddSubscriptionToken() 1012 } 1013 1014 func shutdown() -> TeraRuntimeShutdownReceipt { 1015 let wasClosed = closed 1016 closed = true 1017 return TeraRuntimeShutdownReceipt(state: "closed", alreadyClosed: wasClosed) 1018 } 1019 1020 func makeStatus( 1021 id: String, 1022 revision: UInt64, 1023 kind: TeraDraftKind, 1024 commandType: TeraAddCommandType, 1025 form: TeraAddForm?, 1026 state: TeraOutboxState, 1027 updatedAt: UInt64, 1028 media: [TeraDraftMediaStatus], 1029 isRevision: Bool = false 1030 ) -> TeraDraftStatus { 1031 TeraDraftStatus( 1032 id: id, 1033 revision: revision, 1034 authorPublicKey: String(repeating: "ab", count: 32), 1035 kind: kind, 1036 commandType: commandType, 1037 form: form, 1038 state: state, 1039 cardID: String(repeating: "c", count: 64), 1040 operationID: state == .draft ? nil : String(repeating: "d", count: 32), 1041 createdAtUnixMilliseconds: updatedAt, 1042 updatedAtUnixMilliseconds: updatedAt, 1043 media: media, 1044 settlement: state == .complete ? settlement() : nil, 1045 isRevision: isRevision 1046 ) 1047 } 1048 1049 func replacing( 1050 _ value: TeraDraftStatus, 1051 revision: UInt64, 1052 state: TeraOutboxState, 1053 updatedAt: UInt64, 1054 media: [TeraDraftMediaStatus]? = nil 1055 ) -> TeraDraftStatus { 1056 TeraDraftStatus( 1057 id: value.id, 1058 revision: revision, 1059 authorPublicKey: value.authorPublicKey, 1060 kind: value.kind, 1061 commandType: value.commandType, 1062 form: value.form, 1063 state: state, 1064 cardID: value.cardID, 1065 operationID: state == .draft ? nil : String(repeating: "d", count: 32), 1066 createdAtUnixMilliseconds: value.createdAtUnixMilliseconds, 1067 updatedAtUnixMilliseconds: updatedAt, 1068 media: media ?? value.media, 1069 settlement: state == .complete ? settlement() : nil, 1070 isRevision: value.isRevision 1071 ) 1072 } 1073 1074 private func settlement() -> TeraOperationSettlement { 1075 TeraOperationSettlement( 1076 artifacts: 1, 1077 signed: 1, 1078 admitted: 1, 1079 pending: 0, 1080 retryable: 0, 1081 indeterminate: 0, 1082 failedTerminal: 0, 1083 cancelled: 0, 1084 deliveryPlans: 1, 1085 deliverySatisfied: 1, 1086 deliveryPending: 0, 1087 deliveryRetryable: 0, 1088 deliveryExhausted: 0, 1089 deliveryFailedTerminal: 0, 1090 deliveryCancelled: 0 1091 ) 1092 } 1093 1094 private func delayOnce(at phase: AddDelayPhase) async throws { 1095 guard delayedPhase == phase, !delayConsumed else { return } 1096 delayConsumed = true 1097 try await Task.sleep(nanoseconds: 50_000_000) 1098 } 1099 1100 func unsupported() -> TeraRuntimeFailure { 1101 .local( 1102 operation: "test.add", code: "test.unsupported", safeMessage: "Unsupported test operation." 1103 ) 1104 } 1105 } 1106 1107 private actor AddSubscriptionToken: TeraRuntimeSubscriptionToken { 1108 func cancel() {} 1109 } 1110 1111 extension TeraAddStoreTests { 1112 @MainActor 1113 func testMutationAdmissionKeepsOneAddOperationBeforeItsBackendWait() async throws { 1114 let pause = ResourceTestPause() 1115 let backend = AddBackend(savePause: pause) 1116 let client = try await Self.startedClient(backend) 1117 let store = TeraAddStore(runtimeClient: client) 1118 await store.configure(snapshot: backend.snapshot()) 1119 await store.start() 1120 store.updateForm(\.content, "One admitted draft") 1121 let owner = Task { await store.save() } 1122 await admissionPauseEntered(pause) 1123 XCTAssertTrue(store.isWorking) 1124 await store.save() 1125 await store.submit() 1126 XCTAssertTrue(store.isWorking) 1127 XCTAssertNil(store.activeDraft) 1128 await pause.resume.open() 1129 await owner.value 1130 XCTAssertTrue(store.drafts.isEmpty) 1131 XCTAssertEqual(store.savedComposer?.revision, 1) 1132 XCTAssertEqual(store.savedComposer?.form.content, "One admitted draft") 1133 XCTAssertFalse(store.isWorking) 1134 _ = try await client.stop() 1135 } 1136 1137 @MainActor 1138 func testMutationAdmissionIgnoresAnAddCallerCancelledWhileQueued() async throws { 1139 let backend = AddBackend() 1140 let client = try await Self.startedClient(backend) 1141 let store = TeraAddStore(runtimeClient: client) 1142 await store.configure(snapshot: backend.snapshot()) 1143 await store.start() 1144 store.updateForm(\.content, "Only save after explicit admission") 1145 let pause = ResourceTestPause() 1146 let queued = Task { await pause.wait(); await store.save() } 1147 await admissionPauseEntered(pause) 1148 queued.cancel() 1149 await pause.resume.open() 1150 await queued.value 1151 XCTAssertTrue(store.drafts.isEmpty) 1152 XCTAssertFalse(store.isWorking) 1153 await store.save() 1154 XCTAssertEqual(store.savedComposer?.form.content, "Only save after explicit admission") 1155 _ = try await client.stop() 1156 } 1157 1158 @MainActor 1159 private func admissionPauseEntered(_ pause: ResourceTestPause) async { 1160 let entered = expectation(description: "Entered the explicit Add pause") 1161 let observer = Task { await pause.entered.wait(); entered.fulfill() } 1162 await fulfillment(of: [entered], timeout: 2) 1163 await pause.entered.open() 1164 await observer.value 1165 } 1166 } 1167 1168 extension AddBackend { 1169 func reserveComposerID() async -> String { 1170 await composerStorage.reserve() 1171 } 1172 1173 func saveComposer(request: TeraComposerSaveRequest) async throws -> TeraComposerSaveReceipt { 1174 await savePause?.wait() 1175 if let saveFailure { 1176 throw saveFailure 1177 } 1178 try await delayOnce(at: .save) 1179 return try await composerStorage.save(request) 1180 } 1181 1182 func loadComposer(scope: TeraComposerScope, id: String) async throws -> TeraComposerDraft { 1183 try await composerStorage.load(scope, id: id) 1184 } 1185 1186 private func storedDraft(id: String) throws -> TeraDraftStatus { 1187 guard let value = values[id] else { throw unsupported() } 1188 return value 1189 } 1190 } 1191 1192 extension AddBackend { 1193 func snapshot() -> TeraRuntimeSnapshot { 1194 TeraRuntimeSnapshot( 1195 identity: TeraRuntimeIdentity( 1196 publicKeyHex: String(repeating: "ab", count: 32), 1197 hostSignerConfigured: true 1198 ), 1199 relay: TeraRelayStatus( 1200 profile: "simulator", 1201 state: "configured", 1202 readAvailability: "unobserved", 1203 writeAvailability: "unobserved", 1204 relays: includeWritableRelay 1205 ? [ 1206 TeraRelayEndpointStatus( 1207 url: "ws://127.0.0.1:7447", 1208 access: .readWrite, 1209 readState: "unobserved", 1210 writeState: "unobserved", 1211 readLastAttemptUnixMilliseconds: nil, 1212 writeLastAttemptUnixMilliseconds: nil, 1213 readNextAttemptUnixMilliseconds: nil, 1214 writeNextAttemptUnixMilliseconds: nil 1215 ), 1216 ] : [] 1217 ), 1218 blossomConfiguration: TeraBlossomConfigurationStatus( 1219 schemaVersion: 1, 1220 hostKind: "simulator", 1221 endpointAuthority: "loopback_development", 1222 primaryOrigin: "http://127.0.0.1:3000", 1223 fallbackOrigins: [], 1224 configFingerprint: String(repeating: "f", count: 64) 1225 ), 1226 blossomEvidence: nil, 1227 crateName: "tera_ffi", 1228 crateVersion: "0.1.0-alpha", 1229 isClosed: closed 1230 ) 1231 } 1232 }