diff --git a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift index c2bfc71e..5627daf5 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudContainer.swift @@ -1,6 +1,7 @@ #if canImport(CloudKit) import CloudKit +@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) package protocol CloudContainer: AnyObject, Equatable, Hashable, Sendable { associatedtype Database: CloudDatabase @@ -8,13 +9,42 @@ package protocol CloudContainer: AnyObject, Equatable, Hashable, Senda var containerIdentifier: String? { get } var rawValue: CKContainer { get } var privateCloudDatabase: Database { get } - func accept(_ metadata: CKShare.Metadata) async throws -> CKShare + func accept(_ metadata: ShareMetadata) async throws -> CKShare static func createContainer(identifier containerIdentifier: String) -> Self var sharedCloudDatabase: Database { get } - @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) - func shareMetadata(for url: URL, shouldFetchRootRecord: Bool) async throws -> CKShare.Metadata + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) + func shareMetadata(for share: CKShare, shouldFetchRootRecord: Bool) async throws -> ShareMetadata } +@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) +package struct ShareMetadata: Hashable { + package var containerIdentifier: String + package var hierarchicalRootRecordID: CKRecord.ID? + package var rootRecord: CKRecord? + package var share: CKShare + package var rawValue: CKShare.Metadata? + package init(rawValue: CKShare.Metadata) { + self.containerIdentifier = rawValue.containerIdentifier + self.hierarchicalRootRecordID = rawValue.hierarchicalRootRecordID + self.rootRecord = rawValue.rootRecord + self.share = rawValue.share + self.rawValue = rawValue + } + package init( + containerIdentifier: String, + hierarchicalRootRecordID: CKRecord.ID?, + rootRecord: CKRecord?, + share: CKShare + ) { + self.containerIdentifier = containerIdentifier + self.hierarchicalRootRecordID = hierarchicalRootRecordID + self.rootRecord = rootRecord + self.share = share + self.rawValue = nil + } +} + +@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) extension CloudContainer { package func database(for recordID: CKRecord.ID) -> any CloudDatabase { recordID.zoneID.ownerName == CKCurrentUserDefaultName @@ -23,7 +53,16 @@ extension CloudContainer { } } +@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) extension CKContainer: CloudContainer { + package func accept(_ metadata: ShareMetadata) async throws -> CKShare { + guard let metadata = metadata.rawValue + else { + fatalError("This should never be called with 'ShareMetadata' that has a nil 'rawValue'") + } + return try await self.accept(metadata) + } + package static func createContainer(identifier containerIdentifier: String) -> Self { Self(identifier: containerIdentifier) } @@ -32,16 +71,16 @@ extension CKContainer: CloudContainer { self } - @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) package func shareMetadata( - for url: URL, + for share: CKShare, shouldFetchRootRecord: Bool = false - ) async throws -> CKShare.Metadata { + ) async throws -> ShareMetadata { try await withUnsafeThrowingContinuation { continuation in - let operation = CKFetchShareMetadataOperation(shareURLs: [url]) + let operation = CKFetchShareMetadataOperation(shareURLs: [share.url].compactMap(\.self)) operation.shouldFetchRootRecord = true operation.perShareMetadataResultBlock = { url, result in - continuation.resume(with: result) + continuation.resume(with: result.map(ShareMetadata.init(rawValue:))) } add(operation) } diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift index 5e47bcdf..40448072 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKit+StructuredQueries.swift @@ -243,7 +243,7 @@ extension CKRecord { let column = column as! any WritableTableColumnExpression let didSet: Bool if let value = other[key] as? CKAsset { - didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key]) + didSet = setValue(value, forKey: key, at: other[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 { diff --git a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift index ed301c64..556b9b34 100644 --- a/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift +++ b/Sources/SharingGRDBCore/CloudKit/CloudKitSharing.swift @@ -7,6 +7,7 @@ import SwiftUI import UIKit #endif +@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) public struct SharedRecord: Hashable, Identifiable, Sendable { let container: any CloudContainer public let share: CKShare @@ -124,7 +125,7 @@ extension SyncEngine { let sharedRecord = try await existingShare ?? CKShare( rootRecord: rootRecord, shareID: CKRecord.ID( - recordName: UUID().uuidString, + recordName: "share-\(recordName)", zoneID: rootRecord.recordID.zoneID ) ) diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift index 56fe5bf9..39c11fe5 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudContainer.swift @@ -18,6 +18,12 @@ package final class MockCloudContainer: CloudContainer, CustomDumpReflectable { self.containerIdentifier = containerIdentifier self.privateCloudDatabase = privateCloudDatabase self.sharedCloudDatabase = sharedCloudDatabase + + guard let containerIdentifier else { return } + @Dependency(\.mockCloudContainers) var mockCloudContainers + mockCloudContainers.withValue { storage in + storage[containerIdentifier] = self + } } package func accountStatus() -> CKAccountStatus { @@ -32,12 +38,39 @@ package final class MockCloudContainer: CloudContainer, CustomDumpReflectable { _accountStatus.withValue { $0 } } - package func shareMetadata(for url: URL, shouldFetchRootRecord: Bool) async throws -> CKShare.Metadata { - fatalError() + package func shareMetadata( + for share: CKShare, + shouldFetchRootRecord: Bool + ) async throws -> ShareMetadata { + let database = share.recordID.zoneID.ownerName == CKCurrentUserDefaultName + ? privateCloudDatabase + : sharedCloudDatabase + + let rootRecord: CKRecord? = database.storage.withValue { + $0[share.recordID.zoneID]?.values.first { record in + record.share?.recordID == share.recordID + } + } + + return ShareMetadata( + containerIdentifier: containerIdentifier!, + hierarchicalRootRecordID: rootRecord?.recordID, + rootRecord: shouldFetchRootRecord ? rootRecord : nil, + share: share + ) } - package func accept(_ metadata: CKShare.Metadata) async throws -> CKShare { - fatalError() + package func accept(_ metadata: ShareMetadata) async throws -> CKShare { + guard let rootRecord = metadata.rootRecord + else { + fatalError("Must provide root record in mock shares during tests.") + } + + let (saveResults, _) = try sharedCloudDatabase.modifyRecords( + saving: [metadata.share, rootRecord] + ) + try saveResults.values.forEach { _ = try $0.get() } + return metadata.share } package static func createContainer(identifier containerIdentifier: String) -> MockCloudContainer { @@ -45,7 +78,7 @@ package final class MockCloudContainer: CloudContainer, CustomDumpReflectable { return mockCloudContainers.withValue { storage in let container: MockCloudContainer if let existingContainer = storage[containerIdentifier] { - container = existingContainer + return existingContainer } else { container = MockCloudContainer( accountStatus: .available, @@ -82,7 +115,10 @@ package final class MockCloudContainer: CloudContainer, CustomDumpReflectable { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -private enum MockCloudContainersKey: TestDependencyKey { +private enum MockCloudContainersKey: DependencyKey { + static var liveValue: LockIsolated<[String: MockCloudContainer]> { + LockIsolated<[String: MockCloudContainer]>([:]) + } static var testValue: LockIsolated<[String: MockCloudContainer]> { LockIsolated<[String: MockCloudContainer]>([:]) } diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift index 1fb82e7b..7bf55819 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockCloudDatabase.swift @@ -87,6 +87,22 @@ package final class MockCloudDatabase: CloudDatabase { switch savePolicy { case .ifServerRecordUnchanged: for recordToSave in recordsToSave { + if let share = recordToSave as? CKShare { + let isSavingRootRecord = recordsToSave.contains(where: { $0.share?.recordID == share.recordID }) + let shareWasPreviouslySaved = storage[share.recordID.zoneID]?[share.recordID] != nil + guard shareWasPreviouslySaved || isSavingRootRecord + else { + reportIssue( + """ + An added share is being saved without its rootRecord being saved in the same \ + operation. + """ + ) + saveResults[recordToSave.recordID] = .failure(CKError(.invalidArguments)) + continue + } + } + guard storage[recordToSave.recordID.zoneID] != nil else { saveResults[recordToSave.recordID] = .failure(CKError(.zoneNotFound)) @@ -111,6 +127,7 @@ package final class MockCloudDatabase: CloudDatabase { guard let copy = recordToSave.copy() as? CKRecord else { fatalError("Could not copy CKRecord.") } copy._recordChangeTag = UUID().uuidString + assets.withValue { assets in for key in copy.allKeys() { guard let assetURL = (copy[key] as? CKAsset)?.fileURL @@ -119,6 +136,8 @@ package final class MockCloudDatabase: CloudDatabase { .load(assetURL) } } + + // TODO: this should merge copy's values into storage but not sure how right now. storage[recordToSave.recordID.zoneID]?[recordToSave.recordID] = copy saveResults[recordToSave.recordID] = .success(copy) } @@ -268,7 +287,7 @@ extension MockCloudDatabase: CustomDumpReflectable { } } -@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) private func ckError(forAccountStatus accountStatus: CKAccountStatus) -> CKError { switch accountStatus { case .couldNotDetermine, .restricted, .noAccount: diff --git a/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift index 3bd4507a..07bee97f 100644 --- a/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/Internal/MockSyncEngine.swift @@ -33,7 +33,27 @@ package final class MockSyncEngine: SyncEngineProtocol { } package func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { - // TODO: do something here + let records: [CKRecord] + let zoneIDs: [CKRecordZone.ID] + switch options.scope { + case .all: + zoneIDs = Array(database.storage.keys) + case .allExcluding(let excludedZoneIDs): + zoneIDs = Array(Set(database.storage.keys).subtracting(excludedZoneIDs)) + case .zoneIDs(let includedZoneIDs): + zoneIDs = includedZoneIDs + @unknown default: + fatalError() + } + records = zoneIDs.reduce(into: [CKRecord]()) { accum, zoneID in + accum += database.storage.withValue { + ($0[zoneID]?.values).map { Array($0) } ?? [] + } + } + await delegate.handleEvent( + .fetchedRecordZoneChanges(modifications: records, deletions: []), + syncEngine: self + ) } package func recordZoneChangeBatch( diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 005b4794..92072d43 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -478,22 +478,16 @@ syncEngine?.state.add(pendingRecordZoneChanges: changes) } - // TODO: Possible to get test coverage on this? package func acceptShare(metadata: ShareMetadata) async throws { - guard let metadata = metadata.rawValue - else { - reportIssue("TODO") - return - } guard let rootRecordID = metadata.hierarchicalRootRecordID else { - reportIssue("TODO") + reportIssue("Attempting to share without root record information.") return } let container = type(of: container).createContainer(identifier: metadata.containerIdentifier) _ = try await container.accept(metadata) try await syncEngines.shared?.fetchChanges( - .init( + CKSyncEngine.FetchChangesOptions( scope: .zoneIDs([rootRecordID.zoneID]), operationGroup: nil ) @@ -1253,25 +1247,18 @@ } private func cacheShare(_ share: CKShare) async throws { - // TODO: Instead of getting URL here we can make `shareMetadata(…)` take a share instead of a URL - guard let url = share.url - else { return } - guard - let metadata = try? await container.shareMetadata( - for: url, - shouldFetchRootRecord: true - ) + let metadata = try? await container.shareMetadata(for: share, shouldFetchRootRecord: false) else { // TODO: should we delete this record if it doesn't exist in the container? return } - guard let rootRecord = metadata.rootRecord + guard let rootRecordID = metadata.hierarchicalRootRecordID else { return } try await userDatabase.write { db in try SyncMetadata - .where { $0.recordName.eq(rootRecord.recordID.recordName) } + .where { $0.recordName.eq(rootRecordID.recordName) } .update { $0.share = share } .execute(db) } diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift index fbb0426d..1ddc0b7a 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngineProtocol.swift @@ -27,23 +27,6 @@ package protocol SyncEngineProtocol: AnyObject, Sendable { ) async -> CKSyncEngine.RecordZoneChangeBatch? } -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -package struct ShareMetadata: Hashable { - package var containerIdentifier: String - package var hierarchicalRootRecordID: CKRecord.ID? - package var rawValue: CKShare.Metadata? - package init(rawValue: CKShare.Metadata) { - self.containerIdentifier = rawValue.containerIdentifier - self.hierarchicalRootRecordID = rawValue.hierarchicalRootRecordID - self.rawValue = rawValue - } - package init(containerIdentifier: String, hierarchicalRootRecordID: CKRecord.ID?) { - self.containerIdentifier = containerIdentifier - self.hierarchicalRootRecordID = hierarchicalRootRecordID - self.rawValue = nil - } -} - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package protocol CKSyncEngineStateProtocol: Sendable { var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { get } diff --git a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift index 31822f41..5b94aa00 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -388,5 +388,27 @@ extension BaseCloudKitTests { """ } } + + @Test func saveShareWithoutRootRecord() async throws { + let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "1")) + let share = CKShare(rootRecord: record, shareID: CKRecord.ID(recordName: "share")) + try withKnownIssue { + _ = try syncEngine.modifyRecords(scope: .private, saving: [share]) + } matching: { issue in + issue.description == """ + Issue recorded: An added share is being saved without its rootRecord being saved in the \ + same operation. + """ + } + } + + @Test func saveShareAndRootThenSaveShareAlone() async throws { + let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "1")) + let share = CKShare(rootRecord: record, shareID: CKRecord.ID(recordName: "share")) + _ = try syncEngine.modifyRecords(scope: .private, saving: [share, record]) + + let newShare = try syncEngine.private.database.record(for: CKRecord.ID(recordName: "share")) + _ = try syncEngine.modifyRecords(scope: .private, saving: [newShare]) + } } } diff --git a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift index 775e075b..0d599b0f 100644 --- a/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift +++ b/Tests/SharingGRDBTests/CloudKitTests/SharingTests.swift @@ -61,7 +61,10 @@ extension BaseCloudKitTests { configure: { _ in } ) } - assertInlineSnapshot(of: (error as? any LocalizedError)?.localizedDescription, as: .customDump) { + assertInlineSnapshot( + of: (error as? any LocalizedError)?.localizedDescription, + as: .customDump + ) { """ "The record could not be shared." """ @@ -86,7 +89,10 @@ extension BaseCloudKitTests { configure: { _ in } ) } - assertInlineSnapshot(of: (error as? any LocalizedError)?.localizedDescription, as: .customDump) { + assertInlineSnapshot( + of: (error as? any LocalizedError)?.localizedDescription, + as: .customDump + ) { """ "The record could not be shared." """ @@ -122,7 +128,10 @@ extension BaseCloudKitTests { configure: { _ in } ) } - assertInlineSnapshot(of: (error as? any LocalizedError)?.localizedDescription, as: .customDump) { + assertInlineSnapshot( + of: (error as? any LocalizedError)?.localizedDescription, + as: .customDump + ) { """ "The record could not be shared." """ @@ -226,13 +235,19 @@ extension BaseCloudKitTests { let share = CKShare( rootRecord: remindersListRecord, shareID: CKRecord.ID( - recordName: "Share-\(1)", + recordName: "share-\(remindersListRecord.recordID.recordName)", zoneID: externalZone.zoneID ) ) - try await syncEngine.modifyRecords(scope: .shared, saving: [share]).notify() - try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() + _ = try syncEngine.modifyRecords(scope: .shared, saving: [share, remindersListRecord]) + + let newShare = try syncEngine.shared.database.record(for: share.recordID) + let newRemindersListRecord = try syncEngine.shared.database.record( + for: remindersListRecord.recordID + ) + try await syncEngine.modifyRecords(scope: .shared, saving: [newShare]).notify() + try await syncEngine.modifyRecords(scope: .shared, saving: [newRemindersListRecord]).notify() assertInlineSnapshot(of: syncEngine.container, as: .customDump) { """ @@ -245,7 +260,7 @@ extension BaseCloudKitTests { databaseScope: .shared, storage: [ [0]: CKRecord( - recordID: CKRecord.ID(Share-1/external.zone/external.owner), + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), recordType: "cloudkit.share", parent: nil, share: nil @@ -254,7 +269,7 @@ extension BaseCloudKitTests { recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), recordType: "remindersLists", parent: nil, - share: CKReference(recordID: CKRecord.ID(Share-1/external.zone/external.owner)), + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), id: 1, isCompleted: 0, title: "Personal" @@ -282,20 +297,25 @@ extension BaseCloudKitTests { recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), recordType: "remindersLists", parent: nil, - share: CKReference(recordID: CKRecord.ID(Share-1/external.zone/external.owner)) + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)) ), _lastKnownServerRecordAllFields: CKRecord( recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), recordType: "remindersLists", parent: nil, - share: CKReference(recordID: CKRecord.ID(Share-1/external.zone/external.owner)), + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), id: 1, isCompleted: 0, title: "Personal" ), - share: nil, + share: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), _isDeleted: false, - isShared: false, + isShared: true, userModificationDate: Date(1970-01-01T00:00:00.000Z) ) ] @@ -402,7 +422,10 @@ extension BaseCloudKitTests { reminderRecord.setValue(1, forKey: "remindersListID", at: now) reminderRecord.parent = CKRecord.Reference(record: remindersListRecord, action: .none) - try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord, reminderRecord]).notify() + try await syncEngine.modifyRecords( + scope: .shared, + saving: [remindersListRecord, reminderRecord] + ).notify() try await withDependencies { $0.datetime.now.addTimeInterval(60) @@ -437,6 +460,227 @@ extension BaseCloudKitTests { """ } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func share() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + remindersList + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let sharedRecord = try await syncEngine.share(record: remindersList, configure: { _ in }) + + try await userDatabase.read { db in + let metadata = try #require( + try SyncMetadata + .where { $0.recordPrimaryKey.eq("1") } + .fetchOne(db) + ) + #expect(metadata.share?.recordID == sharedRecord.share.recordID) + } + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/co.pointfree.SQLiteData.defaultZone/__defaultOwner__)) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func acceptShare() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + try await userDatabase.read { db in + let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) + let metadata = try #require( + try SyncMetadata + .where { $0.recordName.eq(remindersListRecord.recordID.recordName) } + .fetchOne(db) + ) + #expect(remindersList.title == "Personal") + #expect( + metadata.share?.recordID.recordName == "share-\(remindersListRecord.recordID.recordName)" + ) + } + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), + id: 1, + title: "Personal" + ) + ] + ) + ) + """ + } + } + + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func acceptShareCreateReminder() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + try await userDatabase.read { db in + let metadata = try #require( + try SyncMetadata + .where { $0.recordName.eq("1:reminders") } + .fetchOne(db) + ) + #expect(metadata.parentRecordName == "1:remindersLists") + } + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:reminders/external.zone/external.owner), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), + id: 1, + title: "Personal" + ) + ] + ) + ) + """ + } + } } }