diff --git a/.gitignore b/.gitignore index d6a7bb00..6e91ebaf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ DerivedData/ *.orig *.sqlite *.xcresult +Package.resolved diff --git a/Package.swift b/Package.swift index e1f278e8..5168e191 100644 --- a/Package.swift +++ b/Package.swift @@ -84,6 +84,10 @@ let package = Package( .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), .product(name: "SnapshotTestingCustomDump", package: "swift-snapshot-testing"), .product(name: "StructuredQueries", package: "swift-structured-queries"), + ], + resources: [ + .copy("Resources/test-black.svg"), + .copy("Resources/test-red.svg") ] ), ], diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 3b6db09a..0281db2a 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -64,6 +64,10 @@ let package = Package( .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), .product(name: "SnapshotTestingCustomDump", package: "swift-snapshot-testing"), .product(name: "StructuredQueries", package: "swift-structured-queries"), + ], + resources: [ + .copy("Resources/test-black.svg"), + .copy("Resources/test-red.svg") ] ), ], diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index c03123a1..37aeb4a9 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -142,6 +142,10 @@ get { self["\(key)_hash"] as? Data } set { self["\(key)_hash"] = newValue } } + package subscript(data key: String) -> Data? { + get { self["\(key)_data"] as? Data } + set { self["\(key)_data"] = newValue } + } } @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) @@ -182,6 +186,9 @@ encryptedValues[hash: key] != hash else { return false } + if encryptedValues[data: key] != nil { + encryptedValues[data: key] = nil + } self[key] = newValue encryptedValues[hash: key] = hash encryptedValues[at: key] = userModificationTime @@ -198,6 +205,22 @@ guard encryptedValues[at: key] <= userModificationTime else { return false } + if newValue.isSmall { + let newData = Data(newValue) + guard + encryptedValues[at: key] <= userModificationTime, + encryptedValues[data: key] != newData + else { return false } + if self[key] != nil { + self[key] = nil + encryptedValues[hash: key] = nil + } + encryptedValues[data: key] = newData + encryptedValues[at: key] = userModificationTime + self.userModificationTime = userModificationTime + return true + } + @Dependency(\.dataManager) var dataManager let hash = newValue.sha256 let fileURL = dataManager.temporaryDirectory.appending( @@ -229,6 +252,12 @@ } if encryptedValues[key] != nil { encryptedValues[key] = nil + encryptedValues[hash: key] = nil + encryptedValues[at: key] = userModificationTime + self.userModificationTime = userModificationTime + return true + } else if encryptedValues[data: key] != nil { + encryptedValues[data: key] = nil encryptedValues[at: key] = userModificationTime self.userModificationTime = userModificationTime return true @@ -299,7 +328,9 @@ didSet = setAsset(value, forKey: key, at: other.encryptedValues[at: key]) } else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol { didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key]) - } else if other.encryptedValues[key] == nil { + } else if let data = other.encryptedValues[data: key] { + didSet = setValue(Array(data), forKey: key, at: other.encryptedValues[at: key]) + } else if other.encryptedValues[key] == nil, other.encryptedValues[data: key] == nil { didSet = removeValue(forKey: key, at: other.encryptedValues[at: key]) } else { didSet = false @@ -308,7 +339,16 @@ var isRowValueModified: Bool { switch Value(queryOutput: row[keyPath: keyPath]).queryBinding { case .blob(let value): - return other.encryptedValues[hash: key] != value.sha256 + if value.isSmall, + let serverData = + other.encryptedValues[key] as? Data ?? other.encryptedValues[data: key] + { + return serverData != Data(value) + } else if let otherHash = other.encryptedValues[hash: key] { + return otherHash != value.sha256 + } else { + return true + } case .bool(let value): return other.encryptedValues[key] != value case .double(let value): @@ -364,6 +404,8 @@ return value.queryFragment } else if let value = self as? Date { return value.queryFragment + } else if let value = self as? [UInt8] { + return value.queryFragment } else { return "\(.invalid(Unbindable()))" } @@ -383,5 +425,10 @@ fileprivate var sha256: Data { Data(SHA256.hash(data: self)) } + + fileprivate var isSmall: Bool { + count <= 16_384 + } } + #endif diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 871c7999..017118c2 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -2048,7 +2048,9 @@ } return data?.queryFragment ?? "NULL" } else { - return record.encryptedValues[columnName]?.queryFragment ?? "NULL" + return record.encryptedValues[columnName]?.queryFragment + ?? record.encryptedValues[data: columnName]?.queryFragment + ?? "NULL" } } .joined(separator: ", ") @@ -2063,12 +2065,15 @@ if data == nil { reportIssue("Asset data not found on disk") } - return - "\(quote: columnName) = \(data?.queryFragment ?? #""excluded".\#(quote: columnName)"#)" + return "\(quote: columnName) = \(data?.queryFragment ?? #""excluded".\#(quote: columnName)"#)" + } else if let queryFragment = record.encryptedValues[columnName]?.queryFragment + ?? record.encryptedValues[data: columnName]?.queryFragment + { + return "\(quote: columnName) = \(queryFragment)" } else { return """ \(quote: columnName) = \ - \(record.encryptedValues[columnName]?.queryFragment ?? #""excluded".\#(quote: columnName)"#) + \(#""excluded".\#(quote: columnName)"#) """ } } @@ -2481,7 +2486,9 @@ return (try? asset.fileURL.map { try dataManager.load($0) })? .queryFragment ?? "NULL" } else { - return record.encryptedValues[columnName]?.queryFragment ?? "NULL" + return record.encryptedValues[columnName]?.queryFragment + ?? record.encryptedValues[data: columnName]?.queryFragment + ?? "NULL" } } .joined(separator: ", ") diff --git a/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift index 82e24399..c726cdb1 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift @@ -14,17 +14,20 @@ final class AssetsTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func basics() async throws { + let blackImageURL = Bundle.module.url(forResource: "test-black", withExtension: "svg")! + let blackCoverImage = try Data(contentsOf: blackImageURL) + try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Personal") - RemindersListAsset(remindersListID: 1, coverImage: Data("image".utf8)) + RemindersListAsset(remindersListID: 1, coverImage: blackCoverImage) } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) assertInlineSnapshot(of: container, as: .customDump) { - """ + #""" MockCloudContainer( privateCloudDatabase: MockCloudDatabase( databaseScope: .private, @@ -37,8 +40,94 @@ coverImage_hash: Data(32 bytes), remindersListID: 1, coverImage: CKAsset( - fileURL: URL(file:///tmp/6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d), - dataString: "image" + fileURL: URL(file:///tmp/4eb74bd60d41b48bd682896ff4ba846da5051a1e190159414b3ba177a9dbe482), + dataString: """ + + + + + + + + + + + """ ) ), [1]: CKRecord( @@ -56,23 +145,26 @@ storage: [] ) ) - """ + """# } inMemoryDataManager.storage.withValue { storage in let url = URL( - string: "file:///tmp/6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d" + string: "file:///tmp/4eb74bd60d41b48bd682896ff4ba846da5051a1e190159414b3ba177a9dbe482" )! - #expect(storage[url] == Data("image".utf8)) + #expect(storage[url] == blackCoverImage) } + let redImageURL = Bundle.module.url(forResource: "test-red", withExtension: "svg")! + let redCoverImage = try Data(contentsOf: redImageURL) + try await withDependencies { $0.currentTime.now += 1 } operation: { try await userDatabase.userWrite { db in try RemindersListAsset .find(1) - .update { $0.coverImage = #bind(Data("new-image".utf8)) } + .update { $0.coverImage = #bind(redCoverImage) } .execute(db) } } @@ -80,7 +172,7 @@ try await syncEngine.processPendingRecordZoneChanges(scope: .private) assertInlineSnapshot(of: container, as: .customDump) { - """ + #""" MockCloudContainer( privateCloudDatabase: MockCloudDatabase( databaseScope: .private, @@ -93,8 +185,94 @@ coverImage_hash: Data(32 bytes), remindersListID: 1, coverImage: CKAsset( - fileURL: URL(file:///tmp/97e67a5645969953f1a4cfe2ea75649864ff99789189cdd3f6db03e59f8a8ebf), - dataString: "new-image" + fileURL: URL(file:///tmp/43aba58d3830c6821f433a10c9fd554e53c257ebfd9c451514ea2c27c774b79f), + dataString: """ + + + + + + + + + + + """ ) ), [1]: CKRecord( @@ -112,14 +290,14 @@ storage: [] ) ) - """ + """# } inMemoryDataManager.storage.withValue { storage in let url = URL( - string: "file:///tmp/97e67a5645969953f1a4cfe2ea75649864ff99789189cdd3f6db03e59f8a8ebf" + string: "file:///tmp/43aba58d3830c6821f433a10c9fd554e53c257ebfd9c451514ea2c27c774b79f" )! - #expect(storage[url] == Data("new-image".utf8)) + #expect(storage[url] == redCoverImage) } } @@ -127,6 +305,8 @@ // => Stored in database as bytes @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func receiveAsset() async throws { + let blackImageURL = Bundle.module.url(forResource: "test-black", withExtension: "svg")! + let blackCoverImage = try Data(contentsOf: blackImageURL) let remindersListRecord = CKRecord( recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: 1) @@ -135,7 +315,7 @@ remindersListRecord.setValue("Personal", forKey: "title", at: now) let fileURL = URL(fileURLWithPath: UUID().uuidString) - try inMemoryDataManager.save(Data("image".utf8), to: fileURL) + try inMemoryDataManager.save(blackCoverImage, to: fileURL) let remindersListAssetRecord = CKRecord( recordType: RemindersListAsset.tableName, recordID: RemindersListAsset.recordID(for: 1) @@ -158,7 +338,7 @@ let remindersListAsset = try #require( try RemindersListAsset.find(1).fetchOne(db) ) - #expect(remindersListAsset.coverImage == Data("image".utf8)) + #expect(remindersListAsset.coverImage == blackCoverImage) } } @@ -166,19 +346,23 @@ // => Stored in database as bytes @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func receiveUpdatedAsset() async throws { + let blackImageURL = Bundle.module.url(forResource: "test-black", withExtension: "svg")! + let blackCoverImage = try Data(contentsOf: blackImageURL) try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Personal") - RemindersListAsset(remindersListID: 1, coverImage: Data("image".utf8)) + RemindersListAsset(remindersListID: 1, coverImage: blackCoverImage) } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) + let redImageURL = Bundle.module.url(forResource: "test-red", withExtension: "svg")! + let redCoverImage = try Data(contentsOf: redImageURL) try await withDependencies { $0.currentTime.now += 1 } operation: { let fileURL = URL(fileURLWithPath: UUID().uuidString) - try inMemoryDataManager.save(Data("new-image".utf8), to: fileURL) + try inMemoryDataManager.save(redCoverImage, to: fileURL) let remindersListAssetRecord = try syncEngine.private.database.record( for: RemindersListAsset.recordID(for: 1) ) @@ -198,7 +382,7 @@ let remindersListAsset = try #require( try RemindersListAsset.find(1).fetchOne(db) ) - #expect(remindersListAsset.coverImage == Data("new-image".utf8)) + #expect(remindersListAsset.coverImage == redCoverImage) } } @@ -208,6 +392,8 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func receiveAssetThenReceiveUpdate() async throws { do { + let blackImageURL = Bundle.module.url(forResource: "test-black", withExtension: "svg")! + let blackCoverImage = try Data(contentsOf: blackImageURL) let remindersListRecord = CKRecord( recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: 1) @@ -216,7 +402,7 @@ remindersListRecord.setValue("Personal", forKey: "title", at: now) let fileURL = URL(fileURLWithPath: UUID().uuidString) - try inMemoryDataManager.save(Data("image".utf8), to: fileURL) + try inMemoryDataManager.save(blackCoverImage, to: fileURL) let remindersListAssetRecord = CKRecord( recordType: RemindersListAsset.tableName, recordID: RemindersListAsset.recordID(for: 1) @@ -240,11 +426,13 @@ .notify() } + let redImageURL = Bundle.module.url(forResource: "test-red", withExtension: "svg")! + let redCoverImage = try Data(contentsOf: redImageURL) try await withDependencies { $0.currentTime.now += 1 } operation: { let fileURL = URL(fileURLWithPath: UUID().uuidString) - try inMemoryDataManager.save(Data("new-image".utf8), to: fileURL) + try inMemoryDataManager.save(redCoverImage, to: fileURL) let remindersListAssetRecord = try syncEngine.private.database.record( for: RemindersListAsset.recordID(for: 1) ) @@ -264,7 +452,7 @@ let remindersListAsset = try #require( try RemindersListAsset.find(1).fetchOne(db) ) - #expect(remindersListAsset.coverImage == Data("new-image".utf8)) + #expect(remindersListAsset.coverImage == redCoverImage) } } @@ -273,6 +461,8 @@ // => Both records (and the image data) should be synchronized @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func assetReceivedBeforeParentRecord() async throws { + let blackImageURL = Bundle.module.url(forResource: "test-black", withExtension: "svg")! + let blackCoverImage = try Data(contentsOf: blackImageURL) let remindersListRecord = CKRecord( recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: 1) @@ -286,7 +476,7 @@ ) remindersListAssetRecord.setValue("1", forKey: "id", at: now) remindersListAssetRecord.setValue( - Array("image".utf8), + blackCoverImage, forKey: "coverImage", at: now ) @@ -320,12 +510,12 @@ } assertQuery(RemindersListAsset.all, database: userDatabase.database) { """ - ┌─────────────────────────────┐ - │ RemindersListAsset( │ - │ remindersListID: 1, │ - │ coverImage: Data(5 bytes) │ - │ ) │ - └─────────────────────────────┘ + ┌──────────────────────────────────┐ + │ RemindersListAsset( │ + │ remindersListID: 1, │ + │ coverImage: Data(16,811 bytes) │ + │ ) │ + └──────────────────────────────────┘ """ } diff --git a/Tests/SQLiteDataTests/CloudKitTests/DataTests.swift b/Tests/SQLiteDataTests/CloudKitTests/DataTests.swift new file mode 100644 index 00000000..51b87751 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/DataTests.swift @@ -0,0 +1,309 @@ +#if canImport(CloudKit) + import CloudKit + import ConcurrencyExtras + import CustomDump + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SQLiteDataTestSupport + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + final class DataTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func basics() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersListAsset(remindersListID: 1, coverImage: Data("image".utf8)) + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersListAssets/zone/__defaultOwner__), + recordType: "remindersListAssets", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + coverImage_data: Data(5 bytes), + remindersListID: 1 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + try await userDatabase.userWrite { db in + try RemindersListAsset + .find(1) + .update { $0.coverImage = #bind(Data("new-image".utf8)) } + .execute(db) + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersListAssets/zone/__defaultOwner__), + recordType: "remindersListAssets", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + coverImage_data: Data(9 bytes), + remindersListID: 1 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + // * Receive record with CKAsset from CloudKit + // => Stored in database as bytes + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func receiveData() async throws { + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1) + ) + remindersListRecord.setValue("1", forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + let remindersListAssetRecord = CKRecord( + recordType: RemindersListAsset.tableName, + recordID: RemindersListAsset.recordID(for: 1) + ) + remindersListAssetRecord.setValue("1", forKey: "id", at: now) + remindersListAssetRecord.setValue(Data("image".utf8), forKey: "coverImage", at: now) + remindersListAssetRecord.setValue("1", forKey: "remindersListID", at: now) + remindersListAssetRecord.parent = CKRecord.Reference( + record: remindersListRecord, + action: .none + ) + + try await syncEngine.modifyRecords( + scope: .private, + saving: [remindersListAssetRecord, remindersListRecord] + ) + .notify() + + try await userDatabase.read { db in + let remindersListAsset = try #require( + try RemindersListAsset.find(1).fetchOne(db) + ) + #expect(remindersListAsset.coverImage == Data("image".utf8)) + } + } + + // * Receive record with Data from CloudKit when local asset exists + // => Stored in database as bytes + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func receiveUpdatedData() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersListAsset(remindersListID: 1, coverImage: Data("image".utf8)) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + let remindersListAssetRecord = try syncEngine.private.database.record( + for: RemindersListAsset.recordID(for: 1) + ) + remindersListAssetRecord.setValue( + Data("new-image".utf8), + forKey: "coverImage", + at: now + ) + try await syncEngine.modifyRecords( + scope: .private, + saving: [remindersListAssetRecord] + ) + .notify() + } + + try await userDatabase.read { db in + let remindersListAsset = try #require( + try RemindersListAsset.find(1).fetchOne(db) + ) + #expect(remindersListAsset.coverImage == Data("new-image".utf8)) + } + } + + // * Receive record with CKAsset from CloudKit when local asset does not exist + // * Receive updated asset from CloudKit + // => Local database has freshest asset + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func receiveAssetThenReceiveUpdate() async throws { + do { + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1) + ) + remindersListRecord.setValue("1", forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + let fileURL = URL(fileURLWithPath: UUID().uuidString) + try inMemoryDataManager.save(Data("image".utf8), to: fileURL) + let remindersListAssetRecord = CKRecord( + recordType: RemindersListAsset.tableName, + recordID: RemindersListAsset.recordID(for: 1) + ) + remindersListAssetRecord.setValue("1", forKey: "id", at: now) + remindersListAssetRecord.setAsset( + CKAsset(fileURL: fileURL), + forKey: "coverImage", + at: now + ) + remindersListAssetRecord.setValue("1", forKey: "remindersListID", at: now) + remindersListAssetRecord.parent = CKRecord.Reference( + record: remindersListRecord, + action: .none + ) + + try await syncEngine.modifyRecords( + scope: .private, + saving: [remindersListAssetRecord, remindersListRecord] + ) + .notify() + } + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + let fileURL = URL(fileURLWithPath: UUID().uuidString) + try inMemoryDataManager.save(Data("new-image".utf8), to: fileURL) + let remindersListAssetRecord = try syncEngine.private.database.record( + for: RemindersListAsset.recordID(for: 1) + ) + remindersListAssetRecord.setAsset( + CKAsset(fileURL: fileURL), + forKey: "coverImage", + at: now + ) + try await syncEngine.modifyRecords( + scope: .private, + saving: [remindersListAssetRecord] + ) + .notify() + } + + try await userDatabase.read { db in + let remindersListAsset = try #require( + try RemindersListAsset.find(1).fetchOne(db) + ) + #expect(remindersListAsset.coverImage == Data("new-image".utf8)) + } + } + + // * Client receives RemindersListAsset with image data + // * A moment later client receives the parent RemindersList + // => Both records (and the image data) should be synchronized + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func assetReceivedBeforeParentRecord() async throws { + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1) + ) + remindersListRecord.setValue("1", forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + let remindersListAssetRecord = CKRecord( + recordType: RemindersListAsset.tableName, + recordID: RemindersListAsset.recordID(for: 1) + ) + remindersListAssetRecord.setValue("1", forKey: "id", at: now) + remindersListAssetRecord.setValue( + Array("image".utf8), + forKey: "coverImage", + at: now + ) + remindersListAssetRecord.setValue( + "1", + forKey: "remindersListID", + at: now + ) + remindersListAssetRecord.parent = CKRecord.Reference( + record: remindersListRecord, + action: .none + ) + + let remindersListModification = try syncEngine.modifyRecords( + scope: .private, + saving: [remindersListRecord] + ) + try await syncEngine.modifyRecords(scope: .private, saving: [remindersListAssetRecord]) + .notify() + await remindersListModification.notify() + + assertQuery(RemindersList.all, database: userDatabase.database) { + """ + ┌─────────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "Personal" │ + │ ) │ + └─────────────────────┘ + """ + } + assertQuery(RemindersListAsset.all, database: userDatabase.database) { + """ + ┌─────────────────────────────┐ + │ RemindersListAsset( │ + │ remindersListID: 1, │ + │ coverImage: Data(5 bytes) │ + │ ) │ + └─────────────────────────────┘ + """ + } + + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/Resources/test-black.svg b/Tests/SQLiteDataTests/Resources/test-black.svg new file mode 100644 index 00000000..1f42b9bf --- /dev/null +++ b/Tests/SQLiteDataTests/Resources/test-black.svg @@ -0,0 +1,85 @@ + + + + + + + + + + \ No newline at end of file diff --git a/Tests/SQLiteDataTests/Resources/test-red.svg b/Tests/SQLiteDataTests/Resources/test-red.svg new file mode 100644 index 00000000..dc16c928 --- /dev/null +++ b/Tests/SQLiteDataTests/Resources/test-red.svg @@ -0,0 +1,85 @@ + + + + + + + + + + \ No newline at end of file