1365 lines
68 KiB
Swift
1365 lines
68 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import CloudKit
|
|
import Foundation
|
|
import SwiftData
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPersistence
|
|
import Testing
|
|
|
|
@testable import TermindInfrastructure
|
|
|
|
@Suite @MainActor
|
|
struct CloudSyncPhysicalDeletionTests {
|
|
@Test(arguments: SyncSafetyCategory.allCases)
|
|
func destructiveZoneReasonsPurgeOnlyTheActiveReplicaAndCannotResendOldSnapshot(
|
|
category: SyncSafetyCategory
|
|
) async throws {
|
|
for reason in [CloudSyncZoneDeletionReason.purged, .encryptedDataReset] {
|
|
let fixture = try CloudSyncSafetyFixture(category: category)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let records = try fixture.recordIDs.map { try fixture.newerRecord($0) }
|
|
try fixture.receive(records, [])
|
|
#expect(try fixture.projectionIsPopulated())
|
|
#expect(fixture.remoteApplyCount == 1)
|
|
let scope = try #require(fixture.lifecycle.scope)
|
|
let safety = CloudSyncSafetyStore(context: fixture.container.mainContext, scope: scope)
|
|
try safety.quarantine(recordName: "active-only", payloadData: Data([1]), reason: "purge")
|
|
var otherScope = scope
|
|
otherScope.account = "other-account"
|
|
let other = CloudSyncSafetyStore(context: fixture.container.mainContext, scope: otherScope)
|
|
try other.quarantine(recordName: "active-only", payloadData: Data([2]), reason: "keep")
|
|
try fixture.container.mainContext.save()
|
|
let engine = fixture.network.nextEngine
|
|
|
|
fixture.lifecycle.handle(
|
|
.fetchedDatabaseChanges(deletions: [(fixture.lifecycle.zoneID, reason)]), engine: engine,
|
|
receiveRemoteRecords: { try fixture.receive($0, $1) },
|
|
handleSentChanges: { try fixture.sent($0) })
|
|
|
|
#expect(fixture.lifecycle.engine == nil)
|
|
#expect(try fixture.projectionIsCleared())
|
|
#expect(fixture.remoteApplyCount == 2)
|
|
#expect(fixture.remoteApplyObservedCleared)
|
|
#expect(try safety.blockedRecordNames().isEmpty)
|
|
#expect(try other.entry(recordName: "active-only")?.payloadData == Data([2]))
|
|
fixture.network.nextEngine = LifecycleEngine()
|
|
fixture.lifecycle.start()
|
|
await fixture.lifecycle.operation?.value
|
|
let pending = try fixture.lifecycle.store.pendingChanges(try #require(fixture.lifecycle.scope))
|
|
#expect(Set(pending.map(\.recordName)).isDisjoint(with: fixture.recordIDs.map(\.recordName)))
|
|
#expect(
|
|
fixture.batch(
|
|
fixture.recordIDs.flatMap { [.saveRecord($0), .deleteRecord($0)] }) == nil)
|
|
}
|
|
}
|
|
|
|
@Test(arguments: [SyncSafetyCategory.vault, .settings, .metrics])
|
|
func destructivePurgeRemovesOnlyTheCategoryOwnedLargePayloadAssets(
|
|
category: SyncSafetyCategory
|
|
) async throws {
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let fixture = try CloudSyncSafetyFixture(category: category, assetCacheRoot: root)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
|
|
let directoryName = try #require(Self.assetDirectoryName(for: category))
|
|
let assetURL = try CloudSyncRecordCoding.assetURL(
|
|
recordName: fixture.recordIDs[0].recordName, directoryName: directoryName, cacheRoot: root)
|
|
let record = CKRecord(recordType: "LargePayload", recordID: fixture.recordIDs[0])
|
|
try CloudSyncRecordCoding.setPayload(
|
|
Data(repeating: 0x5a, count: CloudSyncRecordCoding.inlinePayloadLimit + 1),
|
|
on: record, assetURL: assetURL)
|
|
#expect(record["payloadAsset"] is CKAsset)
|
|
#expect(FileManager.default.fileExists(atPath: assetURL.path))
|
|
|
|
let unrelated = root.appending(path: "Unrelated/keep")
|
|
try FileManager.default.createDirectory(
|
|
at: unrelated.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
try Data([1]).write(to: unrelated)
|
|
|
|
let engine = fixture.network.nextEngine
|
|
fixture.lifecycle.handle(
|
|
.fetchedDatabaseChanges(deletions: [(fixture.lifecycle.zoneID, .purged)]), engine: engine,
|
|
receiveRemoteRecords: { try fixture.receive($0, $1) },
|
|
handleSentChanges: { try fixture.sent($0) })
|
|
|
|
#expect(!FileManager.default.fileExists(atPath: assetURL.deletingLastPathComponent().path))
|
|
#expect(FileManager.default.fileExists(atPath: unrelated.path))
|
|
}
|
|
|
|
@Test
|
|
func assetPurgeFailureRetiresSyncAfterDurableProjectionPurge() async throws {
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
let root = directory.appending(path: "Caches")
|
|
let storeURL = directory.appendingPathComponent("AssetCleanup.store")
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let assetURL = try CloudSyncRecordCoding.assetURL(
|
|
recordName: "large/payload",
|
|
directoryName: VaultSyncCoordinator.assetDirectoryName, cacheRoot: root)
|
|
let assetID = CKRecord.ID(recordName: "large/payload")
|
|
let record = CKRecord(recordType: "LargePayload", recordID: assetID)
|
|
try CloudSyncRecordCoding.setPayload(
|
|
Data(repeating: 0x5a, count: CloudSyncRecordCoding.inlinePayloadLimit + 1),
|
|
on: record, assetURL: assetURL)
|
|
var savedScope: CloudSyncScope?
|
|
|
|
do {
|
|
let fixture = try CloudSyncSafetyFixture(
|
|
category: .vault, url: storeURL, assetCacheRoot: root,
|
|
removeAssetDirectory: { _ in throw CocoaError(.fileWriteUnknown) })
|
|
try await fixture.start()
|
|
let records = try fixture.recordIDs.map { try fixture.newerRecord($0) }
|
|
try fixture.receive(records, [])
|
|
#expect(try fixture.projectionIsPopulated())
|
|
let scope = try #require(fixture.lifecycle.scope)
|
|
savedScope = scope
|
|
|
|
let engine = fixture.network.nextEngine
|
|
fixture.lifecycle.handle(
|
|
.fetchedDatabaseChanges(deletions: [(fixture.lifecycle.zoneID, .purged)]), engine: engine,
|
|
receiveRemoteRecords: { try fixture.receive($0, $1) },
|
|
handleSentChanges: { try fixture.sent($0) })
|
|
|
|
#expect(fixture.lifecycle.engine == nil)
|
|
#expect(try fixture.projectionIsCleared())
|
|
#expect(FileManager.default.fileExists(atPath: assetURL.path))
|
|
#expect(fixture.remoteApplyCount == 2)
|
|
#expect(fixture.remoteApplyObservedCleared)
|
|
#expect(try fixture.lifecycle.store.pendingChanges(scope).isEmpty)
|
|
#expect(
|
|
try CloudSyncAssetCleanupStore(context: fixture.container.mainContext).isPending(
|
|
scope: scope, cacheKey: VaultSyncCoordinator.assetDirectoryName))
|
|
fixture.stop()
|
|
}
|
|
|
|
let scope = try #require(savedScope)
|
|
do {
|
|
let failedRestart = try CloudSyncSafetyFixture(
|
|
category: .vault, url: storeURL, seed: false, assetCacheRoot: root,
|
|
removeAssetDirectory: { _ in throw CocoaError(.fileWriteUnknown) })
|
|
failedRestart.lifecycle.start()
|
|
await failedRestart.lifecycle.operation?.value
|
|
#expect(failedRestart.lifecycle.engine == nil)
|
|
#expect(!failedRestart.lifecycle.isReadyToSend)
|
|
let isUpToDate = if case .upToDate = failedRestart.lifecycle.status { true } else { false }
|
|
#expect(!isUpToDate)
|
|
#expect(FileManager.default.fileExists(atPath: assetURL.path))
|
|
#expect(
|
|
try CloudSyncAssetCleanupStore(context: failedRestart.container.mainContext).isPending(
|
|
scope: scope, cacheKey: VaultSyncCoordinator.assetDirectoryName))
|
|
#expect(failedRestart.batch(failedRestart.recordIDs.map { .saveRecord($0) }) == nil)
|
|
failedRestart.stop()
|
|
}
|
|
|
|
let recovered = try CloudSyncSafetyFixture(
|
|
category: .vault, url: storeURL, seed: false, assetCacheRoot: root)
|
|
defer { recovered.stop() }
|
|
let changedBinding = try VaultSyncJournal(context: recovered.container.mainContext).state()
|
|
changedBinding.boundUserRecordName = "previous-account"
|
|
recovered.defaults.set("PreviousEnvironment", forKey: SettingsKey.vaultSyncEnvironment)
|
|
try recovered.container.mainContext.save()
|
|
recovered.lifecycle.start()
|
|
await recovered.lifecycle.operation?.value
|
|
#expect(!FileManager.default.fileExists(atPath: assetURL.deletingLastPathComponent().path))
|
|
#expect(
|
|
try !CloudSyncAssetCleanupStore(context: recovered.container.mainContext).isPending(
|
|
scope: scope, cacheKey: VaultSyncCoordinator.assetDirectoryName))
|
|
#expect(recovered.lifecycle.status == .accountChangeConfirmationRequired)
|
|
#expect(recovered.lifecycle.engine == nil)
|
|
|
|
recovered.lifecycle.confirmAccountSwitchMerge()
|
|
await recovered.lifecycle.operation?.value
|
|
#expect(recovered.lifecycle.isReadyToSend)
|
|
#expect(recovered.batch(recovered.recordIDs.map { .saveRecord($0) }) == nil)
|
|
}
|
|
|
|
@Test
|
|
func vaultPurgeRemovesScopedQuarantineOnlyInActiveScope() async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: .vault)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let scope = try #require(fixture.lifecycle.scope)
|
|
let safety = CloudSyncSafetyStore(context: fixture.container.mainContext, scope: scope)
|
|
let legacyNames = [
|
|
HistoryRecordKey(kind: .connectionHistory, id: UUID()).recordName,
|
|
HistoryRecordKey(kind: .device, id: UUID()).recordName,
|
|
]
|
|
for name in legacyNames {
|
|
try safety.quarantine(
|
|
recordName: name, payloadData: Data(repeating: 7, count: 32), reason: "legacy")
|
|
}
|
|
try fixture.container.mainContext.save()
|
|
for name in legacyNames { #expect(try safety.entry(recordName: name) != nil) }
|
|
|
|
var otherScope = scope
|
|
otherScope.account = "other-account"
|
|
let other = CloudSyncSafetyStore(context: fixture.container.mainContext, scope: otherScope)
|
|
try other.quarantine(recordName: legacyNames[0], payloadData: Data([9]), reason: "other")
|
|
try fixture.container.mainContext.save()
|
|
|
|
let engine = fixture.network.nextEngine
|
|
fixture.lifecycle.handle(
|
|
.fetchedDatabaseChanges(deletions: [(fixture.lifecycle.zoneID, .encryptedDataReset)]), engine: engine,
|
|
receiveRemoteRecords: { try fixture.receive($0, $1) },
|
|
handleSentChanges: { try fixture.sent($0) })
|
|
|
|
for name in legacyNames { #expect(try safety.entry(recordName: name) == nil) }
|
|
#expect(try other.entry(recordName: legacyNames[0])?.payloadData == Data([9]))
|
|
}
|
|
|
|
@Test(arguments: SyncSafetyCategory.allCases)
|
|
func restartAfterHandedOffDeleteKeepsExplicitNewIncarnationsAndTerminalHistory(
|
|
category: SyncSafetyCategory
|
|
) async throws {
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let url = directory.appendingPathComponent("DeleteHandoff.store")
|
|
let recordName: String
|
|
|
|
do {
|
|
let fixture = try CloudSyncSafetyFixture(category: category, url: url)
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
recordName = id.recordName
|
|
try fixture.receive([try fixture.newerRecord(id)], [])
|
|
try fixture.markTombstone()
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
let batch = try #require(fixture.batch([.saveRecord(id), .deleteRecord(id)]))
|
|
#expect(batch.recordsToSave.isEmpty)
|
|
#expect(batch.recordIDsToDelete == [id])
|
|
fixture.stop()
|
|
}
|
|
|
|
let reopened = try CloudSyncSafetyFixture(category: category, url: url, seed: false)
|
|
defer { reopened.stop() }
|
|
let recreated = try reopened.recreateFirst()
|
|
try await reopened.start()
|
|
let id = reopened.recordIDs[0]
|
|
#expect(id.recordName == recordName)
|
|
let journalRecord = try #require(
|
|
reopened.cloudRecords().first { $0.mutation.recordName == recordName })
|
|
|
|
if category == .history {
|
|
#expect(!recreated)
|
|
#expect(journalRecord.mutation.operation == .delete)
|
|
#expect(reopened.record(id) == nil)
|
|
} else {
|
|
#expect(recreated)
|
|
#expect(journalRecord.mutation.operation == .save)
|
|
#expect(journalRecord.needsUpload)
|
|
#expect(!journalRecord.hasSystemFields)
|
|
let batch = try #require(reopened.batch([.saveRecord(id), .deleteRecord(id)]))
|
|
#expect(batch.recordsToSave.map(\.recordID) == [id])
|
|
#expect(batch.recordIDsToDelete.isEmpty)
|
|
}
|
|
}
|
|
|
|
@Test(arguments: [SyncSafetyCategory.vault, .settings, .metrics, .device])
|
|
func staleSaveAndDeleteAcknowledgementsCannotEraseARecreatedIncarnation(
|
|
category: SyncSafetyCategory
|
|
) async throws {
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let url = directory.appendingPathComponent("StaleAcknowledgement.store")
|
|
|
|
do {
|
|
let fixture = try CloudSyncSafetyFixture(category: category, url: url)
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
let delayedSave = try #require(fixture.record(id))
|
|
try fixture.receive([delayedSave], [])
|
|
try fixture.markTombstone()
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
_ = try #require(fixture.batch([.deleteRecord(id)]))
|
|
#expect(try fixture.recreateFirst())
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
try fixture.sent(
|
|
.init(savedRecords: [delayedSave], failedRecordSaves: [], deletedRecordIDs: [id]))
|
|
fixture.stop()
|
|
}
|
|
|
|
let reopened = try CloudSyncSafetyFixture(category: category, url: url, seed: false)
|
|
defer { reopened.stop() }
|
|
try await reopened.start()
|
|
let id = reopened.recordIDs[0]
|
|
let row = try #require(reopened.cloudRecords().first { $0.mutation.recordName == id.recordName })
|
|
#expect(row.mutation.operation == .save)
|
|
#expect(row.needsUpload)
|
|
#expect(reopened.record(id) != nil)
|
|
}
|
|
|
|
@Test(arguments: [SyncSafetyCategory.vault, .settings, .metrics, .device])
|
|
func acknowledgedRecreationMakesLaterEditsSubjectToRealRemoteDeletion(
|
|
category: SyncSafetyCategory
|
|
) async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: category)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
try fixture.receive([try #require(fixture.record(id))], [])
|
|
try fixture.markTombstone()
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
_ = try #require(fixture.batch([.deleteRecord(id)]))
|
|
try fixture.sent(
|
|
.init(
|
|
savedRecords: [], failedRecordSaves: [],
|
|
failedRecordDeletes: [id: CKError(.networkFailure)]))
|
|
|
|
#expect(try fixture.recreateFirst())
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
let recreated = try #require(fixture.batch([.saveRecord(id)]))
|
|
#expect(recreated.recordsToSave.map(\.recordID) == [id])
|
|
#expect(try fixture.editFirstAgain())
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
|
|
try fixture.sent(.init(savedRecords: recreated.recordsToSave, failedRecordSaves: []))
|
|
try fixture.receive([], [id])
|
|
|
|
let row = try #require(fixture.cloudRecords().first { $0.mutation.recordName == id.recordName })
|
|
#expect(row.mutation.operation == .delete)
|
|
#expect(fixture.record(id) == nil)
|
|
#expect(fixture.batch([.saveRecord(id)]) == nil)
|
|
}
|
|
|
|
@Test(arguments: [SyncSafetyCategory.vault, .settings, .metrics, .device])
|
|
func ambiguousDeleteFailureKeepsRecreatedIncarnationAcrossRestart(
|
|
category: SyncSafetyCategory
|
|
) async throws {
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let url = directory.appendingPathComponent("AmbiguousDelete.store")
|
|
|
|
do {
|
|
let fixture = try CloudSyncSafetyFixture(category: category, url: url)
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
try fixture.receive([try #require(fixture.record(id))], [])
|
|
try fixture.markTombstone()
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
_ = try #require(fixture.batch([.deleteRecord(id)]))
|
|
#expect(try fixture.recreateFirst())
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
try fixture.sent(
|
|
.init(
|
|
savedRecords: [], failedRecordSaves: [],
|
|
failedRecordDeletes: [id: CKError(.networkFailure)]))
|
|
fixture.stop()
|
|
}
|
|
|
|
let reopened = try CloudSyncSafetyFixture(category: category, url: url, seed: false)
|
|
defer { reopened.stop() }
|
|
try await reopened.start()
|
|
let id = reopened.recordIDs[0]
|
|
let row = try #require(reopened.cloudRecords().first { $0.mutation.recordName == id.recordName })
|
|
#expect(row.mutation.operation == .save)
|
|
#expect(row.needsUpload)
|
|
#expect(reopened.record(id) != nil)
|
|
}
|
|
|
|
@Test(arguments: SyncSafetyCategory.allCases)
|
|
func incrementalDeletionConfirmsOnlyTheHandedOffIncarnation(category: SyncSafetyCategory) async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: category)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
try fixture.receive([try #require(fixture.record(id))], [])
|
|
try fixture.markTombstone()
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
_ = try #require(fixture.batch([.deleteRecord(id)]))
|
|
let recreated = try fixture.recreateFirst()
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
|
|
try fixture.receive([], [id])
|
|
try fixture.receive([], [id])
|
|
let row = try #require(fixture.cloudRecords().first { $0.mutation.recordName == id.recordName })
|
|
if category == .history {
|
|
#expect(!recreated)
|
|
#expect(row.mutation.operation == .delete)
|
|
#expect(fixture.record(id) == nil)
|
|
} else {
|
|
#expect(recreated)
|
|
#expect(row.mutation.operation == .save)
|
|
#expect(row.needsUpload)
|
|
#expect(fixture.record(id) != nil)
|
|
}
|
|
}
|
|
|
|
@Test(arguments: SyncSafetyCategory.allCases)
|
|
func confirmedTombstoneReobservationSchedulesAnotherPhysicalDelete(
|
|
category: SyncSafetyCategory
|
|
) async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: category)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
let tombstone = try Self.tombstone(try #require(fixture.record(id)))
|
|
try fixture.receive([tombstone], [])
|
|
let first = try #require(fixture.batch([.deleteRecord(id)]))
|
|
try fixture.sent(
|
|
.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: first.recordIDsToDelete))
|
|
#expect(fixture.batch([.deleteRecord(id)]) == nil)
|
|
|
|
try fixture.receive([tombstone], [])
|
|
let retry = try #require(fixture.batch([.deleteRecord(id)]))
|
|
#expect(retry.recordIDsToDelete == [id])
|
|
}
|
|
|
|
@Test
|
|
func unknownItemForOldSettingsSavePreservesRecreatedIncarnationAcrossRestart() async throws {
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let url = directory.appendingPathComponent("UnknownItem.store")
|
|
|
|
do {
|
|
let fixture = try CloudSyncSafetyFixture(category: .settings, url: url)
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
let delayedSave = try #require(fixture.record(id))
|
|
try fixture.receive([delayedSave], [])
|
|
try fixture.markTombstone()
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
_ = try #require(fixture.batch([.deleteRecord(id)]))
|
|
#expect(try fixture.recreateFirst())
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
try fixture.sent(
|
|
.init(
|
|
savedRecords: [],
|
|
failedRecordSaves: [.init(record: delayedSave, error: CKError(.unknownItem))]))
|
|
fixture.stop()
|
|
}
|
|
|
|
let reopened = try CloudSyncSafetyFixture(category: .settings, url: url, seed: false)
|
|
defer { reopened.stop() }
|
|
try await reopened.start()
|
|
let id = reopened.recordIDs[0]
|
|
let row = try #require(reopened.cloudRecords().first { $0.mutation.recordName == id.recordName })
|
|
#expect(row.mutation.operation == .save)
|
|
#expect(row.needsUpload)
|
|
#expect(reopened.record(id) != nil)
|
|
}
|
|
|
|
@Test(arguments: [false, true])
|
|
func oldLiveAfterIncrementalDeleteCannotResetNewIncarnationEvidence(
|
|
asSaveAcknowledgement: Bool
|
|
) async throws {
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let url = directory.appendingPathComponent("OldLiveAfterDelete.store")
|
|
|
|
do {
|
|
let fixture = try CloudSyncSafetyFixture(category: .settings, url: url)
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
let oldLive = try #require(fixture.record(id))
|
|
try fixture.receive([oldLive], [])
|
|
try fixture.markTombstone()
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
_ = try #require(fixture.batch([.deleteRecord(id)]))
|
|
#expect(try fixture.recreateFirst())
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
try fixture.receive([], [id])
|
|
if asSaveAcknowledgement {
|
|
try fixture.sent(.init(savedRecords: [oldLive], failedRecordSaves: []))
|
|
} else {
|
|
try fixture.receive([oldLive], [])
|
|
}
|
|
fixture.stop()
|
|
}
|
|
|
|
let reopened = try CloudSyncSafetyFixture(category: .settings, url: url, seed: false)
|
|
defer { reopened.stop() }
|
|
try await reopened.start()
|
|
let id = reopened.recordIDs[0]
|
|
let row = try #require(reopened.cloudRecords().first { $0.mutation.recordName == id.recordName })
|
|
#expect(row.mutation.operation == .save)
|
|
#expect(row.needsUpload)
|
|
#expect(reopened.record(id) != nil)
|
|
}
|
|
|
|
@Test(arguments: SyncSafetyCategory.allCases)
|
|
func remoteDeletionCancelsPendingUpdatesAndSurvivesRestart(category: SyncSafetyCategory) async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: category)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
let delayed = try #require(fixture.record(id))
|
|
|
|
try fixture.receive([delayed], [id])
|
|
try fixture.receive([], [id])
|
|
|
|
let scope = try #require(fixture.lifecycle.scope)
|
|
#expect(
|
|
try fixture.cloudRecords().first { $0.mutation.recordName == id.recordName }?.mutation.operation == .delete)
|
|
#expect(try !fixture.lifecycle.store.pendingRecordNames(scope).contains(id.recordName))
|
|
#expect(fixture.batch([.saveRecord(id), .deleteRecord(id)]) == nil)
|
|
#expect(fixture.record(id) == nil)
|
|
#expect(fixture.remoteApplyCount == 1)
|
|
fixture.lifecycle.pause()
|
|
fixture.network.nextEngine = LifecycleEngine()
|
|
try await fixture.start()
|
|
#expect(fixture.record(id) == nil)
|
|
#expect(try !fixture.lifecycle.store.pendingRecordNames(scope).contains(id.recordName))
|
|
}
|
|
|
|
@Test(arguments: SyncSafetyCategory.allCases)
|
|
func localDeletionUsesPhysicalBatchAndOnlyAcknowledgesTheSentDelete(category: SyncSafetyCategory) async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: category)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
try fixture.markTombstone()
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
let batch = try #require(fixture.batch([.saveRecord(id), .deleteRecord(id)]))
|
|
#expect(batch.recordsToSave.isEmpty)
|
|
#expect(batch.recordIDsToDelete == [id])
|
|
#expect(fixture.record(id) == nil)
|
|
#expect(fixture.batch([.deleteRecord(id)]) == nil)
|
|
|
|
try fixture.sent(
|
|
.init(savedRecords: [], failedRecordSaves: [], failedRecordDeletes: [id: CKError(.networkFailure)]))
|
|
let scope = try #require(fixture.lifecycle.scope)
|
|
#expect(try fixture.lifecycle.store.pendingRecordNames(scope).contains(id.recordName))
|
|
_ = try #require(fixture.batch([.deleteRecord(id)]))
|
|
try fixture.sent(
|
|
.init(savedRecords: [], failedRecordSaves: [], failedRecordDeletes: [id: CKError(.unknownItem)]))
|
|
#expect(try !fixture.lifecycle.store.pendingRecordNames(scope).contains(id.recordName))
|
|
try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id]))
|
|
#expect(fixture.batch([.deleteRecord(id)]) == nil)
|
|
}
|
|
|
|
/// A failed (or unreported) send must not strand the physical delete; with
|
|
/// overlapping sends the release waits for the last completion.
|
|
@Test(arguments: SyncSafetyCategory.allCases, [false, true])
|
|
func failedSendsReleasePhysicalDeleteForRetry(
|
|
category: SyncSafetyCategory, overlapping: Bool
|
|
) async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: category)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
try fixture.markTombstone()
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
try fixture.lifecycle.beginSend()
|
|
if overlapping { try fixture.lifecycle.beginSend() }
|
|
_ = try #require(fixture.batch([.deleteRecord(id)]))
|
|
#expect(fixture.batch([.deleteRecord(id)]) == nil)
|
|
|
|
try fixture.lifecycle.finishSend(succeeded: false)
|
|
|
|
if overlapping {
|
|
#expect(fixture.batch([.deleteRecord(id)]) == nil)
|
|
try fixture.lifecycle.finishSend(succeeded: false)
|
|
}
|
|
let retry = try #require(fixture.batch([.deleteRecord(id)]))
|
|
#expect(retry.recordIDsToDelete == [id])
|
|
}
|
|
|
|
@Test(arguments: SyncSafetyCategory.allCases)
|
|
func lateSaveAcknowledgementCannotUndoDeletion(category: SyncSafetyCategory) async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: category)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
let delayedSave = try #require(fixture.record(id))
|
|
try fixture.receive([], [id])
|
|
try fixture.sent(.init(savedRecords: [delayedSave], failedRecordSaves: []))
|
|
#expect(fixture.record(id) == nil)
|
|
let batch = try #require(fixture.batch([.deleteRecord(id)]))
|
|
#expect(batch.recordIDsToDelete == [id])
|
|
try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id]))
|
|
#expect(fixture.batch([.deleteRecord(id)]) == nil)
|
|
}
|
|
|
|
@Test(arguments: SyncSafetyCategory.allCases)
|
|
func fetchedRecordObservedAfterDeleteHandoffRequiresAnotherPhysicalDelete(
|
|
category: SyncSafetyCategory
|
|
) async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: category)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
let delayedRecord = try #require(fixture.record(id))
|
|
try fixture.markTombstone()
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
let firstDelete = try #require(fixture.batch([.deleteRecord(id)]))
|
|
#expect(firstDelete.recordIDsToDelete == [id])
|
|
|
|
try fixture.receive([delayedRecord], [])
|
|
try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id]))
|
|
#expect(fixture.record(id) == nil)
|
|
let scope = try #require(fixture.lifecycle.scope)
|
|
#expect(try fixture.lifecycle.store.pendingRecordNames(scope).contains(id.recordName))
|
|
|
|
// A replayed acknowledgement has no handed-off operation to acknowledge.
|
|
try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id]))
|
|
#expect(try fixture.lifecycle.store.pendingRecordNames(scope).contains(id.recordName))
|
|
fixture.lifecycle.pause()
|
|
fixture.network.nextEngine = LifecycleEngine()
|
|
fixture.network.nextEngine.fetchBody = { try fixture.receive([delayedRecord], []) }
|
|
try await fixture.start()
|
|
let retry = try #require(fixture.batch([.deleteRecord(id)]))
|
|
#expect(retry.recordIDsToDelete == [id])
|
|
try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id]))
|
|
#expect(fixture.batch([.deleteRecord(id)]) == nil)
|
|
}
|
|
|
|
@Test(arguments: SyncSafetyCategory.allCases)
|
|
func fetchedDeletionWinsOverSameBatchRecordDuringDeleteHandoff(
|
|
category: SyncSafetyCategory
|
|
) async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: category)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
let staleRecord = try #require(fixture.record(id))
|
|
try fixture.markTombstone()
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
_ = try #require(fixture.batch([.deleteRecord(id)]))
|
|
|
|
try fixture.receive([staleRecord], [id])
|
|
try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id]))
|
|
|
|
#expect(fixture.record(id) == nil)
|
|
#expect(fixture.batch([.deleteRecord(id)]) == nil)
|
|
}
|
|
|
|
@Test(arguments: SyncSafetyCategory.allCases)
|
|
func savedRecordObservedAfterDeleteHandoffRequiresAnotherPhysicalDelete(
|
|
category: SyncSafetyCategory
|
|
) async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: category)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
let delayedSave = try #require(fixture.record(id))
|
|
try fixture.markTombstone()
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
_ = try #require(fixture.batch([.deleteRecord(id)]))
|
|
|
|
try fixture.sent(.init(savedRecords: [delayedSave], failedRecordSaves: []))
|
|
try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id]))
|
|
#expect(fixture.record(id) == nil)
|
|
let retry = try #require(fixture.batch([.deleteRecord(id)]))
|
|
#expect(retry.recordIDsToDelete == [id])
|
|
try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id]))
|
|
#expect(fixture.batch([.deleteRecord(id)]) == nil)
|
|
}
|
|
|
|
@Test(arguments: SyncSafetyCategory.allCases)
|
|
func olderAndEqualLiveRecordsCannotReviveAConfirmedPhysicalDeletion(
|
|
category: SyncSafetyCategory
|
|
) async throws {
|
|
for (useRemoteDeletion, useEqualVersion) in [
|
|
(false, false), (false, true), (true, false), (true, true),
|
|
] {
|
|
let fixture = try CloudSyncSafetyFixture(category: category)
|
|
defer { fixture.stop() }
|
|
let id = fixture.recordIDs[0]
|
|
try await fixture.start()
|
|
let original = try #require(fixture.record(id))
|
|
if useRemoteDeletion {
|
|
try fixture.receive([], [id])
|
|
} else {
|
|
try fixture.markTombstone()
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
_ = try #require(fixture.batch([.deleteRecord(id)]))
|
|
try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id]))
|
|
}
|
|
let deletion = try #require(
|
|
fixture.cloudRecords().first { $0.mutation.recordName == id.recordName }?.mutation)
|
|
|
|
let delayed =
|
|
try useEqualVersion
|
|
? Self.liveRecord(original, version: deletion.version)
|
|
: fixture.olderRecord(id)
|
|
try fixture.receive([delayed], [])
|
|
let local = try #require(
|
|
fixture.cloudRecords().first { $0.mutation.recordName == id.recordName })
|
|
#expect(local.mutation.operation == .delete)
|
|
#expect(try fixture.pendingNames().contains(id.recordName))
|
|
// An ID-only result that has no handed-off delete cannot clear this retry.
|
|
try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id]))
|
|
#expect(try fixture.pendingNames().contains(id.recordName))
|
|
let retry = try #require(fixture.batch([.deleteRecord(id)]))
|
|
#expect(retry.recordIDsToDelete == [id])
|
|
try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id]))
|
|
}
|
|
}
|
|
|
|
@Test(arguments: SyncSafetyCategory.allCases)
|
|
func trulyNewerLiveRecordFollowsTheCategoryProtocolAfterConfirmedDeletion(
|
|
category: SyncSafetyCategory
|
|
) async throws {
|
|
for useRemoteDeletion in [false, true] {
|
|
let fixture = try CloudSyncSafetyFixture(category: category)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
let original = try #require(fixture.record(id))
|
|
if useRemoteDeletion {
|
|
try fixture.receive([], [id])
|
|
} else {
|
|
try fixture.markTombstone()
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
_ = try #require(fixture.batch([.deleteRecord(id)]))
|
|
try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id]))
|
|
}
|
|
let deletion = try #require(
|
|
fixture.cloudRecords().first { $0.mutation.recordName == id.recordName }?.mutation)
|
|
var newerVersion = deletion.version
|
|
newerVersion.counter += 1
|
|
|
|
try fixture.receive([try Self.liveRecord(original, version: newerVersion)], [])
|
|
|
|
let local = try #require(fixture.cloudRecords().first { $0.mutation.recordName == id.recordName })
|
|
if category == .history {
|
|
#expect(local.mutation.operation == .delete)
|
|
#expect(try fixture.pendingNames().contains(id.recordName))
|
|
} else {
|
|
#expect(local.mutation.operation == .save)
|
|
#expect(try !fixture.pendingNames().contains(id.recordName))
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test(arguments: SyncSafetyCategory.allCases)
|
|
func quarantinedObservationReleasesCompletedDeleteBeforeSupportedRecovery(
|
|
category: SyncSafetyCategory
|
|
) async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: category)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
let supported = try #require(fixture.record(id))
|
|
let future = try fixture.futureRecord()
|
|
try fixture.markTombstone()
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
_ = try #require(fixture.batch([.deleteRecord(id)]))
|
|
|
|
try fixture.receive([future], [])
|
|
#expect(try fixture.safety().isUploadBlocked(recordName: id.recordName))
|
|
try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id]))
|
|
try fixture.receive([supported], [])
|
|
#expect(try !fixture.safety().isUploadBlocked(recordName: id.recordName))
|
|
let retry = try #require(fixture.batch([.deleteRecord(id)]))
|
|
#expect(retry.recordIDsToDelete == [id])
|
|
}
|
|
|
|
@Test(arguments: SyncSafetyCategory.allCases)
|
|
func oldCloudTombstoneIsDeletedEvenWhenItsJournalWasAcknowledged(category: SyncSafetyCategory) async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: category)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
let tombstone = try Self.tombstone(try #require(fixture.record(id)))
|
|
try fixture.receive([tombstone], [])
|
|
#expect(try !fixture.pendingNames().contains(id.recordName))
|
|
#expect(fixture.record(id) == nil)
|
|
let batch = try #require(fixture.batch([.deleteRecord(id)]))
|
|
#expect(batch.recordIDsToDelete == [id])
|
|
try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id]))
|
|
fixture.lifecycle.pause()
|
|
fixture.network.nextEngine = LifecycleEngine()
|
|
try await fixture.start()
|
|
#expect(fixture.batch([.saveRecord(id), .deleteRecord(id)]) == nil)
|
|
}
|
|
|
|
@Test(arguments: SyncSafetyCategory.allCases)
|
|
func fullRefetchRemovesPreviouslySyncedMissingRecordsAndKeepsNewLocalRecords(category: SyncSafetyCategory)
|
|
async throws
|
|
{
|
|
let fixture = try CloudSyncSafetyFixture(category: category)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let synced = fixture.recordIDs[0]
|
|
let newLocal = fixture.recordIDs[1]
|
|
try fixture.receive([try #require(fixture.record(synced))], [])
|
|
fixture.lifecycle.pause()
|
|
fixture.network.nextEngine = LifecycleEngine()
|
|
try await fixture.start()
|
|
|
|
#expect(fixture.record(synced) == nil)
|
|
#expect(
|
|
try fixture.cloudRecords().first { $0.mutation.recordName == synced.recordName }?.mutation.operation
|
|
== .delete)
|
|
#expect(fixture.record(newLocal) != nil)
|
|
#expect(fixture.batch([.saveRecord(synced), .deleteRecord(synced)]) == nil)
|
|
}
|
|
|
|
@Test(arguments: SyncSafetyCategory.allCases)
|
|
func failedFullFetchDoesNotInferDeletionsFromAnIncompleteInventory(category: SyncSafetyCategory) async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: category)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let records = try fixture.recordIDs.map { try #require(fixture.record($0)) }
|
|
try fixture.receive(records, [])
|
|
fixture.lifecycle.pause()
|
|
fixture.network.nextEngine = LifecycleEngine()
|
|
fixture.network.nextEngine.fetchBody = {
|
|
try fixture.receive([records[0]], [])
|
|
throw CKError(.networkFailure)
|
|
}
|
|
fixture.lifecycle.start()
|
|
await fixture.lifecycle.operation?.value
|
|
#expect(!fixture.lifecycle.isReadyToSend)
|
|
#expect(try fixture.cloudRecords().allSatisfy { $0.mutation.operation == .save })
|
|
}
|
|
|
|
@Test(arguments: SyncSafetyCategory.allCases)
|
|
func unknownItemForAnExistingRecordAppliesRemoteDeletion(category: SyncSafetyCategory) async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: category)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
let record = try #require(fixture.record(id))
|
|
try fixture.receive([record], [])
|
|
try fixture.sent(
|
|
.init(savedRecords: [], failedRecordSaves: [.init(record: record, error: CKError(.unknownItem))]))
|
|
#expect(
|
|
try fixture.cloudRecords().first { $0.mutation.recordName == id.recordName }?.mutation.operation == .delete)
|
|
#expect(fixture.batch([.saveRecord(id), .deleteRecord(id)]) == nil)
|
|
}
|
|
|
|
@Test
|
|
func historyRetentionAndClearUseDeletesWithoutTouchingDevices() async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: .history)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let zone = fixture.lifecycle.zoneID
|
|
let device = DeviceRecord(id: UUID(), displayName: "Device")
|
|
fixture.container.mainContext.insert(PersistentDeviceRecord(device))
|
|
let records = try (0..<300).map { index in
|
|
let entry = ConnectionHistoryEntry(
|
|
hostID: UUID(), kind: .ssh, connectedAt: Date.now.addingTimeInterval(Double(index + 1)))
|
|
let envelope = HistoryEnvelope(
|
|
key: .init(kind: .connectionHistory, id: entry.id),
|
|
version: .baseline(writerID: UUID()), payload: .connectionHistory(entry))
|
|
return try HistoryRecordSyncCoordinator.makeRecord(
|
|
.init(envelope: envelope),
|
|
recordID: .init(recordName: envelope.key.recordName, zoneID: zone))
|
|
}
|
|
try fixture.receive(records, [])
|
|
#expect(
|
|
try fixture.container.mainContext.fetchCount(FetchDescriptor<PersistentConnectionHistoryEntry>()) == 250)
|
|
var server = Set(records.map(\.recordID))
|
|
while let batch = fixture.batch(fixture.network.nextEngine.pendingChanges) {
|
|
#expect(batch.recordsToSave.count + batch.recordIDsToDelete.count <= 250)
|
|
#expect(
|
|
batch.recordIDsToDelete.allSatisfy {
|
|
HistoryRecordKey(recordName: $0.recordName)?.kind == .connectionHistory
|
|
})
|
|
server.subtract(batch.recordIDsToDelete)
|
|
try fixture.sent(
|
|
.init(
|
|
savedRecords: batch.recordsToSave, failedRecordSaves: [], deletedRecordIDs: batch.recordIDsToDelete)
|
|
)
|
|
}
|
|
#expect(server.count == 250)
|
|
try SwiftDataConnectionHistoryRepository(context: fixture.container.mainContext).clearAll()
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
while let batch = fixture.batch(fixture.network.nextEngine.pendingChanges) {
|
|
server.subtract(batch.recordIDsToDelete)
|
|
try fixture.sent(
|
|
.init(
|
|
savedRecords: batch.recordsToSave, failedRecordSaves: [], deletedRecordIDs: batch.recordIDsToDelete)
|
|
)
|
|
}
|
|
#expect(server.isEmpty)
|
|
#expect(try fixture.container.mainContext.fetchCount(FetchDescriptor<PersistentDeviceRecord>()) == 1)
|
|
}
|
|
|
|
@Test
|
|
func manualHistoryDeletionAndLocalOverflowBothScheduleCloudDeletes() async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: .history)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let context = fixture.container.mainContext
|
|
let repository = SwiftDataConnectionHistoryRepository(context: context)
|
|
repository.didRecordHistoryChanges = { fixture.lifecycle.noteLocalChanges($0.keys.map(\.recordName)) }
|
|
let firstID = fixture.recordIDs[0]
|
|
let firstKey = try #require(HistoryRecordKey(recordName: firstID.recordName))
|
|
try repository.delete(firstKey.id)
|
|
let manual = try #require(fixture.batch([.deleteRecord(firstID)]))
|
|
#expect(manual.recordIDsToDelete == [firstID])
|
|
try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [firstID]))
|
|
|
|
let oldID = fixture.recordIDs[1]
|
|
let host = UUID()
|
|
for index in 0..<ConnectionHistoryModel.limit {
|
|
try repository.insert(
|
|
ConnectionHistoryEntry(
|
|
hostID: host, kind: .sftp, connectedAt: Date.now.addingTimeInterval(Double(index + 1))),
|
|
limit: ConnectionHistoryModel.limit)
|
|
}
|
|
let overflow = try #require(fixture.batch([.deleteRecord(oldID)]))
|
|
#expect(overflow.recordIDsToDelete == [oldID])
|
|
#expect(try repository.entries().count == 250)
|
|
try repository.clear(for: host)
|
|
#expect(try repository.entries().isEmpty)
|
|
let scope = try #require(fixture.lifecycle.scope)
|
|
#expect(try fixture.lifecycle.store.pendingChanges(scope).allSatisfy { $0.operation == .delete })
|
|
}
|
|
|
|
@Test
|
|
func obsoleteVaultCopiesAreIgnoredWithoutImportingOrApplyingTheirDeletionEvents() async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: .vault)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let context = fixture.container.mainContext
|
|
let event = ConnectionHistoryEntry(hostID: UUID(), kind: .ssh)
|
|
let device = DeviceRecord(id: UUID(), displayName: "Current device")
|
|
let native = HistorySyncPersistence(context: context)
|
|
let version = VaultVersion.baseline(writerID: UUID())
|
|
let payloads: [HistoryRecordPayload] = [.connectionHistory(event), .device(device)]
|
|
_ = try native.applyRemote(payloads.map { HistoryEnvelope(key: $0.key, version: version, payload: $0) })
|
|
let oldRecords = payloads.map { payload in
|
|
let record = CKRecord(
|
|
recordType: VaultSyncCoordinator.recordType,
|
|
recordID: .init(recordName: payload.key.recordName, zoneID: fixture.lifecycle.zoneID))
|
|
// Old copies are obsolete even if their payload is unreadable to the new decoder.
|
|
record["payload"] = Data([0xff]) as NSData
|
|
return record
|
|
}
|
|
let safety = try fixture.safety()
|
|
for record in oldRecords {
|
|
try safety.quarantine(
|
|
recordName: record.recordID.recordName, payloadData: Data([0xff]), reason: "Retired copy")
|
|
}
|
|
try context.save()
|
|
try fixture.receive(oldRecords, [])
|
|
try fixture.receive([], oldRecords.map(\.recordID))
|
|
#expect(try context.fetchCount(FetchDescriptor<PersistentConnectionHistoryEntry>()) == 1)
|
|
#expect(try context.fetchCount(FetchDescriptor<PersistentDeviceRecord>()) == 1)
|
|
#expect(fixture.remoteApplyCount == 0)
|
|
#expect(try safety.blockedRecordNames() == Set(oldRecords.map { $0.recordID.recordName }))
|
|
#expect(fixture.batch(oldRecords.map { .deleteRecord($0.recordID) }) == nil)
|
|
#expect(fixture.batch(oldRecords.map { .saveRecord($0.recordID) }) == nil)
|
|
try fixture.receive(oldRecords, oldRecords.map(\.recordID))
|
|
#expect(fixture.batch(oldRecords.map { .deleteRecord($0.recordID) }) == nil)
|
|
|
|
let activeZone = CKRecordZone.ID(zoneName: ConnectionHistorySyncCoordinator.zoneName)
|
|
let active = try payloads.map { payload in
|
|
try HistoryRecordSyncCoordinator.makeRecord(
|
|
.init(envelope: .init(key: payload.key, version: version, payload: payload)),
|
|
recordID: .init(recordName: payload.key.recordName, zoneID: activeZone))
|
|
}
|
|
try fixture.receive(active, active.map(\.recordID))
|
|
#expect(fixture.batch(active.map { .deleteRecord($0.recordID) }) == nil)
|
|
#expect(try context.fetchCount(FetchDescriptor<PersistentConnectionHistoryEntry>()) == 1)
|
|
#expect(try context.fetchCount(FetchDescriptor<PersistentDeviceRecord>()) == 1)
|
|
}
|
|
|
|
@Test
|
|
func oldVaultHistoryAndDeviceCopiesAndQueuedDeletesStaySkippedAfterRestart() async throws {
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let url = directory.appendingPathComponent("LegacyCopies.store")
|
|
var savedScope: CloudSyncScope?
|
|
var recordNames = Set<String>()
|
|
|
|
do {
|
|
let fixture = try CloudSyncSafetyFixture(category: .vault, url: url)
|
|
try await fixture.start()
|
|
let context = fixture.container.mainContext
|
|
let cases: [(HistoryRecordPayload, Bool)] = [
|
|
(.connectionHistory(ConnectionHistoryEntry(hostID: UUID(), kind: .ssh)), false),
|
|
(.connectionHistory(ConnectionHistoryEntry(hostID: UUID(), kind: .sftp)), true),
|
|
(.device(DeviceRecord(id: UUID(), displayName: "Live legacy device")), false),
|
|
(.device(DeviceRecord(id: UUID(), displayName: "Deleted legacy device")), true),
|
|
]
|
|
let version = VaultVersion.baseline(writerID: UUID())
|
|
let current = cases.map { HistoryEnvelope(key: $0.0.key, version: version, payload: $0.0) }
|
|
_ = try HistorySyncPersistence(context: context).applyRemote(current)
|
|
let oldRecords = try cases.map { payload, isDeleted in
|
|
let oldPayload: VaultRecordPayload
|
|
switch payload {
|
|
case .connectionHistory(let value): oldPayload = .connectionHistory(value)
|
|
case .device(let value): oldPayload = .device(value)
|
|
}
|
|
let envelope = VaultEnvelope(
|
|
key: oldPayload.key, version: version, isDeleted: isDeleted,
|
|
payload: isDeleted ? nil : oldPayload)
|
|
return try VaultSyncCoordinator.makeRecord(
|
|
.init(envelope: envelope),
|
|
recordID: .init(recordName: envelope.key.recordName, zoneID: fixture.lifecycle.zoneID))
|
|
}
|
|
recordNames = Set(oldRecords.map(\.recordID.recordName))
|
|
let scope = try #require(fixture.lifecycle.scope)
|
|
savedScope = scope
|
|
for name in recordNames {
|
|
let row = PersistentCloudSyncRecordState(scope: scope, recordName: name)
|
|
row.wasPresent = true
|
|
row.legacyDeletion = try JSONEncoder().encode(version)
|
|
context.insert(row)
|
|
}
|
|
try context.save()
|
|
let replica = try #require(fixture.lifecycle.store.replica)
|
|
let missing = try replica.missing(scope, [])
|
|
#expect(recordNames.isDisjoint(with: missing.deleted))
|
|
|
|
try fixture.receive(oldRecords, [])
|
|
try fixture.receive([], oldRecords.map(\.recordID))
|
|
#expect(try context.fetchCount(FetchDescriptor<PersistentConnectionHistoryEntry>()) == 2)
|
|
#expect(try context.fetchCount(FetchDescriptor<PersistentDeviceRecord>()) == 2)
|
|
#expect(fixture.remoteApplyCount == 0)
|
|
#expect(fixture.batch(oldRecords.map { .deleteRecord($0.recordID) }) == nil)
|
|
#expect(fixture.batch(oldRecords.map { .saveRecord($0.recordID) }) == nil)
|
|
fixture.lifecycle.pause()
|
|
fixture.network.nextEngine = LifecycleEngine()
|
|
try await fixture.start()
|
|
#expect(fixture.batch(oldRecords.map { .deleteRecord($0.recordID) }) == nil)
|
|
#expect(
|
|
try fixture.lifecycle.store.pendingChanges(scope).allSatisfy {
|
|
!recordNames.contains($0.recordName)
|
|
})
|
|
fixture.stop()
|
|
}
|
|
|
|
let scope = try #require(savedScope)
|
|
let reopened = try TermindPersistence.makeContainer(url: url)
|
|
let records = try VaultSyncJournal(context: reopened.mainContext).cloudRecords()
|
|
let pending = try CloudSyncRecordStateStore(context: reopened.mainContext, scope: scope).pendingChanges(records)
|
|
#expect(recordNames.isDisjoint(with: pending.map(\.recordName)))
|
|
}
|
|
|
|
@Test
|
|
func ignoredVaultCopiesDoNotClearQuarantineOrSendOldDeletes() async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: .vault)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let vaultZone = fixture.lifecycle.zoneID
|
|
let wrongTypeID = CKRecord.ID(
|
|
recordName: HistoryRecordKey(kind: .device, id: UUID()).recordName, zoneID: vaultZone)
|
|
let wrongZoneID = CKRecord.ID(
|
|
recordName: HistoryRecordKey(kind: .connectionHistory, id: UUID()).recordName,
|
|
zoneID: .init(zoneName: ConnectionHistorySyncCoordinator.zoneName))
|
|
let noncanonicalUUID = UUID().uuidString.uppercased()
|
|
let noncanonicalID = CKRecord.ID(recordName: "v1/device/\(noncanonicalUUID)", zoneID: vaultZone)
|
|
let queuedCanonicalID = CKRecord.ID(
|
|
recordName: HistoryRecordKey(kind: .device, id: UUID()).recordName, zoneID: vaultZone)
|
|
let records = [
|
|
CKRecord(recordType: "DeviceItem", recordID: wrongTypeID),
|
|
CKRecord(recordType: VaultSyncCoordinator.recordType, recordID: wrongZoneID),
|
|
CKRecord(recordType: VaultSyncCoordinator.recordType, recordID: noncanonicalID),
|
|
]
|
|
let safety = try fixture.safety()
|
|
for record in records {
|
|
try safety.quarantine(recordName: record.recordID.recordName, payloadData: Data([1]), reason: "Keep")
|
|
}
|
|
try safety.quarantine(recordName: queuedCanonicalID.recordName, payloadData: Data([2]), reason: "Obsolete")
|
|
let scope = try #require(fixture.lifecycle.scope)
|
|
let row = PersistentCloudSyncRecordState(scope: scope, recordName: queuedCanonicalID.recordName)
|
|
row.legacyDeletion = try JSONEncoder().encode(VaultVersion.baseline(writerID: UUID()))
|
|
fixture.container.mainContext.insert(row)
|
|
try fixture.container.mainContext.save()
|
|
|
|
try fixture.receive(records, [])
|
|
try fixture.lifecycle.reconcilePendingChanges()
|
|
|
|
#expect(
|
|
try safety.blockedRecordNames()
|
|
== Set(records.map(\.recordID.recordName) + [queuedCanonicalID.recordName]))
|
|
let pending =
|
|
records.map { CKSyncEngine.PendingRecordZoneChange.deleteRecord($0.recordID) }
|
|
+ [.deleteRecord(queuedCanonicalID)]
|
|
#expect(fixture.batch(pending) == nil)
|
|
}
|
|
|
|
@Test
|
|
func deviceLaunchRegistrationDoesNotRecreateADeletedDeviceButRenameCan() async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: .device)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
let key = try #require(HistoryRecordKey(recordName: id.recordName))
|
|
try fixture.receive([], [id])
|
|
let model = DeviceDirectoryModel(
|
|
repository: SwiftDataDeviceDirectoryRepository(context: fixture.container.mainContext),
|
|
currentDeviceID: key.id, currentDeviceName: "Deleted device")
|
|
#expect(!model.devices().contains { $0.id == key.id })
|
|
#expect(fixture.record(id) == nil)
|
|
model.updateCurrentDeviceName("Explicit rename")
|
|
#expect(model.devices().contains { $0.id == key.id && $0.displayName == "Explicit rename" })
|
|
#expect(fixture.record(id) != nil)
|
|
}
|
|
|
|
@Test
|
|
func settingsRecreationWaitsForInFlightDeletionAndSurvivesItsAcknowledgement() async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: .settings)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let id = fixture.recordIDs[0]
|
|
let key = try #require(SettingsRecordKey(recordName: id.recordName))
|
|
let context = fixture.container.mainContext
|
|
let journal = SettingsSyncJournal(context: context)
|
|
let saved = try #require(fixture.record(id))
|
|
try fixture.receive([saved], [])
|
|
try fixture.markTombstone()
|
|
_ = try #require(fixture.batch([.deleteRecord(id)]))
|
|
var replacement = try SettingsSyncCoordinator.envelope(from: saved)
|
|
replacement.version = .next(after: try journal.envelope(for: key)?.version, writerID: UUID())
|
|
try journal.acceptRemote([replacement])
|
|
try journal.markNeedsUpload([key])
|
|
try context.save()
|
|
#expect(fixture.batch([.saveRecord(id)]) == nil)
|
|
try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id]))
|
|
let scope = try #require(fixture.lifecycle.scope)
|
|
#expect(
|
|
try fixture.lifecycle.store.pendingChanges(scope).contains {
|
|
$0.recordName == id.recordName && $0.operation == .save && $0.version == replacement.version
|
|
})
|
|
#expect(try !CloudSyncRecordStateStore(context: context, scope: scope).wasPresent(id.recordName))
|
|
#expect(fixture.batch([.saveRecord(id)])?.recordsToSave.count == 1)
|
|
}
|
|
|
|
@Test(arguments: SyncSafetyCategory.allCases)
|
|
func version36MigrationPhysicallyDeletesConfirmedTombstonesAcrossEveryCategory(
|
|
category: SyncSafetyCategory
|
|
) async throws {
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let url = directory.appendingPathComponent("Migration.store")
|
|
let tombstone = try Self.writeVersion36Tombstone(url: url, category: category)
|
|
let scope: CloudSyncScope
|
|
do {
|
|
let fixture = try MigratedPhysicalDeletionFixture(category: category, url: url)
|
|
try await fixture.start()
|
|
scope = try #require(fixture.lifecycle.scope)
|
|
#expect(
|
|
try fixture.lifecycle.store.pendingChanges(scope)
|
|
== [tombstone])
|
|
let id = CKRecord.ID(recordName: tombstone.recordName, zoneID: fixture.lifecycle.zoneID)
|
|
let batch = try #require(fixture.batch([.deleteRecord(id)]))
|
|
#expect(batch.recordsToSave.isEmpty)
|
|
#expect(batch.recordIDsToDelete == [id])
|
|
try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id]))
|
|
#expect(try fixture.lifecycle.store.pendingChanges(scope).isEmpty)
|
|
let acknowledgedRecords = try fixture.cloudRecords()
|
|
#expect(acknowledgedRecords.contains { $0.mutation == tombstone && !$0.needsUpload })
|
|
fixture.stop()
|
|
}
|
|
|
|
let reopened = try TermindPersistence.makeContainer(url: url)
|
|
let records = try Self.cloudRecords(category: category, context: reopened.mainContext)
|
|
#expect(
|
|
try CloudSyncRecordStateStore(context: reopened.mainContext, scope: scope).pendingChanges(records).isEmpty)
|
|
var differentAccount = scope
|
|
differentAccount.account = "another-account"
|
|
#expect(
|
|
try CloudSyncRecordStateStore(context: reopened.mainContext, scope: differentAccount).pendingChanges(
|
|
records) == [tombstone])
|
|
}
|
|
|
|
private static func writeVersion36Tombstone(
|
|
url: URL, category: SyncSafetyCategory
|
|
) throws -> CloudSyncMutation {
|
|
let schema = Schema(versionedSchema: TermindSchemaV36.self)
|
|
let configuration = ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none)
|
|
let container = try ModelContainer(for: schema, configurations: [configuration])
|
|
let version = VaultVersion.baseline(writerID: UUID())
|
|
let recordName: String
|
|
switch category {
|
|
case .vault:
|
|
let key = VaultRecordPayload.hostGroup(HostGroup(name: "Migrated")).key
|
|
container.mainContext.insert(
|
|
try PersistentVaultSyncEntry(
|
|
envelope: .init(key: key, version: version, isDeleted: true), needsUpload: false))
|
|
recordName = key.recordName
|
|
case .settings:
|
|
let key = SettingsRecordPayload.sftp(SFTPPreferences()).key
|
|
container.mainContext.insert(
|
|
try PersistentAIConfigurationSyncEntry(
|
|
envelope: .init(key: key, version: version, isDeleted: true), needsUpload: false))
|
|
recordName = key.recordName
|
|
case .metrics:
|
|
let payload = MetricsRecordPayload.selection(
|
|
SyncedMetricsSelection(selection: MetricsSelection(id: UUID()), sortIndex: 0))
|
|
container.mainContext.insert(
|
|
try PersistentMetricsSyncEntry(
|
|
envelope: .init(key: payload.key, version: version, isDeleted: true), needsUpload: false))
|
|
recordName = payload.key.recordName
|
|
case .history, .device:
|
|
let key = HistoryRecordKey(
|
|
kind: category == .device ? .device : .connectionHistory, id: UUID())
|
|
container.mainContext.insert(
|
|
try PersistentHistorySyncEntry(
|
|
envelope: .init(key: key, version: version, isDeleted: true), needsUpload: false))
|
|
recordName = key.recordName
|
|
}
|
|
try container.mainContext.save()
|
|
return CloudSyncMutation(recordName: recordName, operation: .delete, version: version)
|
|
}
|
|
|
|
private static func cloudRecords(
|
|
category: SyncSafetyCategory, context: ModelContext
|
|
) throws -> [CloudSyncJournalRecord] {
|
|
switch category {
|
|
case .vault: try VaultSyncJournal(context: context).cloudRecords()
|
|
case .settings: try SettingsSyncJournal(context: context).cloudRecords()
|
|
case .metrics: try MetricsSyncJournal(context: context).cloudRecords()
|
|
case .history: try HistorySyncJournal(context: context, kind: .connectionHistory).cloudRecords()
|
|
case .device: try HistorySyncJournal(context: context, kind: .device).cloudRecords()
|
|
}
|
|
}
|
|
|
|
private static func assetDirectoryName(for category: SyncSafetyCategory) -> String? {
|
|
switch category {
|
|
case .vault: VaultSyncCoordinator.assetDirectoryName
|
|
case .settings: SettingsSyncCoordinator.assetDirectoryName
|
|
case .metrics: MetricsSyncCoordinator.assetDirectoryName
|
|
case .history, .device: nil
|
|
}
|
|
}
|
|
|
|
private static func tombstone(_ record: CKRecord) throws -> CKRecord {
|
|
switch record.recordType {
|
|
case "VaultItem":
|
|
var envelope = try VaultSyncCoordinator.envelope(from: record)
|
|
envelope.version.counter += 1
|
|
envelope.isDeleted = true
|
|
envelope.payload = nil
|
|
return try VaultSyncCoordinator.makeRecord(.init(envelope: envelope), recordID: record.recordID)
|
|
case "MetricsItem":
|
|
var envelope = try MetricsSyncCoordinator.envelope(from: record)
|
|
envelope.version.counter += 1
|
|
envelope.isDeleted = true
|
|
envelope.payload = nil
|
|
return try MetricsSyncCoordinator.makeRecord(.init(envelope: envelope), recordID: record.recordID)
|
|
case "ConnectionHistoryItem", "DeviceItem":
|
|
var envelope = try HistoryRecordSyncCoordinator.envelope(from: record)
|
|
envelope.version.counter += 1
|
|
envelope.isDeleted = true
|
|
envelope.payload = nil
|
|
return try HistoryRecordSyncCoordinator.makeRecord(.init(envelope: envelope), recordID: record.recordID)
|
|
default:
|
|
var envelope = try SettingsSyncCoordinator.envelope(from: record)
|
|
envelope.version.counter += 1
|
|
envelope.isDeleted = true
|
|
envelope.payload = nil
|
|
return try SettingsSyncCoordinator.makeRecord(.init(envelope: envelope), recordID: record.recordID)
|
|
}
|
|
}
|
|
|
|
private static func liveRecord(_ record: CKRecord, version: VaultVersion) throws -> CKRecord {
|
|
switch record.recordType {
|
|
case "VaultItem":
|
|
var envelope = try VaultSyncCoordinator.envelope(from: record)
|
|
envelope.version = version
|
|
return try VaultSyncCoordinator.makeRecord(.init(envelope: envelope), recordID: record.recordID)
|
|
case "MetricsItem":
|
|
var envelope = try MetricsSyncCoordinator.envelope(from: record)
|
|
envelope.version = version
|
|
return try MetricsSyncCoordinator.makeRecord(.init(envelope: envelope), recordID: record.recordID)
|
|
case "ConnectionHistoryItem", "DeviceItem":
|
|
var envelope = try HistoryRecordSyncCoordinator.envelope(from: record)
|
|
envelope.version = version
|
|
return try HistoryRecordSyncCoordinator.makeRecord(.init(envelope: envelope), recordID: record.recordID)
|
|
default:
|
|
var envelope = try SettingsSyncCoordinator.envelope(from: record)
|
|
envelope.version = version
|
|
return try SettingsSyncCoordinator.makeRecord(.init(envelope: envelope), recordID: record.recordID)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private struct MigratedDeletionAdapter {
|
|
var lifecycle: CloudSyncLifecycle
|
|
var batch: ([CKSyncEngine.PendingRecordZoneChange]) -> CKSyncEngine.RecordZoneChangeBatch?
|
|
var sent: (CloudSyncSentChanges) throws -> Void
|
|
var cloudRecords: () throws -> [CloudSyncJournalRecord]
|
|
}
|
|
|
|
@MainActor
|
|
private final class MigratedPhysicalDeletionFixture {
|
|
let container: ModelContainer
|
|
let defaults: UserDefaults
|
|
let defaultsName = "MigratedPhysicalDeletionTests.\(UUID())"
|
|
private let adapter: MigratedDeletionAdapter
|
|
|
|
var lifecycle: CloudSyncLifecycle { adapter.lifecycle }
|
|
var batch: ([CKSyncEngine.PendingRecordZoneChange]) -> CKSyncEngine.RecordZoneChangeBatch? { adapter.batch }
|
|
var sent: (CloudSyncSentChanges) throws -> Void { adapter.sent }
|
|
var cloudRecords: () throws -> [CloudSyncJournalRecord] { adapter.cloudRecords }
|
|
|
|
init(category: SyncSafetyCategory, url: URL) throws {
|
|
container = try TermindPersistence.makeContainer(url: url)
|
|
defaults = UserDefaults(suiteName: defaultsName)!
|
|
let fixtureNetwork = LifecycleNetwork()
|
|
for key in [
|
|
SettingsKey.iCloudSyncEnabled, SettingsKey.vaultSyncEnabled,
|
|
SettingsKey.settingsSyncEnabled, SettingsKey.metricsSyncEnabled, SettingsKey.historySyncEnabled,
|
|
] { defaults.set(true, forKey: key) }
|
|
let context = container.mainContext
|
|
switch category {
|
|
case .vault:
|
|
let journal = VaultSyncJournal(context: context)
|
|
let coordinator = VaultSyncCoordinator(
|
|
context: context, defaults: defaults, environmentIdentifier: "Test",
|
|
makeTransport: { _, _ in fixtureNetwork.transport })
|
|
adapter = MigratedDeletionAdapter(
|
|
lifecycle: coordinator.lifecycle,
|
|
batch: { coordinator.makeChangeBatch($0, engine: fixtureNetwork.nextEngine) },
|
|
sent: coordinator.handleSentChanges, cloudRecords: journal.cloudRecords)
|
|
case .settings:
|
|
let persistence = SettingsSyncPersistence(context: context, defaults: defaults)
|
|
let journal = persistence.journal
|
|
let secrets = AISecretStore(context: context, keychain: InMemorySecretStore(), defaults: defaults)
|
|
let coordinator = SettingsSyncCoordinator(
|
|
persistence: persistence, secrets: secrets, defaults: defaults, environmentIdentifier: "Test",
|
|
makeTransport: { _, _ in fixtureNetwork.transport })
|
|
adapter = MigratedDeletionAdapter(
|
|
lifecycle: coordinator.lifecycle,
|
|
batch: { coordinator.makeChangeBatch($0, engine: fixtureNetwork.nextEngine) },
|
|
sent: coordinator.handleSentChanges, cloudRecords: journal.cloudRecords)
|
|
case .metrics:
|
|
let persistence = MetricsSyncPersistence(context: context, defaults: defaults)
|
|
let journal = persistence.journal
|
|
let coordinator = MetricsSyncCoordinator(
|
|
persistence: persistence, defaults: defaults, environmentIdentifier: "Test",
|
|
makeTransport: { _, _ in fixtureNetwork.transport })
|
|
adapter = MigratedDeletionAdapter(
|
|
lifecycle: coordinator.lifecycle,
|
|
batch: { coordinator.makeChangeBatch($0, engine: fixtureNetwork.nextEngine) },
|
|
sent: coordinator.handleSentChanges, cloudRecords: journal.cloudRecords)
|
|
case .history, .device:
|
|
let kind: HistoryRecordKind = category == .device ? .device : .connectionHistory
|
|
let persistence = HistorySyncPersistence(context: context)
|
|
try persistence.prepare()
|
|
let journal = HistorySyncJournal(context: context, kind: kind)
|
|
let coordinator = HistoryRecordSyncCoordinator(
|
|
persistence: persistence, kind: kind, defaults: defaults, environmentIdentifier: "Test",
|
|
makeTransport: { _, _ in fixtureNetwork.transport })
|
|
adapter = MigratedDeletionAdapter(
|
|
lifecycle: coordinator.lifecycle,
|
|
batch: { coordinator.makeChangeBatch($0, engine: fixtureNetwork.nextEngine) },
|
|
sent: coordinator.handleSentChanges, cloudRecords: journal.cloudRecords)
|
|
}
|
|
}
|
|
|
|
func start() async throws {
|
|
lifecycle.start()
|
|
await lifecycle.operation?.value
|
|
#expect(lifecycle.isReadyToSend)
|
|
}
|
|
|
|
func stop() {
|
|
lifecycle.pause()
|
|
defaults.removePersistentDomain(forName: defaultsName)
|
|
}
|
|
}
|