TeraRecoverySettlementTests.swift (7059B)
1 import Foundation 2 import RadrootsKit 3 @testable import TeraApp 4 import XCTest 5 6 /// Fixture evidence only. Production obtains this receipt from Rust. 7 enum RecoverySettlementFixture { 8 static func receipt(_ input: TeraRecoveryUploadReceipt) -> TeraRecoveryCompletionReceipt { 9 .init(parent: input.parent, attempt: input.attempt, canonicalURL: input.media.remoteURL ?? "", 10 sha256: input.media.sha256, mediaType: input.media.mediaType, byteSize: input.media.byteSize, verifiedAtUnixMS: 1_800_000_000_000) 11 } 12 13 static func run(transfer: BackgroundTransferHarness, cursor: String?, 14 lookup: @Sendable (String) async throws -> TeraNativeUploadRecoveryOwner?) async throws 15 -> (progress: TeraNativeRecoveryProgress, cursor: String?) 16 { 17 try await TeraNativeRecoveryInventory.run(transfer: transfer, cursor: cursor, complete: { snapshot, owner in 18 let input = try TeraRecoveryUploadReceipt(snapshot: snapshot, owner: owner) 19 try await TeraNativeUploadReconciliation.settle(snapshot, input: input, receipt: receipt(input), transfer: transfer) 20 }, lookup: lookup) 21 } 22 } 23 24 final class TeraRecoverySettlementTests: XCTestCase { 25 @MainActor 26 func testVerifiedRustDraftReconcilesAwaitingReceiptAfterRelaunch() async throws { 27 for legacyPath in [false, true] { 28 let fixture = try BackgroundUploadFixture() 29 defer { fixture.remove() } 30 let transfer = BackgroundTransferHarness() 31 let coordinator = fixture.coordinator(transfer: transfer) 32 let job = fixture.job(revision: 2, operation: String(repeating: "a", count: 32)) 33 try await transfer.seed(request: fixture.request(job: job, remoteURL: legacyPath ? job.remoteURL : nil), 34 state: .awaitingVerification) 35 36 let backend = try TeraScopeBackend() 37 let draft = fixture.draft(revision: 3, stage: .verified) 38 await backend.setDrafts([draft]) 39 await backend.setRecoveryCompletion { input, _ in 40 XCTAssertEqual(input.revision, 2) 41 XCTAssertEqual(input.uploadURL, legacyPath ? job.remoteURL : job.uploadURL) 42 return RecoverySettlementFixture.receipt(input) 43 } 44 let client = try await TeraScopeFixtures.client(backend) 45 try await coordinator.reconcileBackgroundUploads(drafts: [draft], client: client) 46 _ = try await client.stop() 47 48 let counts = await transfer.counts 49 let state = await transfer.state 50 XCTAssertEqual(counts.acceptedSettlement, 1) 51 XCTAssertEqual(state, .completed) 52 } 53 } 54 55 @MainActor 56 func testDuplicateDisplayHintsCannotDuplicateSettlement() async throws { 57 let fixture = try BackgroundUploadFixture() 58 defer { fixture.remove() } 59 let transfer = BackgroundTransferHarness() 60 let coordinator = fixture.coordinator(transfer: transfer) 61 let draft = fixture.draft(revision: 3, stage: .verified) 62 try await transfer.seed(request: fixture.request(job: fixture.job(revision: 2, operation: String(repeating: "a", count: 32))), state: .awaitingVerification) 63 let backend = try TeraScopeBackend() 64 await backend.setDrafts([draft]) 65 await backend.setRecoveryCompletion { input, _ in RecoverySettlementFixture.receipt(input) } 66 let client = try await TeraScopeFixtures.client(backend) 67 try await coordinator.reconcileBackgroundUploads(drafts: [draft, draft], client: client) 68 let counts = await transfer.counts 69 XCTAssertEqual(counts.acceptedSettlement, 1) 70 _ = try await client.stop() 71 } 72 73 func testDuplicateAndLostSettlementReplyConvergeWithoutAnotherWrite() async throws { 74 for lostReply in [false, true] { 75 let fixture = try BackgroundUploadFixture() 76 defer { fixture.remove() } 77 let transfer = BackgroundTransferHarness() 78 let request = try fixture.request(job: fixture.job(revision: 2, operation: String(repeating: "a", count: 32))) 79 try await transfer.seed(request: request, state: .awaitingVerification) 80 let value = try await transfer.snapshot(for: request.identifier) 81 let snapshot = try XCTUnwrap(value) 82 let input = try TeraRecoveryUploadReceipt(snapshot: snapshot, owner: .init(draft: fixture.draft(revision: 3, stage: .verified))) 83 if lostReply { 84 await transfer.failNextSettlement(afterWrite: true) 85 } 86 for _ in 0 ..< 2 { 87 try await TeraNativeUploadReconciliation.settle(snapshot, input: input, receipt: RecoverySettlementFixture.receipt(input), transfer: transfer) 88 } 89 let counts = await transfer.counts 90 XCTAssertEqual(counts.acceptedSettlement, 1) 91 XCTAssertEqual(counts.enqueue, 0) 92 XCTAssertEqual(counts.retry, 0) 93 } 94 } 95 96 func testUnconfirmedSettlementRetainsReceiptAndCanResume() async throws { 97 let fixture = try BackgroundUploadFixture() 98 defer { fixture.remove() } 99 let transfer = BackgroundTransferHarness() 100 let request = try fixture.request(job: fixture.job(revision: 2, operation: String(repeating: "a", count: 32))) 101 try await transfer.seed(request: request, state: .awaitingVerification) 102 let value = try await transfer.snapshot(for: request.identifier) 103 let snapshot = try XCTUnwrap(value) 104 let input = try TeraRecoveryUploadReceipt(snapshot: snapshot, owner: .init(draft: fixture.draft(revision: 3, stage: .verified))) 105 await transfer.failNextSettlement(afterWrite: false) 106 do { 107 try await TeraNativeUploadReconciliation.settle(snapshot, input: input, receipt: RecoverySettlementFixture.receipt(input), transfer: transfer) 108 XCTFail("A lost reply without completed native state cannot succeed") 109 } catch {} 110 let state = await transfer.state 111 XCTAssertEqual(state, .awaitingVerification) 112 try await TeraNativeUploadReconciliation.settle(snapshot, input: input, receipt: RecoverySettlementFixture.receipt(input), transfer: transfer) 113 let counts = await transfer.counts 114 XCTAssertEqual(counts.acceptedSettlement, 1) 115 } 116 117 func testChangedNativeAssociationCannotSettle() async throws { 118 let fixture = try BackgroundUploadFixture() 119 defer { fixture.remove() } 120 let transfer = BackgroundTransferHarness() 121 let request = try fixture.request(job: fixture.job(revision: 2, operation: String(repeating: "a", count: 32))) 122 try await transfer.seed(request: request, state: .awaitingVerification) 123 let value = try await transfer.snapshot(for: request.identifier) 124 let snapshot = try XCTUnwrap(value) 125 let input = try TeraRecoveryUploadReceipt(snapshot: snapshot, owner: .init(draft: fixture.draft(revision: 3, stage: .verified))) 126 let changed = try fixture.request(job: fixture.job(revision: 2, operation: String(repeating: "a", count: 32)), remoteURL: "http://127.0.0.1:3000/changed") 127 try await transfer.seed(request: changed, state: .awaitingVerification) 128 do { 129 try await TeraNativeUploadReconciliation.settle(snapshot, input: input, receipt: RecoverySettlementFixture.receipt(input), transfer: transfer) 130 XCTFail("Changed request must retain evidence") 131 } catch {} 132 let counts = await transfer.counts 133 XCTAssertEqual(counts.acceptedSettlement, 0) 134 XCTAssertEqual(counts.cancel, 0) 135 } 136 }