Files
Termind/FrameworkTests/CloudSyncLifecycleTests.swift

833 lines
35 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import CloudKit
import Foundation
import TermindApplication
import TermindDomain
import Testing
@testable import TermindInfrastructure
@Suite @MainActor
struct CloudSyncLifecycleTests {
@Test(arguments: ["start", "retry", "localChanges"])
func synchronizationDoesNotInheritDelegateTaskContext(trigger: String) async throws {
let fixture = LifecycleFixture()
defer { fixture.driver.pause() }
let engine = fixture.network.nextEngine
if trigger != "start" {
fixture.driver.start()
await fixture.driver.operation?.value
}
var fetchContexts: [Bool] = []
var sendContexts: [Bool] = []
engine.fetchBody = { fetchContexts.append(LifecycleDelegateContext.isHandlingEvent) }
engine.sendBody = { sendContexts.append(LifecycleDelegateContext.isHandlingEvent) }
LifecycleDelegateContext.$isHandlingEvent.withValue(true) {
switch trigger {
case "start": fixture.driver.start()
case "retry": fixture.driver.retry()
default:
fixture.driver.handle(
.fetchedRecordZoneChanges(records: [], deletions: []), engine: engine,
receiveRemoteRecords: { _, _ in
fixture.journal.pending.append("host-identity-change")
fixture.driver.noteLocalChanges(["host-identity-change"])
}, handleSentChanges: { _ in })
}
}
let operation = try #require(fixture.driver.operation)
await operation.value
#expect(fetchContexts == (trigger == "localChanges" ? [] : [false]))
#expect(sendContexts == [false])
#expect(fixture.driver.isReadyToSend)
if trigger == "localChanges" {
#expect(engine.queued.contains("host-identity-change"))
}
}
@Test
func accountChangeCancelsEngineOutsideDelegateTaskContext() async {
let fixture = LifecycleFixture()
fixture.driver.start()
await fixture.driver.operation?.value
let engine = fixture.network.nextEngine
let gate = LifecycleGate()
var cancellationContext: Bool?
engine.cancelBody = {
cancellationContext = LifecycleDelegateContext.isHandlingEvent
try? await gate.suspend()
}
LifecycleDelegateContext.$isHandlingEvent.withValue(true) {
fixture.driver.handle(
.accountChange(.signOut(previousUser: .init(recordName: "current-user"))), engine: engine,
receiveRemoteRecords: { _, _ in }, handleSentChanges: { _ in })
}
await gate.waitUntilEntered()
gate.resume()
#expect(cancellationContext == false)
#expect(engine.cancelCount == 1)
#expect(fixture.driver.engine == nil)
#expect(fixture.driver.status == .signInRequired)
}
@Test
func mixedSaveAndDeleteBatchesShareTheRequestLimitAndRejectUnjournaledDeletes() async throws {
let fixture = LifecycleFixture()
defer { fixture.driver.pause() }
fixture.journal.pending = (0..<300).map { "record-\($0)" }
fixture.journal.deleted = Set(fixture.journal.pending.prefix(180))
fixture.driver.start()
await fixture.driver.operation?.value
let zone = fixture.driver.zoneID
let pending =
fixture.network.nextEngine.pendingChanges + [.deleteRecord(.init(recordName: "rogue", zoneID: zone))]
let batch = try #require(
fixture.driver.makeChangeBatch(pending, engine: fixture.network.nextEngine) {
CKRecord(recordType: "Test", recordID: $0)
})
#expect(batch.recordsToSave.count == 70)
#expect(batch.recordIDsToDelete.count == 180)
#expect(!batch.recordIDsToDelete.contains { $0.recordName == "rogue" })
#expect(Set(batch.recordsToSave.map(\.recordID)).isDisjoint(with: batch.recordIDsToDelete))
}
@Test func signedOutStatesUseTheSharedSignInRequirement() async throws {
let fixture = LifecycleFixture()
defer { fixture.driver.pause() }
fixture.network.accountBody = { .noAccount }
fixture.driver.start()
await fixture.driver.operation?.value
#expect(fixture.driver.status == .signInRequired)
#expect(fixture.driver.status(for: CKError(.notAuthenticated)) == .signInRequired)
try fixture.driver.handleAccountChange(.signOut(previousUser: CKRecord.ID(recordName: "previous-user")))
#expect(fixture.driver.status == .signInRequired)
#expect(
CloudSyncStatusSummary.make([.signInRequired, .upToDate(lastSuccessfulSync: nil)], isActive: true)
== .signInRequired)
}
@Test
func sharedEventDispatcherRoutesCallbacksAndRejectsRetiredEngines() async throws {
let fixture = LifecycleFixture()
fixture.driver.start()
await fixture.driver.operation?.value
let activeEngine = fixture.network.nextEngine
let record = CKRecord(
recordType: "Test",
recordID: CKRecord.ID(recordName: "remote", zoneID: fixture.driver.zoneID))
var receivedRecordNames: [String] = []
var sentCallbackCount = 0
let receive: @MainActor @Sendable ([CKRecord], [CKRecord.ID]) throws -> Void = {
receivedRecordNames.append(contentsOf: $0.map(\.recordID.recordName))
#expect($1.isEmpty)
}
let sent: @MainActor @Sendable (CloudSyncSentChanges) throws -> Void = { _ in sentCallbackCount += 1 }
fixture.driver.handle(
.fetchedRecordZoneChanges(records: [record], deletions: []), engine: activeEngine,
receiveRemoteRecords: receive, handleSentChanges: sent)
fixture.driver.handle(
.sentRecordZoneChanges(.init(savedRecords: [], failedRecordSaves: [])), engine: activeEngine,
receiveRemoteRecords: receive, handleSentChanges: sent)
fixture.driver.handle(
.fetchedDatabaseChanges(deletions: [(fixture.driver.zoneID, .deleted)]), engine: activeEngine,
receiveRemoteRecords: receive, handleSentChanges: sent)
#expect(receivedRecordNames == ["remote"])
#expect(sentCallbackCount == 1)
#expect(fixture.journal.resets == ["environment", "zone"])
fixture.driver.pause()
fixture.network.nextEngine = LifecycleEngine()
fixture.driver.start()
await fixture.driver.operation?.value
fixture.driver.handle(
.fetchedRecordZoneChanges(records: [record], deletions: []), engine: activeEngine,
receiveRemoteRecords: receive, handleSentChanges: sent)
#expect(receivedRecordNames == ["remote"])
}
@Test(arguments: [CloudSyncZoneDeletionReason.purged, .encryptedDataReset])
func destructiveZoneDeletionPurgesAndRetiresWithoutRecreating(
reason: CloudSyncZoneDeletionReason
) async throws {
let fixture = LifecycleFixture()
fixture.driver.start()
await fixture.driver.operation?.value
let engine = fixture.network.nextEngine
let receive: @MainActor @Sendable ([CKRecord], [CKRecord.ID]) throws -> Void = { _, _ in }
let sent: @MainActor @Sendable (CloudSyncSentChanges) throws -> Void = { _ in }
fixture.driver.handle(
.fetchedDatabaseChanges(deletions: [(fixture.driver.zoneID, reason)]), engine: engine,
receiveRemoteRecords: receive, handleSentChanges: sent)
#expect(fixture.journal.purgeCount == 1)
#expect(fixture.driver.engine == nil)
#expect(!fixture.journal.uploadsAuthorized)
#expect(fixture.network.nextEngine.recreatedZones.isEmpty)
#expect(fixture.journal.resets == ["environment"])
fixture.driver.handle(
.fetchedDatabaseChanges(deletions: [(fixture.driver.zoneID, reason)]), engine: engine,
receiveRemoteRecords: receive, handleSentChanges: sent)
#expect(fixture.journal.purgeCount == 1)
fixture.journal.pending = ["new-explicit-write"]
fixture.driver.noteLocalChanges(fixture.journal.pending)
await fixture.driver.operation?.value
#expect(fixture.network.engineCount == 2)
}
@Test
func sharedEventDispatcherFailsClosedAndFetchFailureKeepsUploadsFenced() async {
let fixture = LifecycleFixture()
fixture.driver.start()
await fixture.driver.operation?.value
let engine = fixture.network.nextEngine
let unusedReceive: @MainActor @Sendable ([CKRecord], [CKRecord.ID]) throws -> Void = { _, _ in }
let unusedSent: @MainActor @Sendable (CloudSyncSentChanges) throws -> Void = { _ in }
fixture.driver.handle(
.willFetchChanges, engine: engine, receiveRemoteRecords: unusedReceive,
handleSentChanges: unusedSent)
fixture.driver.handle(
.didFetchRecordZoneChanges(CKError(.networkFailure)), engine: engine,
receiveRemoteRecords: unusedReceive, handleSentChanges: unusedSent)
fixture.driver.handle(
.didFetchChanges, engine: engine, receiveRemoteRecords: unusedReceive,
handleSentChanges: unusedSent)
#expect(fixture.driver.accepts(engine))
#expect(!fixture.driver.isReadyToSend)
#expect(!fixture.journal.uploadsAuthorized)
#expect(fixture.journal.recoveryRequired)
let record = CKRecord(recordType: "Test")
fixture.driver.handle(
.fetchedRecordZoneChanges(records: [record], deletions: []), engine: engine,
receiveRemoteRecords: { _, _ in throw CocoaError(.fileWriteUnknown) },
handleSentChanges: unusedSent)
#expect(fixture.driver.engine == nil)
#expect(!fixture.journal.uploadsAuthorized)
guard case .failed = fixture.driver.status else {
Issue.record("A category callback failure must fail the shared dispatcher closed")
return
}
}
@Test
func sharedFailedSaveHandlerPreservesConflictZoneRetryAndTerminalSemantics() async throws {
let fixture = LifecycleFixture()
fixture.driver.start()
await fixture.driver.operation?.value
let serverRecord = CKRecord(recordType: "Test")
var conflictRecord: CKRecord?
try fixture.driver.handleFailedSave(
CKError(.serverRecordChanged, userInfo: [CKRecordChangedErrorServerRecordKey: serverRecord])
) { records, isConflict in
conflictRecord = records.first
#expect(isConflict)
}
#expect(conflictRecord === serverRecord)
#expect(throws: CKError.self) {
try fixture.driver.handleFailedSave(CKError(.serverRecordChanged)) { _, _ in }
}
try fixture.driver.handleFailedSave(CKError(.zoneNotFound)) { _, _ in }
#expect(fixture.journal.resets == ["environment", "zone"])
try fixture.driver.handleFailedSave(CKError(.networkFailure)) { _, _ in }
#expect(fixture.driver.terminalError == nil)
try fixture.driver.handleFailedSave(CKError(.permissionFailure)) { _, _ in }
#expect(fixture.driver.terminalError != nil)
guard case .failed = fixture.driver.status else {
Issue.record("A terminal save error must remain visible")
return
}
}
@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)
fixture.journal.pending = ids.map(\.recordName)
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))
fixture.journal.pending = ids.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 reconciliationPreservesEligibleAndOtherZoneChangesWhileRemovingStaleAndPhysicalChanges() async {
let fixture = LifecycleFixture()
let engine = fixture.network.nextEngine
let zone = fixture.driver.zoneID
let otherZone = CKRecordZone.ID(zoneName: "OtherZone")
fixture.journal.pending = ["first", "missing"]
engine.pendingChanges = [
.saveRecord(.init(recordName: "other", zoneID: otherZone)),
.saveRecord(.init(recordName: "first", zoneID: zone)),
.saveRecord(.init(recordName: "stale", zoneID: zone)),
.deleteRecord(.init(recordName: "legacy-delete", zoneID: zone)),
]
fixture.driver.start()
await fixture.driver.operation?.value
#expect(engine.saveRecordNames(in: zone) == ["first", "missing"])
#expect(engine.deleteRecordNames(in: zone).isEmpty)
#expect(engine.saveRecordNames(in: otherZone) == ["other"])
#expect(engine.reconciliationMutationCount == 1)
try? fixture.driver.reconcilePendingChanges()
#expect(engine.reconciliationMutationCount == 1)
}
@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", "zone", "engine"])
}
@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: {},
pendingChanges: { _ in
if let error = journal.lookupError { throw error }
return journal.pending.filter { !journal.blocked.contains($0) }.map {
CloudSyncMutation(
recordName: $0, operation: journal.deleted.contains($0) ? .delete : .save,
version: .baseline(writerID: journal.writerID))
}
},
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") },
purge: { _ in
journal.purgeCount += 1
journal.pending.removeAll()
}),
transport: network.transport, zoneID: .init(zoneName: "LifecycleTests"),
containerIdentifier: "LifecycleTests",
defaults: UserDefaults(suiteName: "CloudSyncLifecycleTests.\(UUID())")!,
enabledKey: "enabled", defaultEnabled: true, environmentKey: "environment",
environmentIdentifier: "Development",
authorizeUploads: { journal.uploadsAuthorized = $0 },
beforeSend: { journal.migrationCount += 1 })
}
}
@MainActor
private final class LifecycleJournal {
var value = CloudSyncState()
let writerID = UUID()
var pending = ["first"]
var deleted = Set<String>()
var blocked = Set<String>()
var recoveryRequired = false
var saveError: (any Error)?
var lookupError: (any Error)?
var resets: [String] = []
var uploadsAuthorized = false
var migrationCount = 0
var purgeCount = 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 pendingChanges: [CKSyncEngine.PendingRecordZoneChange] = []
var queued: [String] {
pendingChanges.compactMap { change in
guard case .saveRecord(let id) = change else { return nil }
return id.recordName
}
}
var recreatedZones: [CKRecordZone.ID] = []
var reconciliationMutationCount = 0
var fetchCount = 0
var sendCount = 0
var cancelCount = 0
var fetchBody: () async throws -> Void = {}
var sendBody: () async throws -> Void = {}
var cancelBody: () async -> 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
await cancelBody()
}
func removePending(_ recordNames: Set<String>, in zoneID: CKRecordZone.ID) {
pendingChanges.removeAll { change in
switch change {
case .saveRecord(let id): id.zoneID == zoneID && recordNames.contains(id.recordName)
case .deleteRecord(let id): id.zoneID == zoneID && recordNames.contains(id.recordName)
@unknown default: false
}
}
}
func reconcilePending(_ mutations: [CloudSyncMutation], in zoneID: CKRecordZone.ID) {
let reconciliation = CloudSyncPendingReconciliation(
current: pendingChanges, mutations: mutations, zoneID: zoneID)
pendingChanges.removeAll { change in
reconciliation.obsolete.contains { obsolete in
switch (change, obsolete) {
case (.saveRecord(let lhs), .saveRecord(let rhs)),
(.deleteRecord(let lhs), .deleteRecord(let rhs)):
lhs == rhs
default: false
}
}
}
pendingChanges.append(
contentsOf: reconciliation.missing)
if !reconciliation.obsolete.isEmpty || !reconciliation.missing.isEmpty {
reconciliationMutationCount += 1
}
}
func recreateZone(_ zoneID: CKRecordZone.ID) { recreatedZones.append(zoneID) }
func saveRecordNames(in zoneID: CKRecordZone.ID) -> [String] {
pendingChanges.compactMap { change in
guard case .saveRecord(let id) = change, id.zoneID == zoneID else { return nil }
return id.recordName
}
}
func deleteRecordNames(in zoneID: CKRecordZone.ID) -> [String] {
pendingChanges.compactMap { change in
guard case .deleteRecord(let id) = change, id.zoneID == zoneID else { return nil }
return id.recordName
}
}
}
private enum LifecycleDelegateContext {
@TaskLocal static var isHandlingEvent = false
}
@MainActor
private final class LifecycleGate {
private var continuation: CheckedContinuation<Void, any Error>?
private var arrival: CheckedContinuation<Void, Never>?
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
}
}