import CloudKit import Foundation import TermindApplication import TermindDomain import Testing @testable import TermindInfrastructure @Suite @MainActor struct CloudSyncLifecycleTests { @Test func uploadBatchesNeverExceedCloudKitRequestLimit() async throws { let fixture = LifecycleFixture() fixture.driver.start() await fixture.driver.operation?.value let ids = (0..<505).map { CKRecord.ID(recordName: "record-\($0)", zoneID: fixture.driver.zoneID) } let pending = ids.map(CKSyncEngine.PendingRecordZoneChange.saveRecord) let first = try #require( fixture.driver.makeChangeBatch(pending, engine: fixture.network.nextEngine) { CKRecord(recordType: "Test", recordID: $0) }) let second = try #require( fixture.driver.makeChangeBatch(Array(pending.dropFirst(250)), engine: fixture.network.nextEngine) { CKRecord(recordType: "Test", recordID: $0) }) let third = try #require( fixture.driver.makeChangeBatch(Array(pending.dropFirst(500)), engine: fixture.network.nextEngine) { CKRecord(recordType: "Test", recordID: $0) }) #expect(CloudSyncLifecycle.maximumRecordsPerRequest == 250) #expect(first.recordsToSave.map(\.recordID) == Array(ids.prefix(250))) #expect(second.recordsToSave.map(\.recordID) == Array(ids.dropFirst(250).prefix(250))) #expect(third.recordsToSave.map(\.recordID) == Array(ids.suffix(5))) #expect(first.recordIDsToDelete.isEmpty) } @Test func invalidPendingChangesDoNotConsumeTheRequestLimit() async throws { let fixture = LifecycleFixture() fixture.driver.start() await fixture.driver.operation?.value let ids = (0..<280).map { CKRecord.ID(recordName: "record-\($0)", zoneID: fixture.driver.zoneID) } fixture.journal.blocked = Set(ids.prefix(10).map(\.recordName)) let missing = Set(ids.dropFirst(10).prefix(10).map(\.recordName)) let pending = ids.flatMap { [ CKSyncEngine.PendingRecordZoneChange.deleteRecord($0), CKSyncEngine.PendingRecordZoneChange.saveRecord($0), ] } let batch = try #require( fixture.driver.makeChangeBatch(pending, engine: fixture.network.nextEngine) { id in missing.contains(id.recordName) ? nil : CKRecord(recordType: "Test", recordID: id) }) #expect(batch.recordsToSave.count == 250) #expect(batch.recordsToSave.first?.recordID == ids[20]) #expect(batch.recordsToSave.last?.recordID == ids[269]) #expect(batch.recordIDsToDelete.isEmpty) } @Test func firstFetchGatesSendsAndAuthorizationWhileLocalChangesAccumulate() async throws { let fixture = LifecycleFixture() let gate = LifecycleGate() fixture.network.nextEngine.fetchBody = { try await gate.suspend() } let driver = fixture.driver driver.start() driver.start() await gate.waitUntilEntered() #expect(fixture.network.engineCount == 1) #expect(!driver.isReadyToSend) #expect(!fixture.journal.uploadsAuthorized) fixture.journal.pending.append("second") driver.noteLocalChanges(["second"]) #expect(fixture.network.nextEngine.sendCount == 0) gate.resume() await driver.operation?.value #expect(driver.isReadyToSend) #expect(fixture.journal.uploadsAuthorized) #expect(fixture.journal.migrationCount == 1) #expect(fixture.network.nextEngine.sendCount == 1) #expect(Set(fixture.network.nextEngine.queued) == ["first", "second"]) #expect(fixture.network.steps == ["account", "user", "engine", "zone"]) } @Test func failedFetchCannotAuthorizeOrSendAndRetryUsesTheSameEngine() async { let fixture = LifecycleFixture() fixture.network.nextEngine.fetchBody = { throw CKError(.networkFailure) } fixture.driver.start() await fixture.driver.operation?.value #expect(!fixture.driver.isReadyToSend) #expect(!fixture.journal.uploadsAuthorized) #expect(fixture.journal.migrationCount == 0) #expect(fixture.network.nextEngine.sendCount == 0) #expect(fixture.driver.status == .syncing(pendingCount: 0)) fixture.network.nextEngine.fetchBody = {} fixture.driver.retry() await fixture.driver.operation?.value #expect(fixture.network.engineCount == 1) #expect(fixture.network.nextEngine.sendCount == 1) #expect(fixture.journal.uploadsAuthorized) fixture.driver.pause() #expect(!fixture.journal.uploadsAuthorized) #expect(!fixture.driver.isReadyToSend) } @Test func retiredAccountFailureCannotOverwriteOrClearTheReplacementOperation() async throws { let fixture = LifecycleFixture() let account = LifecycleGate() fixture.network.accountBody = { try await account.suspend() return .available } fixture.driver.start() await account.waitUntilEntered() let retiredOperation = fixture.driver.operation fixture.driver.pause() let fetch = LifecycleGate() fixture.network.accountBody = { .available } fixture.network.nextEngine.fetchBody = { try await fetch.suspend() } fixture.driver.start() await fetch.waitUntilEntered() account.resume(throwing: CKError(.permissionFailure)) await retiredOperation?.value #expect(fixture.driver.operation != nil) #expect(fixture.driver.status == .checkingAccount) #expect(!fixture.journal.uploadsAuthorized) fetch.resume() await fixture.driver.operation?.value #expect(fixture.driver.status == .syncing(pendingCount: 1)) } @Test(arguments: [false, true]) func retiringAnEngineDuringFetchOrSendRejectsItsLateCompletion(duringSend: Bool) async { let fixture = LifecycleFixture() let gate = LifecycleGate() let retiredEngine = fixture.network.nextEngine if duringSend { retiredEngine.sendBody = { try await gate.suspend() } } else { retiredEngine.fetchBody = { try await gate.suspend() } } fixture.driver.start() await gate.waitUntilEntered() let retiredOperation = fixture.driver.operation fixture.driver.pause() fixture.network.nextEngine = LifecycleEngine() fixture.journal.pending = [] fixture.driver.start() await fixture.driver.operation?.value let replacementStatus = fixture.driver.status #expect(!fixture.driver.accepts(retiredEngine)) gate.resume() await retiredOperation?.value #expect(fixture.driver.status == replacementStatus) #expect(retiredEngine.sendCount == (duringSend ? 1 : 0)) #expect(fixture.network.nextEngine.fetchCount == 1) } @Test func differentAccountRequiresConfirmationAndResetsBeforeBinding() async throws { let fixture = LifecycleFixture() fixture.journal.value.boundUserRecordName = "old-user" fixture.journal.value.engineStateData = Data([1]) fixture.driver.start() await fixture.driver.operation?.value #expect(fixture.driver.status == .accountChangeConfirmationRequired) #expect(fixture.journal.value.boundUserRecordName == "old-user") #expect(fixture.journal.value.pendingUserRecordName == "current-user") #expect(fixture.network.engineCount == 0) #expect(!fixture.journal.uploadsAuthorized) fixture.driver.confirmAccountSwitchMerge() await fixture.driver.operation?.value #expect(fixture.journal.resets == ["account", "environment"]) #expect(fixture.journal.value.boundUserRecordName == "current-user") #expect(fixture.journal.value.pendingUserRecordName == nil) #expect(fixture.network.serializations.count == 1) #expect(fixture.network.serializations[0] == nil) } @Test(arguments: ["signOut", "switchAccounts", "differentSignIn", "sameSignIn"]) func accountEventsRetireOnlyWhenRequired(event: String) async throws { let fixture = LifecycleFixture() fixture.driver.start() await fixture.driver.operation?.value let engine = fixture.network.nextEngine let current = CKRecord.ID(recordName: "current-user") let other = CKRecord.ID(recordName: "other-user") let change: CKSyncEngine.Event.AccountChange.ChangeType = switch event { case "signOut": .signOut(previousUser: current) case "switchAccounts": .switchAccounts(previousUser: current, currentUser: other) case "differentSignIn": .signIn(currentUser: other) default: .signIn(currentUser: current) } try fixture.driver.handleAccountChange(change) #expect(fixture.driver.accepts(engine) == (event == "sameSignIn")) #expect(fixture.journal.uploadsAuthorized == (event == "sameSignIn")) if event == "switchAccounts" || event == "differentSignIn" { #expect(fixture.journal.value.pendingUserRecordName == "other-user") #expect(fixture.driver.status == .accountChangeConfirmationRequired) } } @Test func environmentAndZoneResetsRequeueRecordsWithoutDroppingAccountBinding() async throws { let fixture = LifecycleFixture() fixture.journal.value.engineStateData = Data([1, 2]) fixture.driver.start() await fixture.driver.operation?.value #expect(fixture.journal.resets == ["environment"]) #expect(fixture.network.serializations[0] == nil) try fixture.driver.handleZoneDeletion() #expect(fixture.journal.resets == ["environment", "zone"]) #expect(fixture.network.nextEngine.recreatedZones == [fixture.driver.zoneID]) #expect(fixture.journal.value.boundUserRecordName == "current-user") fixture.driver.pause() fixture.driver.start() await fixture.driver.operation?.value #expect(fixture.journal.resets == ["environment", "zone"]) } @Test func quarantineAndTerminalErrorsPreventSuccessfulStatusWithoutBlockingOtherRecords() async throws { let fixture = LifecycleFixture() fixture.journal.blocked = ["first", "unsupported"] fixture.driver.start() await fixture.driver.operation?.value #expect(fixture.driver.status == .partiallyBlocked(pendingCount: 0, blockedCount: 2)) #expect(fixture.network.nextEngine.sendCount == 0) fixture.driver.terminalError = "terminal" try fixture.driver.updateUpToDateStatus() #expect(fixture.driver.status == .failed("terminal")) #expect(fixture.journal.value.lastSuccessfulSync == nil) fixture.driver.terminalError = nil fixture.journal.pending = [] fixture.journal.blocked = [] try fixture.driver.updateUpToDateStatus() #expect(fixture.journal.value.lastSuccessfulSync != nil) #expect(fixture.driver.status == .upToDate(lastSuccessfulSync: fixture.journal.value.lastSuccessfulSync)) } @Test func persistedRecoveryFenceDiscardsTheCursorButPreservesPendingChanges() async throws { let fixture = LifecycleFixture() fixture.driver.start() await fixture.driver.operation?.value fixture.driver.pause() fixture.journal.value.engineStateData = Data([7, 8]) fixture.journal.recoveryRequired = true fixture.network.nextEngine = LifecycleEngine() fixture.driver.start() await fixture.driver.operation?.value #expect(fixture.network.serializations.last! == nil) #expect(fixture.network.nextEngine.queued.contains("first")) #expect(!fixture.journal.recoveryRequired) } @Test func delegateFailureDuringFetchCannotBeOverwrittenBySuccessfulFetchCompletion() async { let fixture = LifecycleFixture() fixture.network.nextEngine.fetchBody = { fixture.driver.failClosed(CocoaError(.fileWriteUnknown)) } fixture.driver.start() let operation = fixture.driver.operation await operation?.value #expect(fixture.driver.engine == nil) #expect(!fixture.driver.isReadyToSend) #expect(!fixture.journal.uploadsAuthorized) #expect(fixture.journal.recoveryRequired) #expect(fixture.network.nextEngine.sendCount == 0) guard case .failed = fixture.driver.status else { Issue.record("A successful fetch callback must not erase a persistence failure") return } fixture.network.nextEngine = LifecycleEngine() fixture.driver.retry() await fixture.driver.operation?.value #expect(fixture.network.serializations.last! == nil) #expect(fixture.driver.isReadyToSend) } @Test func failedFenceSavePreventsNetworkAndLookupFailureInvalidatesAReadyEngine() async { let fixture = LifecycleFixture() fixture.journal.saveError = CocoaError(.fileWriteUnknown) fixture.driver.start() await fixture.driver.operation?.value #expect(fixture.network.engineCount == 0) #expect(!fixture.driver.isReadyToSend) fixture.journal.saveError = nil fixture.driver.retry() await fixture.driver.operation?.value let engine = fixture.network.nextEngine fixture.journal.lookupError = CocoaError(.fileReadUnknown) fixture.driver.noteLocalChanges(["first"]) #expect(!fixture.driver.accepts(engine)) #expect(!fixture.journal.uploadsAuthorized) } @Test func overlappingExplicitCallsAndAutomaticEventsKeepRecoveryUntilBothComplete() async throws { let fixture = LifecycleFixture() fixture.driver.start() await fixture.driver.operation?.value try fixture.driver.beginFetch() try fixture.driver.beginFetch() try fixture.driver.finishFetch() #expect(!fixture.driver.isReadyToSend) #expect(!fixture.journal.uploadsAuthorized) #expect(fixture.journal.recoveryRequired) try fixture.driver.beginSend() try fixture.driver.finishFetch() #expect(fixture.driver.isReadyToSend) #expect(fixture.journal.recoveryRequired) try fixture.driver.finishSend() #expect(!fixture.journal.recoveryRequired) } @Test func retryableZoneFailureKeepsAutomaticEngineButClosesUploadsUntilNextSuccessfulFetch() async throws { let fixture = LifecycleFixture() fixture.driver.start() await fixture.driver.operation?.value let engine = fixture.network.nextEngine try fixture.driver.beginFetch() fixture.driver.noteFetchFailure(CKError(.networkFailure)) try fixture.driver.finishFetch() try fixture.driver.updateUpToDateStatus() #expect(fixture.driver.accepts(engine)) #expect(!fixture.driver.isReadyToSend) #expect(!fixture.journal.uploadsAuthorized) #expect(fixture.journal.recoveryRequired) #expect(fixture.driver.status == .syncing(pendingCount: 0)) try fixture.driver.beginFetch() try fixture.driver.finishFetch() #expect(fixture.driver.isReadyToSend) #expect(fixture.journal.uploadsAuthorized) #expect(!fixture.journal.recoveryRequired) } @Test func disabledCategoryNeverStartsOrConfirmsAnAccountSwitch() async { let fixture = LifecycleFixture() fixture.driver.setEnabled(false) fixture.journal.value.pendingUserRecordName = "other-user" fixture.driver.start() fixture.driver.retry() fixture.driver.confirmAccountSwitchMerge() #expect(fixture.driver.status == .paused) #expect(fixture.driver.operation == nil) #expect(fixture.network.steps.isEmpty) #expect(fixture.journal.resets.isEmpty) } } @MainActor private struct LifecycleFixture { let journal = LifecycleJournal() let network = LifecycleNetwork() let driver: CloudSyncLifecycle init() { let journal = journal let network = network driver = CloudSyncLifecycle( store: CloudSyncLifecycleStore( state: { journal.value }, updateState: { journal.value = $0 }, save: { if let error = journal.saveError { throw error } }, rollback: {}, pendingRecordNames: { _ in journal.pending.filter { !journal.blocked.contains($0) } }, safety: CloudSyncLifecycleSafety( prepare: { _, _ in journal.recoveryRequired }, setRecoveryRequired: { _, value in journal.recoveryRequired = value }, blockedRecordNames: { _ in if let error = journal.lookupError { throw error } return journal.blocked }, isUploadBlocked: { _, recordName in if let error = journal.lookupError { throw error } return journal.blocked.contains(recordName) }, requiresNewerClient: { _ in false }), resetForNewAccount: { journal.reset("account") }, resetForZone: { journal.reset("zone") }, resetForEnvironment: { journal.reset("environment") }), transport: network.transport, zoneID: .init(zoneName: "LifecycleTests"), containerIdentifier: "LifecycleTests", defaults: UserDefaults(suiteName: "CloudSyncLifecycleTests.\(UUID())")!, enabledKey: "enabled", defaultEnabled: true, environmentKey: "environment", environmentIdentifier: "Development", signInMessage: { "Sign in" }, authorizeUploads: { journal.uploadsAuthorized = $0 }, beforeSend: { journal.migrationCount += 1 }) } } @MainActor private final class LifecycleJournal { var value = CloudSyncState() var pending = ["first"] var blocked = Set() var recoveryRequired = false var saveError: (any Error)? var lookupError: (any Error)? var resets: [String] = [] var uploadsAuthorized = false var migrationCount = 0 func reset(_ reason: String) { resets.append(reason) value.engineStateData = nil } } @MainActor final class LifecycleNetwork { var steps: [String] = [] var serializations: [Data?] = [] var engineCount = 0 var nextEngine = LifecycleEngine() var accountBody: () async throws -> CKAccountStatus = { .available } var transport: CloudSyncTransport { CloudSyncTransport( accountStatus: { self.steps.append("account") return try await self.accountBody() }, userRecordName: { self.steps.append("user") return "current-user" }, createZone: { self.steps.append("zone") }, makeEngine: { self.steps.append("engine") self.serializations.append($0) self.engineCount += 1 return self.nextEngine }) } } @MainActor final class LifecycleEngine: CloudSyncEngineSession { var queued: [String] = [] var recreatedZones: [CKRecordZone.ID] = [] var fetchCount = 0 var sendCount = 0 var cancelCount = 0 var fetchBody: () async throws -> Void = {} var sendBody: () async throws -> Void = {} func fetch(in zoneID: CKRecordZone.ID) async throws { fetchCount += 1 try await fetchBody() } func send() async throws { sendCount += 1 try await sendBody() } func cancel() async { cancelCount += 1 } func enqueue(_ recordNames: [String], in zoneID: CKRecordZone.ID) { queued.append(contentsOf: recordNames) } func removePending(_ recordNames: Set, in zoneID: CKRecordZone.ID) { queued.removeAll { recordNames.contains($0) } } func recreateZone(_ zoneID: CKRecordZone.ID) { recreatedZones.append(zoneID) } } @MainActor private final class LifecycleGate { private var continuation: CheckedContinuation? private var arrival: CheckedContinuation? func suspend() async throws { try await withCheckedThrowingContinuation { continuation in self.continuation = continuation arrival?.resume() arrival = nil } } func waitUntilEntered() async { if continuation != nil { return } await withCheckedContinuation { arrival = $0 } } func resume(throwing error: (any Error)? = nil) { if let error { continuation?.resume(throwing: error) } else { continuation?.resume() } continuation = nil } }