diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift index 3af32acf..e730278a 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift @@ -52,7 +52,8 @@ let rootRecord: CKRecord? = database.state.withValue { $0.storage[share.recordID.zoneID]?.records.values.first { record in record.share?.recordID == share.recordID - } + }? + .copy() as? CKRecord } return ShareMetadata( diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index 510d1f5c..b0149fd2 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -188,21 +188,22 @@ return } - guard let copy = recordToSave.copy() as? CKRecord + guard let databaseCopy = recordToSave.copy() as? CKRecord else { fatalError("Could not copy CKRecord.") } - copy._recordChangeTag = state.nextRecordChangeTag() + databaseCopy._recordChangeTag = state.nextRecordChangeTag() - for key in copy.allKeys() { - guard let assetURL = (copy[key] as? CKAsset)?.fileURL + for key in databaseCopy.allKeys() { + guard let assetURL = (databaseCopy[key] as? CKAsset)?.fileURL else { continue } - state.assets[AssetID(recordID: copy.recordID, key: key)] = + state.assets[AssetID(recordID: databaseCopy.recordID, key: key)] = try? dataManager.wrappedValue .load(assetURL) } // TODO: This should merge copy's values to more accurately reflect reality - state.storage[recordToSave.recordID.zoneID]?.records[recordToSave.recordID] = copy - saveResults[recordToSave.recordID] = .success(copy) + state.storage[recordToSave.recordID.zoneID]?.records[recordToSave.recordID] = + databaseCopy + saveResults[recordToSave.recordID] = .success(databaseCopy.copy() as! CKRecord) // NB: "Touch" parent records when saving a child: if let parent = recordToSave.parent, diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index c8ef0113..e250592a 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -49,6 +49,7 @@ accum, zoneID in accum += ((state.storage[zoneID]?.records.values).map { Array($0) } ?? []) + .map { $0.copy() as! CKRecord } .filter { precondition( $0._recordChangeTag != nil, diff --git a/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift index ab2d92f6..5f00dc5a 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift @@ -5,6 +5,7 @@ import InlineSnapshotTesting import SQLiteData import SnapshotTestingCustomDump + import TestLocals import Testing import SQLiteDataTestSupport @@ -24,7 +25,9 @@ } } defaultNotificationCenter.post( - name: UIApplication.willResignActiveNotification, object: nil) + name: UIApplication.willResignActiveNotification, + object: nil + ) try await Task.sleep(for: .seconds(1)) assertInlineSnapshot(of: container, as: .customDump) { """ @@ -52,10 +55,12 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test(.startImmediately(false)) + @Test(.taskLocal($startImmediately, false)) func background_whileNotRunning() async throws { defaultNotificationCenter.post( - name: UIApplication.willResignActiveNotification, object: nil) + name: UIApplication.willResignActiveNotification, + object: nil + ) try await Task.sleep(for: .seconds(1)) // NB: Not runtime warnings emitted. } @@ -101,7 +106,9 @@ } defaultNotificationCenter.post( - name: UIApplication.willResignActiveNotification, object: nil) + name: UIApplication.willResignActiveNotification, + object: nil + ) try await Task.sleep(for: .seconds(1)) assertInlineSnapshot(of: container, as: .customDump) { """ diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift index d019ae9e..465163b9 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -741,6 +741,49 @@ } #expect(error?.code == .limitExceeded) } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func `mutating record returned from modifyRecords does not mutate database`() async throws { + let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "1")) + record["title"] = "original" + let (saveResults, _) = try syncEngine.private.database.modifyRecords(saving: [record]) + let savedRecord = try #require(try saveResults[record.recordID]?.get()) + savedRecord["title"] = "mutated" + + let storedRecord = try syncEngine.private.database.record(for: record.recordID) + #expect(storedRecord["title"] as? String == "original") + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func `mutating record returned from non-atomic modifyRecords does not mutate database`() async throws { + let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "1")) + record["title"] = "original" + let (saveResults, _) = try syncEngine.private.database.modifyRecords( + saving: [record], + atomically: false + ) + let savedRecord = try #require(try saveResults[record.recordID]?.get()) + savedRecord["title"] = "mutated" + + let storedRecord = try syncEngine.private.database.record(for: record.recordID) + #expect(storedRecord["title"] as? String == "original") + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func `mutating record returned from shareMetadata does not mutate database`() async throws { + let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "1")) + record["title"] = "original" + let share = CKShare(rootRecord: record, shareID: CKRecord.ID(recordName: "share")) + _ = try syncEngine.private.database.modifyRecords(saving: [share, record]) + + let metadata = try await container.shareMetadata(for: share, shouldFetchRootRecord: true) + let rootRecord = try #require(metadata.rootRecord) + rootRecord["title"] = "mutated" + + let storedRecord = try syncEngine.private.database.record(for: record.recordID) + #expect(storedRecord["title"] as? String == "original") + } + } } #endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockSyncEngineTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockSyncEngineTests.swift new file mode 100644 index 00000000..55074668 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/MockSyncEngineTests.swift @@ -0,0 +1,44 @@ +#if canImport(CloudKit) + import CloudKit + import Dependencies + import SQLiteData + import Testing + + extension BaseCloudKitTests { + @MainActor + final class MockSyncEngineTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func `fetching changes does not mutate database`() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withDependencies { + $0.currentTime.now += 30 + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(1).update { $0.title = "Family" }.execute(db) + } + } + + let before = try syncEngine.private.database.record( + for: RemindersList.recordID(for: 1) + ) + #expect(before.userModificationTime == 0) + + try await syncEngine.private.fetchChanges(CKSyncEngine.FetchChangesOptions()) + + let after = try syncEngine.private.database.record( + for: RemindersList.recordID(for: 1) + ) + #expect(after.userModificationTime == 0) + #expect(after.encryptedValues["title"] as? String == "Personal") + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + } + } + } +#endif