TeraDraftRecoveryTests.swift (10182B)
1 import Foundation 2 @testable import TeraApp 3 import XCTest 4 5 @MainActor 6 final class TeraDraftRecoveryTests: XCTestCase { 7 private let scope = TeraComposerScope(authorPublicKey: String(repeating: "a", count: 64), localNetworkID: "default") 8 9 func testRestoredCounterOverflowFailsWithoutWritingOrClaimingSaved() async throws { 10 let storage = ComposerTestStorage() 11 let composer = TeraComposerAutosave(persistence: storage.port, delay: {}) 12 composer.reset(scope: scope) 13 let draft = TeraComposerDraft(scope: scope, id: String(repeating: "1", count: 32), revision: 7, 14 editSequence: .max, form: form("saved")) 15 try composer.restore(draft) 16 XCTAssertEqual(composer.acknowledged, draft) 17 XCTAssertFalse(composer.isDirty) 18 composer.change(form("keep this edit")) 19 XCTAssertTrue(composer.isDirty) 20 XCTAssertEqual(composer.state, .failed) 21 do { 22 _ = try await composer.save(form("keep this edit")) 23 XCTFail("Exhausted edit sequences cannot be saved.") 24 } catch {} 25 let writes = await storage.requests 26 XCTAssertTrue(writes.isEmpty) 27 XCTAssertEqual(composer.acknowledged, draft) 28 } 29 30 func testInvalidRestoreRetainsAcknowledgmentAndOldWriteCannotReplaceRestoredIdentity() async throws { 31 let storage = ComposerTestStorage() 32 let composer = TeraComposerAutosave(persistence: storage.port, delay: {}) 33 composer.reset(scope: scope) 34 let pause = await storage.pauseNext() 35 composer.change(form("old write")) 36 await pause.entered.wait() 37 let restored = TeraComposerDraft(scope: scope, id: String(repeating: "2", count: 32), revision: 4, 38 editSequence: 5, form: form("restored")) 39 try composer.restore(restored) 40 for invalid in [ 41 TeraComposerDraft(scope: scope, id: String(repeating: "0", count: 32), revision: 1, editSequence: 1, form: form("invalid")), 42 TeraComposerDraft(scope: scope, id: restored.id, revision: 0, editSequence: 1, form: form("invalid")), 43 TeraComposerDraft(scope: scope, id: restored.id, revision: .max, editSequence: 1, form: form("invalid")), 44 TeraComposerDraft(scope: scope, id: restored.id, revision: 1, editSequence: 0, form: form("invalid")), 45 TeraComposerDraft(scope: TeraComposerScope(authorPublicKey: scope.authorPublicKey, localNetworkID: "foreign"), 46 id: restored.id, revision: 1, editSequence: 1, form: form("invalid")), 47 ] { 48 XCTAssertThrowsError(try composer.restore(invalid)) 49 XCTAssertEqual(composer.acknowledged, restored) 50 } 51 await pause.resume.open() 52 await storage.completed.wait() 53 let confirmed = try await composer.save(restored.form) 54 XCTAssertEqual(confirmed, restored) 55 XCTAssertEqual(composer.state, .saved) 56 let writes = await storage.requests 57 XCTAssertEqual(writes.count, 1) 58 } 59 60 func testMissingForeignAndLateSelectionsKeepCurrentEditing() async throws { 61 let backend = try TeraScopeBackend() 62 let client = try await TeraScopeFixtures.client(backend) 63 let store = TeraAddStore(runtimeClient: client) 64 store.configure(snapshot: TeraScopeFixtures.snapshot()) 65 await store.start() 66 store.updateForm(\.content, "keep current editing") 67 await store.save() 68 let original = try XCTUnwrap(store.savedComposer) 69 let missing = await store.reopenSaved(.composer(String(repeating: "3", count: 32))) 70 XCTAssertFalse(missing) 71 XCTAssertEqual(store.form.content, "keep current editing") 72 XCTAssertEqual(store.savedComposer, original) 73 let foreign = try await client.saveComposer(request: TeraComposerSaveRequest( 74 scope: TeraComposerScope(authorPublicKey: scope.authorPublicKey, localNetworkID: "foreign"), 75 id: String(repeating: "4", count: 32), expectedRevision: nil, editSequence: 1, form: form("foreign") 76 )) 77 let refused = await store.reopenSaved(.composer(foreign.draft.id)) 78 XCTAssertFalse(refused) 79 XCTAssertEqual(store.savedComposer, original) 80 let pause = await backend.pause(.composerLoad) 81 let old = Task { await store.reopenSaved(.composer(original.id)) } 82 await pause.entered.wait() 83 let snapshot = TeraScopeFixtures.snapshot(account: "b") 84 await backend.configure(snapshot) 85 store.configure(snapshot: snapshot) 86 await store.start() 87 store.updateForm(\.content, "new account editing") 88 await pause.resume.open() 89 let applied = await old.value 90 XCTAssertFalse(applied) 91 XCTAssertEqual(store.form.content, "new account editing") 92 XCTAssertNil(store.savedComposer) 93 store.stop() 94 _ = try await client.stop() 95 } 96 97 func testInventoryReplacesBoundedPagesPreservesRepairsAndIsolatesLegacyFailure() async throws { 98 let backend = try TeraScopeBackend() 99 let client = try await TeraScopeFixtures.client(backend) 100 let entries = (1 ... 100).map { index in 101 TeraComposerListEntry.draft(TeraComposerSummary(id: String(format: "%032x", index), revision: 1, 102 editSequence: 1, commandType: .createEvent, 103 createdAtUnixMilliseconds: 1, updatedAtUnixMilliseconds: 1)) 104 } 105 let repairs: [TeraComposerListEntry] = [ 106 .repair(draftKey: String(repeating: "0", count: 32), revision: 1, reason: .corruptRecord), 107 .repair(draftKey: String(repeating: "f", count: 32), revision: 2, reason: .unsupportedSchema), 108 ] 109 await backend.setComposerPage(TeraComposerPage(scope: scope, entries: entries, nextCursor: "next")) 110 await backend.setComposerPage(TeraComposerPage(scope: scope, entries: repairs, nextCursor: nil), cursor: "next") 111 let failure = await backend.pause(.legacyPage, fails: true) 112 let recovery = TeraDraftRecoveryStore(client: client) 113 recovery.configure(scope: scope) 114 recovery.start() 115 await failure.entered.wait() 116 XCTAssertEqual(recovery.composers, entries) 117 await failure.resume.open() 118 await TeraScopeFixtures.eventually { !recovery.isLoading } 119 XCTAssertNotNil(recovery.legacyError) 120 XCTAssertNil(recovery.composerError) 121 recovery.moreComposers() 122 await TeraScopeFixtures.eventually { !recovery.isLoading } 123 XCTAssertEqual(recovery.composers, repairs) 124 XCTAssertNil(recovery.composerCursor) 125 recovery.start() 126 await TeraScopeFixtures.eventually { !recovery.isLoading } 127 XCTAssertEqual(recovery.composers, entries) 128 XCTAssertNil(recovery.legacyError) 129 let limits = await backend.recoveryLimits 130 XCTAssertTrue(limits.allSatisfy { $0 == 100 }) 131 recovery.stop() 132 _ = try await client.stop() 133 } 134 135 func testOldInventoryPageCannotReplaceChangedScopeAndWorkRemainsBounded() async throws { 136 let backend = try TeraScopeBackend() 137 let client = try await TeraScopeFixtures.client(backend) 138 let old = await backend.pause(.composerList) 139 let recovery = TeraDraftRecoveryStore(client: client) 140 recovery.configure(scope: scope) 141 recovery.start() 142 await old.entered.wait() 143 for index in 1 ... 100 { 144 recovery.configure(scope: TeraComposerScope(authorPublicKey: scope.authorPublicKey, localNetworkID: "context-\(index)")) 145 recovery.start() 146 } 147 let counts = await backend.counts 148 XCTAssertEqual(counts[.composerList], 1) 149 await old.resume.open() 150 await TeraScopeFixtures.eventually { !recovery.isLoading } 151 let finalCounts = await backend.counts 152 XCTAssertEqual(finalCounts[.composerList], 2) 153 XCTAssertEqual(recovery.scope?.localNetworkID, "context-100") 154 XCTAssertNil(recovery.composerError) 155 XCTAssertTrue(recovery.composers.isEmpty) 156 recovery.stop() 157 _ = try await client.stop() 158 } 159 160 private func form(_ content: String) -> TeraComposerForm { 161 var value = TeraComposerForm(commandType: .createEvent) 162 value.content = content 163 value.eventStartDate = "2026-09-" 164 return value 165 } 166 167 func testLegacySelectionLoadsOriginalFormAndRejectsForeignAuthor() async throws { 168 let backend = try TeraScopeBackend() 169 let client = try await TeraScopeFixtures.client(backend) 170 let store = TeraAddStore(runtimeClient: client) 171 store.configure(snapshot: TeraScopeFixtures.snapshot()) 172 await store.start() 173 let applied = await store.reopenSaved(.legacy("draft")) 174 XCTAssertTrue(applied) 175 XCTAssertEqual(store.activeDraft, TeraScopeFixtures.draft("old")) 176 XCTAssertEqual(store.form, TeraScopeFixtures.draft("old").form) 177 let snapshot = TeraScopeFixtures.snapshot(account: "b") 178 await backend.configure(snapshot) 179 store.configure(snapshot: snapshot) 180 await store.start() 181 store.updateForm(\.content, "other account editing") 182 let refused = await store.reopenSaved(.legacy("draft")) 183 XCTAssertFalse(refused) 184 XCTAssertEqual(store.form.content, "other account editing") 185 XCTAssertNil(store.activeDraft) 186 store.stop() 187 _ = try await client.stop() 188 } 189 190 func testLegacyRepairContinuationRemainsSeparateFromComposerPage() async throws { 191 let backend = try TeraScopeBackend() 192 let client = try await TeraScopeFixtures.client(backend) 193 let first: [TeraLegacyDraftListEntry] = (1 ... 100).map { index in 194 .repair(draftKey: String(format: "%032x", index), revision: 1, reason: .unsupportedSchema) 195 } 196 let last: [TeraLegacyDraftListEntry] = [.repair(draftKey: String(repeating: "0", count: 32), revision: 2, reason: .needsAttention)] 197 await backend.setLegacyPage(TeraLegacyDraftPage(authorPublicKey: scope.authorPublicKey, entries: first, nextCursor: "next")) 198 await backend.setLegacyPage(TeraLegacyDraftPage(authorPublicKey: scope.authorPublicKey, entries: last, nextCursor: nil), cursor: "next") 199 let recovery = TeraDraftRecoveryStore(client: client) 200 recovery.configure(scope: scope) 201 recovery.start() 202 await TeraScopeFixtures.eventually { !recovery.isLoading } 203 XCTAssertEqual(recovery.legacy, first) 204 recovery.moreLegacy() 205 await TeraScopeFixtures.eventually { !recovery.isLoading } 206 XCTAssertEqual(recovery.legacy, last) 207 XCTAssertNil(recovery.legacyCursor) 208 XCTAssertNil(recovery.composerError) 209 XCTAssertNil(recovery.legacyError) 210 XCTAssertTrue(recovery.composers.isEmpty) 211 recovery.stop() 212 _ = try await client.stop() 213 } 214 }