Skip to content

Commit 7fb8907

Browse files
authored
Make SyncEngine more testable (#300)
* Improvements to testability of SyncEngine. * wip * wip * wip * simplify * more simplify * wip * clean up * wip * wip * wip * wip * wip * wip
1 parent caa37bb commit 7fb8907

File tree

9 files changed

+199
-115
lines changed

9 files changed

+199
-115
lines changed

Examples/Reminders/Schema.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,13 @@ func appDatabase() throws -> any DatabaseWriter {
140140
db.add(function: $handleReminderStatusUpdate)
141141
#if DEBUG
142142
db.trace(options: .profile) {
143-
if context == .live {
143+
switch context {
144+
case .live:
144145
logger.debug("\($0.expandedDescription)")
145-
} else {
146+
case .preview:
146147
print("\($0.expandedDescription)")
148+
case .test:
149+
break
147150
}
148151
}
149152
#endif

Examples/RemindersTests/Internal.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import Testing
1010
.dependency(\.continuousClock, ImmediateClock()),
1111
.dependency(\.date.now, Date(timeIntervalSince1970: 1_234_567_890)),
1212
.dependency(\.uuid, .incrementing),
13-
.dependencies { try $0.bootstrapDatabase() },
13+
.dependencies {
14+
try $0.bootstrapDatabase()
15+
try await $0.defaultSyncEngine.sendChanges()
16+
},
1417
.snapshots(record: .failed)
1518
)
1619
struct BaseTestSuite {}

Examples/RemindersTests/RemindersListsTests.swift

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import Dependencies
12
import DependenciesTestSupport
3+
import Foundation
24
import InlineSnapshotTesting
35
import SnapshotTestingCustomDump
46
import Testing
@@ -8,6 +10,9 @@ import Testing
810
extension BaseTestSuite {
911
@MainActor
1012
struct RemindersListsTests {
13+
@Dependency(\.defaultDatabase) var database
14+
@Dependency(\.defaultSyncEngine) var syncEngine
15+
1116
@Test func basics() async throws {
1217
let model = RemindersListsModel()
1318
try await model.$remindersLists.load()
@@ -100,5 +105,54 @@ extension BaseTestSuite {
100105
"""
101106
}
102107
}
108+
109+
@Test func share() async throws {
110+
let model = RemindersListsModel()
111+
112+
let personalRemindersList = try #require(
113+
try await database.read { db in
114+
try RemindersList.find(UUID(0)).fetchOne(db)
115+
}
116+
)
117+
let _ = try await syncEngine.share(record: personalRemindersList, configure: { _ in })
118+
119+
try await model.$remindersLists.load()
120+
assertInlineSnapshot(of: model.remindersLists, as: .customDump) {
121+
"""
122+
[
123+
[0]: RemindersListsModel.ReminderListState(
124+
remindersCount: 4,
125+
remindersList: RemindersList(
126+
id: UUID(00000000-0000-0000-0000-000000000000),
127+
color: 1218047999,
128+
position: 1,
129+
title: "Personal"
130+
),
131+
share: CKShare()
132+
),
133+
[1]: RemindersListsModel.ReminderListState(
134+
remindersCount: 2,
135+
remindersList: RemindersList(
136+
id: UUID(00000000-0000-0000-0000-000000000001),
137+
color: 3985191935,
138+
position: 2,
139+
title: "Family"
140+
),
141+
share: nil
142+
),
143+
[2]: RemindersListsModel.ReminderListState(
144+
remindersCount: 2,
145+
remindersList: RemindersList(
146+
id: UUID(00000000-0000-0000-0000-000000000002),
147+
color: 2992493567,
148+
position: 3,
149+
title: "Business"
150+
),
151+
share: nil
152+
)
153+
]
154+
"""
155+
}
156+
}
103157
}
104158
}

Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#if canImport(CloudKit)
22
import CloudKit
3+
import IssueReporting
34
import OrderedCollections
45

56
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
@@ -57,11 +58,19 @@
5758
}
5859

5960
package func sendChanges(_ options: CKSyncEngine.SendChangesOptions) async throws {
60-
guard
61-
!parentSyncEngine.syncEngine(for: database.databaseScope).state.pendingRecordZoneChanges
62-
.isEmpty
63-
else { return }
64-
try await parentSyncEngine.processPendingRecordZoneChanges(scope: database.databaseScope)
61+
62+
if !parentSyncEngine.syncEngine(for: database.databaseScope).state.pendingDatabaseChanges
63+
.isEmpty
64+
{
65+
66+
try await parentSyncEngine.processPendingDatabaseChanges(scope: database.databaseScope)
67+
}
68+
if !parentSyncEngine.syncEngine(for: database.databaseScope).state.pendingRecordZoneChanges
69+
.isEmpty
70+
{
71+
72+
try await parentSyncEngine.processPendingRecordZoneChanges(scope: database.databaseScope)
73+
}
6574
}
6675

6776
package func recordZoneChangeBatch(
@@ -270,6 +279,97 @@
270279
)
271280
}
272281

282+
package func processPendingDatabaseChanges(
283+
scope: CKDatabase.Scope,
284+
fileID: StaticString = #fileID,
285+
filePath: StaticString = #filePath,
286+
line: UInt = #line,
287+
column: UInt = #column
288+
) async throws {
289+
let syncEngine = syncEngine(for: scope)
290+
guard !syncEngine.state.pendingDatabaseChanges.isEmpty
291+
else {
292+
reportIssue(
293+
"Processing empty set of database changes.",
294+
fileID: fileID,
295+
filePath: filePath,
296+
line: line,
297+
column: column
298+
)
299+
return
300+
}
301+
guard try await container.accountStatus() == .available
302+
else {
303+
reportIssue(
304+
"User must be logged in to process pending changes.",
305+
fileID: fileID,
306+
filePath: filePath,
307+
line: line,
308+
column: column
309+
)
310+
return
311+
}
312+
313+
var zonesToSave: [CKRecordZone] = []
314+
var zoneIDsToDelete: [CKRecordZone.ID] = []
315+
for pendingDatabaseChange in syncEngine.state.pendingDatabaseChanges {
316+
switch pendingDatabaseChange {
317+
case .saveZone(let zone):
318+
zonesToSave.append(zone)
319+
case .deleteZone(let zoneID):
320+
zoneIDsToDelete.append(zoneID)
321+
@unknown default:
322+
fatalError("Unsupported pendingDatabaseChange: \(pendingDatabaseChange)")
323+
}
324+
}
325+
let results:
326+
(
327+
saveResults: [CKRecordZone.ID: Result<CKRecordZone, any Error>],
328+
deleteResults: [CKRecordZone.ID: Result<Void, any Error>]
329+
) = try syncEngine.database.modifyRecordZones(
330+
saving: zonesToSave,
331+
deleting: zoneIDsToDelete
332+
)
333+
var savedZones: [CKRecordZone] = []
334+
var failedZoneSaves: [(zone: CKRecordZone, error: CKError)] = []
335+
var deletedZoneIDs: [CKRecordZone.ID] = []
336+
var failedZoneDeletes: [CKRecordZone.ID: CKError] = [:]
337+
for (zoneID, saveResult) in results.saveResults {
338+
switch saveResult {
339+
case .success(let zone):
340+
savedZones.append(zone)
341+
case .failure(let error as CKError):
342+
failedZoneSaves.append((zonesToSave.first(where: { $0.zoneID == zoneID })!, error))
343+
case .failure(let error):
344+
reportIssue("Error thrown not CKError: \(error)")
345+
}
346+
}
347+
for (zoneID, deleteResult) in results.deleteResults {
348+
switch deleteResult {
349+
case .success:
350+
deletedZoneIDs.append(zoneID)
351+
case .failure(let error as CKError):
352+
failedZoneDeletes[zoneID] = error
353+
case .failure(let error):
354+
reportIssue("Error thrown not CKError: \(error)")
355+
}
356+
}
357+
358+
syncEngine.state.remove(pendingDatabaseChanges: savedZones.map { .saveZone($0) })
359+
syncEngine.state.remove(pendingDatabaseChanges: deletedZoneIDs.map { .deleteZone($0) })
360+
361+
await syncEngine.parentSyncEngine
362+
.handleEvent(
363+
.sentDatabaseChanges(
364+
savedZones: savedZones,
365+
failedZoneSaves: failedZoneSaves,
366+
deletedZoneIDs: deletedZoneIDs,
367+
failedZoneDeletes: failedZoneDeletes
368+
),
369+
syncEngine: syncEngine
370+
)
371+
}
372+
273373
package var `private`: MockSyncEngine {
274374
syncEngines.private as! MockSyncEngine
275375
}

Sources/SQLiteData/CloudKit/SyncEngine.swift

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
private let observationRegistrar = ObservationRegistrar()
3838
private let notificationsObserver = LockIsolated<(any NSObjectProtocol)?>(nil)
3939
private let activityCounts = LockIsolated(ActivityCounts())
40+
private let startTask = LockIsolated<Task<Void, Never>?>(nil)
4041

4142
/// The error message used when a write occurs to a record for which the current user does not
4243
/// have permission.
@@ -87,7 +88,7 @@
8788
privateTables: repeat (each T2).Type,
8889
containerIdentifier: String? = nil,
8990
defaultZone: CKRecordZone = CKRecordZone(zoneName: "co.pointfree.SQLiteData.defaultZone"),
90-
startImmediately: Bool = DependencyValues._current.context == .live,
91+
startImmediately: Bool = true,
9192
delegate: (any SyncEngineDelegate)? = nil,
9293
logger: Logger = isTesting
9394
? Logger(.disabled) : Logger(subsystem: "SQLiteData", category: "CloudKit")
@@ -117,12 +118,15 @@
117118
else {
118119
let privateDatabase = MockCloudDatabase(databaseScope: .private)
119120
let sharedDatabase = MockCloudDatabase(databaseScope: .shared)
121+
let container = MockCloudContainer(
122+
containerIdentifier: containerIdentifier ?? "iCloud.co.pointfree.SQLiteData.Tests",
123+
privateCloudDatabase: privateDatabase,
124+
sharedCloudDatabase: sharedDatabase
125+
)
126+
privateDatabase.set(container: container)
127+
sharedDatabase.set(container: container)
120128
try self.init(
121-
container: MockCloudContainer(
122-
containerIdentifier: containerIdentifier ?? "iCloud.co.pointfree.SQLiteData.Tests",
123-
privateCloudDatabase: privateDatabase,
124-
sharedCloudDatabase: sharedDatabase
125-
),
129+
container: container,
126130
defaultZone: defaultZone,
127131
defaultSyncEngines: { _, syncEngine in
128132
(
@@ -487,10 +491,14 @@
487491
($0.tableName, $0)
488492
}
489493
)
490-
return Task {
494+
495+
let startTask = Task<Void, Never> {
491496
await withErrorReporting(.sqliteDataCloudKitFailure) {
492497
guard try await container.accountStatus() == .available
493498
else { return }
499+
syncEngines.withValue {
500+
$0.private?.state.add(pendingDatabaseChanges: [.saveZone(defaultZone)])
501+
}
494502
try await uploadRecordsToCloudKit(
495503
previousRecordTypeByTableName: previousRecordTypeByTableName,
496504
currentRecordTypeByTableName: currentRecordTypeByTableName
@@ -502,8 +510,10 @@
502510
try await cacheUserTables(recordTypes: currentRecordTypes)
503511
}
504512
}
513+
self.startTask.withValue { $0 = startTask }
514+
return startTask
505515
}
506-
516+
507517
/// Fetches pending remote changes from the server.
508518
///
509519
/// Use this method to ensure the sync engine immediately fetches all pending remote changes
@@ -515,6 +525,7 @@
515525
public func fetchChanges(
516526
_ options: CKSyncEngine.FetchChangesOptions = CKSyncEngine.FetchChangesOptions()
517527
) async throws {
528+
await startTask.withValue(\.self)?.value
518529
let (privateSyncEngine, sharedSyncEngine) = syncEngines.withValue {
519530
($0.private, $0.shared)
520531
}
@@ -524,7 +535,7 @@
524535
async let shared: Void = sharedSyncEngine.fetchChanges(options)
525536
_ = try await (`private`, shared)
526537
}
527-
538+
528539
/// Sends pending local changes to the server.
529540
///
530541
/// Use this method to ensure the sync engine sends all pending local changes to the server
@@ -536,6 +547,7 @@
536547
public func sendChanges(
537548
_ options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions()
538549
) async throws {
550+
await startTask.withValue(\.self)?.value
539551
let (privateSyncEngine, sharedSyncEngine) = syncEngines.withValue {
540552
($0.private, $0.shared)
541553
}
@@ -545,7 +557,7 @@
545557
async let shared: Void = sharedSyncEngine.sendChanges(options)
546558
_ = try await (`private`, shared)
547559
}
548-
560+
549561
/// Synchronizes local and remote pending changes.
550562
///
551563
/// Use this method to ensure the sync engine immediately fetches all pending remote changes

Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@
399399
try syncEngine.tearDownSyncEngine()
400400
try syncEngine.setUpSyncEngine()
401401
try await syncEngine.start()
402+
try await syncEngine.processPendingDatabaseChanges(scope: .private)
402403
let recordTypesAfterReSetup = try await syncEngine.metadatabase.read { db in
403404
try RecordType.all.fetchAll(db)
404405
}
@@ -422,6 +423,7 @@
422423
}
423424
try syncEngine.setUpSyncEngine()
424425
try await syncEngine.start()
426+
try await syncEngine.processPendingDatabaseChanges(scope: .private)
425427

426428
let recordTypesAfterMigration = try await syncEngine.metadatabase.read { db in
427429
try RecordType.order(by: \.tableName).fetchAll(db)

Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2784,6 +2784,7 @@
27842784
}
27852785

27862786
try await syncEngine.start()
2787+
try await syncEngine.processPendingDatabaseChanges(scope: .private)
27872788
try await syncEngine.processPendingRecordZoneChanges(scope: .private)
27882789
try await syncEngine.processPendingRecordZoneChanges(scope: .shared)
27892790

0 commit comments

Comments
 (0)