Files
Termind/FrameworkTests/CloudSyncSafetyTests.swift
T

152 lines
7.5 KiB
Swift

import CloudKit
import Foundation
import SwiftData
import TermindDomain
import TermindPersistence
import Testing
@testable import TermindInfrastructure
@Suite @MainActor
struct CloudSyncSafetyTests {
@Test(arguments: SyncSafetyCategory.allCases)
func everyCoordinatorUsesTheSharedCloudKitRequestLimit(category: SyncSafetyCategory) async throws {
let fixture = try CloudSyncSafetyFixture(category: category)
defer { fixture.stop() }
try await fixture.start()
let pending = (0...CloudSyncLifecycle.maximumRecordsPerRequest).map { index in
CKSyncEngine.PendingRecordZoneChange.saveRecord(
fixture.recordIDs[index % fixture.recordIDs.count]
)
}
let batch = try #require(fixture.batch(pending))
#expect(batch.recordsToSave.count == CloudSyncLifecycle.maximumRecordsPerRequest)
#expect(batch.recordIDsToDelete.isEmpty)
}
@Test(arguments: SyncSafetyCategory.allCases)
func unsupportedRecordIsFencedAtJournalBatchAndProviderWithoutBlockingOtherRecords(
category: SyncSafetyCategory
) async throws {
let fixture = try CloudSyncSafetyFixture(category: category)
defer { fixture.stop() }
try await fixture.start()
let future = try fixture.futureRecord()
let blockedID = fixture.recordIDs[0]
let healthyID = fixture.recordIDs[1]
#expect(fixture.network.nextEngine.queued.contains(blockedID.recordName))
try fixture.lifecycle.beginFetch()
try fixture.receive([future], [])
try fixture.lifecycle.finishFetch()
try fixture.lifecycle.updateUpToDateStatus()
let safety = try fixture.safety()
let isolated = try #require(try safety.entry(recordName: blockedID.recordName))
#expect(isolated.payloadData == future["payload"] as? Data)
#expect(isolated.reasonCode == CloudSyncQuarantineReason.unsupportedFormat.rawValue)
#expect(try fixture.pendingNames() == Set(fixture.recordIDs.map(\.recordName)))
#expect(!fixture.network.nextEngine.queued.contains(blockedID.recordName))
#expect(fixture.lifecycle.status == .partiallyBlocked(pendingCount: 1, blockedCount: 1, requiresUpdate: true))
let batch = try #require(
fixture.batch([
.saveRecord(blockedID), .saveRecord(healthyID), .deleteRecord(blockedID), .deleteRecord(healthyID),
]))
#expect(batch.recordsToSave.map(\.recordID) == [healthyID])
#expect(batch.recordIDsToDelete.isEmpty)
#expect(fixture.record(blockedID) == nil)
#expect(fixture.record(healthyID) != nil)
}
@Test(arguments: SyncSafetyCategory.allCases)
func serverConflictUsesTheSameQuarantineAndRetryCannotBypassIt(category: SyncSafetyCategory) async throws {
let fixture = try CloudSyncSafetyFixture(category: category)
defer { fixture.stop() }
try await fixture.start()
let future = try fixture.futureRecord()
try fixture.conflict(CKError(.serverRecordChanged, userInfo: [CKRecordChangedErrorServerRecordKey: future]))
let blockedID = fixture.recordIDs[0]
#expect(try fixture.safety().isUploadBlocked(recordName: blockedID.recordName))
fixture.lifecycle.retry()
await fixture.lifecycle.operation?.value
#expect(fixture.record(blockedID) == nil)
fixture.lifecycle.pause()
fixture.network.nextEngine = LifecycleEngine()
try await fixture.start()
#expect(fixture.record(blockedID) == nil)
#expect(fixture.record(fixture.recordIDs[1]) != nil)
#expect(try fixture.pendingNames().contains(blockedID.recordName))
}
@Test(arguments: SyncSafetyCategory.allCases)
func tombstoneAndPhysicalRecreationStayBlockedUntilSupportedRemoteDataCommits(
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))
try fixture.receive([fixture.futureRecord()], [])
try fixture.markTombstone()
try fixture.receive([], [id])
#expect(fixture.record(id) == nil)
#expect(fixture.batch([.saveRecord(id), .deleteRecord(id)]) == nil)
#expect(try fixture.pendingNames().contains(id.recordName))
try fixture.receive([supported], [])
#expect(try !fixture.safety().isUploadBlocked(recordName: id.recordName))
#expect(fixture.network.nextEngine.queued.contains(id.recordName))
let outgoing = try #require(fixture.record(id))
let data = try #require(outgoing["payload"] as? Data)
let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect(json["isDeleted"] as? Bool == true)
}
@Test(arguments: SyncSafetyCategory.allCases)
func failedMergeRollsBackBothUnblockAndJournalMutation(category: SyncSafetyCategory) async throws {
let fixture = try CloudSyncSafetyFixture(category: category)
defer { fixture.stop() }
try await fixture.start()
let future = try fixture.futureRecord()
try fixture.receive([future], [])
let safety = try fixture.safety()
#expect(throws: CocoaError.self) { try fixture.failingMerge(safety) }
let row = try #require(try safety.entry(recordName: fixture.recordIDs[0].recordName))
#expect(row.payloadData == future["payload"] as? Data)
#expect(try fixture.pendingNames() == Set(fixture.recordIDs.map(\.recordName)))
#expect(fixture.record(fixture.recordIDs[0]) == nil)
}
@Test(arguments: SyncSafetyCategory.allCases)
func malformedPayloadAlsoBlocksUploadsWithoutClaimingAnUpgradeWillFixIt(category: SyncSafetyCategory) async throws {
let fixture = try CloudSyncSafetyFixture(category: category)
defer { fixture.stop() }
try await fixture.start()
let record = try #require(fixture.record(fixture.recordIDs[0]))
let malformed = Data([0xff])
CloudSyncSafetyFixture.setPayload(malformed, in: record)
try fixture.receive([record], [])
let row = try #require(try fixture.safety().entry(recordName: record.recordID.recordName))
#expect(row.reasonCode == CloudSyncQuarantineReason.unreadablePayload.rawValue)
#expect(row.payloadData == malformed)
try fixture.lifecycle.updateUpToDateStatus()
#expect(fixture.lifecycle.status == .partiallyBlocked(pendingCount: 1, blockedCount: 1, requiresUpdate: false))
#expect(fixture.record(record.recordID) == nil)
}
@Test(arguments: SyncSafetyCategory.allCases)
func newRecordTypesAreIgnoredButCannotBypassAConflictForAKnownID(category: SyncSafetyCategory) async throws {
let fixture = try CloudSyncSafetyFixture(category: category)
defer { fixture.stop() }
try await fixture.start()
let source = try #require(fixture.record(fixture.recordIDs[0]))
let unknown = CKRecord(recordType: "FutureFeature", recordID: source.recordID)
for key in source.allKeys() { unknown[key] = source[key] }
try fixture.receive([unknown], [])
#expect(try fixture.safety().blockedRecordNames().isEmpty)
try fixture.conflict(CKError(.serverRecordChanged, userInfo: [CKRecordChangedErrorServerRecordKey: unknown]))
#expect(try fixture.safety().isUploadBlocked(recordName: unknown.recordID.recordName))
#expect(fixture.record(unknown.recordID) == nil)
}
}