TeraSubmissionStoreTests.swift (18741B)
1 import Foundation 2 @testable import TeraApp 3 import XCTest 4 5 @MainActor 6 final class TeraSubmissionStoreTests: XCTestCase { 7 func testTerminalOperationsReconcileReceiptsWithoutOpeningOrUploadingUnfinishedMedia() async throws { 8 for state in [TeraOutboxState.complete, .terminal, .cancelled] { 9 let backend = AddBackend() 10 let client = try await TeraAddStoreTests.startedClient(backend) 11 let store = await make(client, backend: backend) 12 store.updateForm(\.content, "original") 13 await store.submit() 14 let original = try XCTUnwrap(store.submissions.status) 15 let media = AddMediaHarness(failOpen: true) 16 let item = await media.captureImage() 17 var capturedForm = original.captured.form.editingValue 18 capturedForm.media = [item] 19 let captured = TeraComposerDraft(scope: original.captured.scope, id: original.captured.id, 20 revision: original.captured.revision, editSequence: original.captured.editSequence, 21 form: TeraComposerForm(editing: capturedForm)) 22 let status = TeraSubmissionStatus( 23 request: original.request, intentID: original.intentID, operationID: original.operationID, 24 revision: original.revision, captured: captured, state: state, 25 committedAtUnixMilliseconds: original.committedAtUnixMilliseconds, 26 updatedAtUnixMilliseconds: original.updatedAtUnixMilliseconds, 27 media: [TeraSubmissionMedia(opaqueReference: item.opaqueReference, progress: TeraDraftMediaStatus( 28 url: "https://blossom.example/\(item.sha256).png", stage: .pending, uploadAttempts: 0, 29 verifiedAtUnixMilliseconds: nil, possibleOrphan: false, orphanReasonCode: nil, orphanRecordedAtUnixMilliseconds: nil 30 ))], settlement: original.settlement, delivery: original.delivery, targetDetails: original.targetDetails, retry: original.retry 31 ) 32 let before = await backend.submissionBackend.advanceCount 33 let effects = TeraSubmissionEffects(client: client, media: media, ensure: {}, 34 accept: { _ in XCTFail("Terminal work cannot advance") }) 35 do { try await effects.advance(status) } catch { XCTFail("Terminal reconciliation must not open media: \(state)") } 36 let opened = await media.openCount 37 let reconciled = await media.submissionReconciliations 38 let uploads = await backend.submissionBackend.uploadCount 39 let after = await backend.submissionBackend.advanceCount 40 XCTAssertEqual(opened, 0) 41 XCTAssertEqual(uploads, 0) 42 XCTAssertEqual(after, before) 43 XCTAssertEqual(reconciled, 1) 44 store.stop() 45 _ = try await client.stop() 46 } 47 } 48 49 func testDoubleTapAndTenThousandEditsKeepOneCapturedRequestDuringSlowPrepare() async throws { 50 let backend = AddBackend() 51 let client = try await TeraAddStoreTests.startedClient(backend) 52 let store = await make(client, backend: backend) 53 let pause = ResourceTestPause() 54 await backend.submissionBackend.pausePrepare(pause) 55 store.updateForm(\.content, "The tap's original text") 56 let submit = Task { await store.submit() } 57 await entered(pause) 58 let original = try XCTUnwrap(store.submissions.request) 59 XCTAssertTrue(store.submissions.isWorking) 60 XCTAssertFalse(store.isWorking) 61 XCTAssertTrue(store.isFormEditable) 62 for index in 1 ... 10000 { 63 store.updateForm(\.content, "later \(index)") 64 } 65 await store.submit() 66 XCTAssertEqual(store.submissions.request, original) 67 XCTAssertEqual(store.form.content, "later 10000") 68 let captured = try await client.loadComposer(scope: original.scope, id: original.composerID) 69 XCTAssertEqual(captured.form.content, "The tap's original text") 70 XCTAssertEqual(captured.revision, original.expectedRevision) 71 XCTAssertTrue(store.canSave) 72 await store.save() 73 XCTAssertEqual(store.savedComposer?.form.content, "later 10000") 74 XCTAssertNotEqual(store.savedComposer?.id, original.composerID) 75 XCTAssertEqual(store.composerState, .saved) 76 // Navigation suspends observation without abandoning the active operation. 77 store.suspend() 78 await pause.resume.open() 79 await submit.value 80 await store.start() 81 await store.save() 82 XCTAssertEqual(store.submissions.status?.state, .complete) 83 XCTAssertEqual(store.submissions.status?.captured.form.content, "The tap's original text") 84 XCTAssertEqual(store.savedComposer?.form.content, "later 10000") 85 XCTAssertEqual(store.form.content, "later 10000") 86 let counts = await (backend.submissionBackend.idCount, backend.submissionBackend.prepareCount) 87 XCTAssertEqual(counts.0, 1) 88 XCTAssertEqual(counts.1, 1) 89 _ = try await client.stop() 90 } 91 92 func testCancellingViewWaiterDoesNotCancelOrReplaceTheOperation() async throws { 93 let backend = AddBackend() 94 let client = try await TeraAddStoreTests.startedClient(backend) 95 let store = await make(client, backend: backend) 96 let pause = ResourceTestPause() 97 await backend.submissionBackend.pauseAdvance(pause) 98 store.updateForm(\.content, "original") 99 let submit = Task { await store.submit() } 100 await entered(pause) 101 submit.cancel() 102 store.updateForm(\.content, "newer") 103 await store.submit() 104 XCTAssertTrue(store.submissions.isWorking) 105 await pause.resume.open() 106 await submit.value 107 XCTAssertEqual(store.submissions.status?.state, .complete) 108 XCTAssertEqual(store.submissions.status?.captured.form.content, "original") 109 XCTAssertEqual(store.form.content, "newer") 110 let count = await backend.submissionBackend.prepareCount 111 XCTAssertEqual(count, 1) 112 _ = try await client.stop() 113 } 114 115 func testExplicitStopWaitKeepsWorkerSlotAndRecoversLateCommitWithOriginalID() async throws { 116 let backend = AddBackend() 117 let client = try await TeraAddStoreTests.startedClient(backend) 118 let store = await make(client, backend: backend) 119 let pause = ResourceTestPause() 120 await backend.submissionBackend.pausePrepare(pause) 121 store.updateForm(\.content, "original") 122 let submit = Task { await store.submit() } 123 await entered(pause) 124 let request = try XCTUnwrap(store.submissions.request) 125 store.submissions.stopWaiting() 126 XCTAssertTrue(store.submissions.isWorking) 127 await store.submit() 128 store.updateForm(\.content, "later") 129 await pause.resume.open() 130 await submit.value 131 XCTAssertNil(store.submissions.status) 132 XCTAssertEqual(store.submissions.request, request) 133 XCTAssertFalse(store.submissions.isWorking) 134 await store.submit() 135 XCTAssertEqual(store.submissions.request, request) 136 XCTAssertEqual(store.submissions.status?.state, .complete) 137 XCTAssertEqual(store.submissions.status?.captured.form.content, "original") 138 XCTAssertEqual(store.form.content, "later") 139 let count = await backend.submissionBackend.prepareCount 140 XCTAssertEqual(count, 1) 141 _ = try await client.stop() 142 } 143 144 func testLostCommitAndUnreadableRecoveryCannotTurnNewerEditingIntoAReplacementRequest() async throws { 145 let backend = AddBackend() 146 let client = try await TeraAddStoreTests.startedClient(backend) 147 let store = await make(client, backend: backend) 148 let pause = ResourceTestPause() 149 await backend.submissionBackend.pausePrepare(pause, loseReceipt: true) 150 store.updateForm(\.content, "original") 151 let submit = Task { await store.submit() } 152 await entered(pause) 153 let request = try XCTUnwrap(store.submissions.request) 154 await backend.submissionBackend.unreadable(true) 155 store.updateForm(\.content, "newer editing") 156 await pause.resume.open() 157 await submit.value 158 await store.submit() 159 XCTAssertEqual(store.submissions.request, request) 160 XCTAssertNil(store.submissions.status) 161 let captured = try await client.loadComposer(scope: request.scope, id: request.composerID) 162 XCTAssertEqual(captured.form.content, "original") 163 XCTAssertEqual(captured.revision, request.expectedRevision) 164 await store.save() 165 XCTAssertEqual(store.savedComposer?.form.content, "newer editing") 166 XCTAssertNotEqual(store.savedComposer?.id, request.composerID) 167 XCTAssertEqual(store.form.content, "newer editing") 168 await backend.submissionBackend.unreadable(false) 169 await store.submit() 170 XCTAssertEqual(store.submissions.status?.request, request) 171 XCTAssertEqual(store.submissions.status?.captured.form.content, "original") 172 let count = await backend.submissionBackend.prepareCount 173 XCTAssertEqual(count, 1) 174 _ = try await client.stop() 175 } 176 177 func testRecreatedStoreSelectsSavedOperationWithoutChangingEditingOrBeginningEffects() async throws { 178 let backend = AddBackend(advanceOffline: true) 179 let client = try await TeraAddStoreTests.startedClient(backend) 180 let first = await make(client, backend: backend) 181 first.updateForm(\.content, "original") 182 await first.submit() 183 let saved = try XCTUnwrap(first.submissions.status) 184 first.stop() 185 let replacement = await make(client, backend: backend) 186 replacement.updateForm(\.content, "reopened editing") 187 await TeraScopeFixtures.eventually { !replacement.submissions.inventory.isLoading } 188 guard case let .submission(summary) = replacement.submissions.inventory.entries.first else { 189 return XCTFail("Original operation must be selectable after relaunch") 190 } 191 let before = await backend.submissionBackend.advanceCount 192 await replacement.submissions.select(summary) 193 let after = await backend.submissionBackend.advanceCount 194 XCTAssertEqual(after, before) 195 XCTAssertEqual(replacement.submissions.status, saved) 196 XCTAssertEqual(replacement.form.content, "reopened editing") 197 await replacement.submissions.refreshSelected() 198 let checked = await backend.submissionBackend.advanceCount 199 XCTAssertEqual(checked, before, "Checking status must not begin a publication effect") 200 await replacement.submissions.continueSelected() 201 XCTAssertEqual(replacement.submissions.status?.operationID, saved.operationID) 202 XCTAssertEqual(replacement.submissions.request, saved.request) 203 XCTAssertEqual(replacement.form.content, "reopened editing") 204 _ = try await client.stop() 205 } 206 207 func testExplicitNewMakesDistinctOperationForIdenticalIntentionalContent() async throws { 208 let backend = AddBackend() 209 let client = try await TeraAddStoreTests.startedClient(backend) 210 let store = await make(client, backend: backend) 211 store.updateForm(\.content, "identical") 212 await store.submit() 213 let first = try XCTUnwrap(store.submissions.status) 214 await store.submit() 215 XCTAssertEqual(store.submissions.status, first) 216 store.newDraft() 217 await TeraScopeFixtures.eventually { !store.protection.isWorking } 218 store.updateForm(\.content, "identical") 219 await store.submit() 220 let second = try XCTUnwrap(store.submissions.status) 221 XCTAssertNotEqual(second.request.commandID, first.request.commandID) 222 XCTAssertNotEqual(second.operationID, first.operationID) 223 XCTAssertEqual(second.captured.form, first.captured.form) 224 let count = await backend.submissionBackend.prepareCount 225 XCTAssertEqual(count, 2) 226 _ = try await client.stop() 227 } 228 229 private func make(_ client: TeraRuntimeClient, backend: AddBackend) async -> TeraAddStore { 230 let store = TeraAddStore(runtimeClient: client) 231 await store.configure(snapshot: backend.snapshot()) 232 await store.start() 233 return store 234 } 235 236 private func entered(_ pause: ResourceTestPause) async { 237 let reached = expectation(description: "Submission reached controlled native boundary") 238 let observer = Task { await pause.entered.wait(); reached.fulfill() } 239 await fulfillment(of: [reached], timeout: 3) 240 await pause.entered.open() 241 await observer.value 242 } 243 } 244 245 extension TeraSubmissionStoreTests { 246 func testLateAcceptedPublicationCannotPopulateAReplacementAccountOrNetworkScope() async throws { 247 for replacingAccount in [false, true] { 248 let backend = AddBackend() 249 let client = try await TeraAddStoreTests.startedClient(backend) 250 let store = await make(client, backend: backend) 251 let pause = ResourceTestPause() 252 await backend.submissionBackend.pauseAdvance(pause) 253 store.updateForm(\.content, "Original publication") 254 let task = Task { await store.submit() } 255 await entered(pause) 256 let original = try XCTUnwrap(store.submissions.status) 257 let replacement = TeraComposerScope( 258 authorPublicKey: replacingAccount ? String(repeating: "cd", count: 32) : original.request.scope.authorPublicKey, 259 localNetworkID: replacingAccount ? original.request.scope.localNetworkID : "replacement-network" 260 ) 261 let context = TeraLocalNetwork(schemaVersion: 1, id: replacement.localNetworkID, label: "Replacement", 262 relayURLs: [], locality: nil, followedAuthors: [], generation: 2) 263 store.submissions.configure(scope: replacement, context: context) 264 store.submissions.start() 265 await pause.resume.open() 266 await task.value 267 var recovered: TeraSubmissionStatus? 268 let deadline = ContinuousClock.now.advanced(by: .seconds(3)) 269 repeat { 270 recovered = try? await client.submissionStatus(request: original.request) 271 if recovered?.delivery.state == .accepted { 272 break 273 } 274 try await Task.sleep(for: .milliseconds(1)) 275 } while ContinuousClock.now < deadline 276 let accepted = try XCTUnwrap(recovered) 277 XCTAssertEqual(accepted.delivery.state, .accepted) 278 XCTAssertEqual(accepted.request, original.request) 279 XCTAssertEqual(accepted.operationID, original.operationID) 280 XCTAssertEqual(accepted.captured, original.captured) 281 XCTAssertNil(store.submissions.request) 282 XCTAssertNil(store.submissions.status) 283 XCTAssertNil(store.submissions.message) 284 XCTAssertNil(store.submissions.failureCode) 285 let advances = await backend.submissionBackend.advanceCount 286 XCTAssertEqual(advances, 1) 287 store.stop() 288 _ = try await client.stop() 289 } 290 } 291 292 func testStopBeforeIntentCommitStaysUnconfirmedUntilOriginalPreparationReturns() async throws { 293 let backend = AddBackend() 294 let client = try await TeraAddStoreTests.startedClient(backend) 295 let pause = ResourceTestPause() 296 let media = AddMediaHarness(openPause: pause) 297 let store = TeraAddStore(runtimeClient: client, media: media) 298 await store.configure(snapshot: backend.snapshot()) 299 await store.start() 300 store.selectType(.createPhotoUpdate) 301 store.updateForm(\.content, "Stop before commit") 302 await store.importPhotos() 303 let task = Task { await store.submit() } 304 await entered(pause) 305 let original = try XCTUnwrap(store.submissions.request) 306 await store.submissions.requestStop() 307 XCTAssertNil(store.submissions.status) 308 XCTAssertTrue(store.submissions.message?.contains("not yet confirmed") == true) 309 await pause.resume.open() 310 await task.value 311 XCTAssertTrue(store.submissions.status?.delivery.isStopped == true) 312 XCTAssertEqual(store.submissions.request, original) 313 let prepared = await backend.submissionBackend.prepareCount 314 let uploads = await backend.submissionBackend.uploadCount 315 let advances = await backend.submissionBackend.advanceCount 316 XCTAssertEqual(prepared, 1) 317 XCTAssertEqual(uploads + advances, 0) 318 store.stop() 319 _ = try await client.stop() 320 } 321 322 func testStopWhilePrepareCallbackIsHeldRetainsStopAndDoesNotAdvanceOldCallback() async throws { 323 let backend = AddBackend() 324 let client = try await TeraAddStoreTests.startedClient(backend) 325 let store = await make(client, backend: backend) 326 let pause = ResourceTestPause() 327 await backend.submissionBackend.pausePrepare(pause) 328 store.updateForm(\.content, "Original stop request") 329 let task = Task { await store.submit() } 330 await entered(pause) 331 await store.submissions.requestStop() 332 let stopped = try XCTUnwrap(store.submissions.status) 333 XCTAssertTrue(stopped.delivery.isStopped) 334 XCTAssertTrue(store.submissions.isWorking) 335 await store.submissions.requestStop() 336 XCTAssertEqual(store.submissions.status, stopped) 337 await pause.resume.open() 338 await task.value 339 XCTAssertEqual(store.submissions.status, stopped) 340 let advances = await backend.submissionBackend.advanceCount 341 XCTAssertEqual(advances, 0) 342 await store.submit() 343 XCTAssertEqual(store.submissions.status, stopped) 344 let prepared = await backend.submissionBackend.prepareCount 345 XCTAssertEqual(prepared, 1) 346 store.stop() 347 _ = try await client.stop() 348 } 349 350 func testStopRemainsVisibleWhenAlreadyIssuedWorkReturnsAccepted() async throws { 351 let backend = AddBackend() 352 let client = try await TeraAddStoreTests.startedClient(backend) 353 let store = await make(client, backend: backend) 354 let pause = ResourceTestPause() 355 await backend.submissionBackend.pauseAdvance(pause) 356 store.updateForm(\.content, "Already issued") 357 let task = Task { await store.submit() } 358 await entered(pause) 359 await store.submissions.requestStop() 360 let stopped = try XCTUnwrap(store.submissions.status) 361 XCTAssertTrue(stopped.delivery.isStopped) 362 await pause.resume.open() 363 await task.value 364 let accepted = try XCTUnwrap(store.submissions.status) 365 XCTAssertEqual(accepted.delivery.state, .accepted) 366 XCTAssertEqual(accepted.delivery.stopRequestedAtUnixMilliseconds, stopped.delivery.stopRequestedAtUnixMilliseconds) 367 XCTAssertEqual(accepted.operationID, stopped.operationID) 368 XCTAssertTrue(accepted.summary.contains("accepted")) 369 let advances = await backend.submissionBackend.advanceCount 370 XCTAssertEqual(advances, 1) 371 store.stop() 372 _ = try await client.stop() 373 } 374 375 func testUnconfirmedStopDoesNotClaimPersistenceAndRetryUsesOriginalRequest() async throws { 376 let backend = AddBackend() 377 let client = try await TeraAddStoreTests.startedClient(backend) 378 let store = await make(client, backend: backend) 379 store.updateForm(\.content, "Original") 380 await store.submit() 381 let original = try XCTUnwrap(store.submissions.status) 382 await backend.submissionBackend.unreadable(true) 383 await store.submissions.requestStop() 384 XCTAssertEqual(store.submissions.status, original) 385 XCTAssertTrue(store.submissions.message?.contains("not yet confirmed") == true) 386 await backend.submissionBackend.unreadable(false) 387 await store.submissions.refreshSelected() 388 XCTAssertTrue(store.submissions.status?.delivery.isStopped == true) 389 XCTAssertEqual(store.submissions.request, original.request) 390 let advances = await backend.submissionBackend.advanceCount 391 XCTAssertEqual(advances, 1) 392 store.stop() 393 _ = try await client.stop() 394 } 395 }