PendingChangeReapTests.swift (3762B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import Testing 5 6 @testable import Crossmate 7 8 /// Pins down the reap path in `makeRecordZoneChangeBatch`. A pending 9 /// `.saveRecord` whose record can't be reconstructed (a ping whose in-memory 10 /// `pendingPings` payload was lost across a relaunch, or a deleted Core Data 11 /// entity) must be dropped from the engine's persisted pending changes rather 12 /// than left queued forever. Without this the device showed a permanent 13 /// `Pending Changes: 1` that never drained and surfaced no error — every push 14 /// "succeeded" while silently sending nothing (see the stuck-ping log). 15 @Suite("PendingChangeReap", .serialized) 16 @MainActor 17 struct PendingChangeReapTests { 18 19 private func makeEngine( 20 persistence: PersistenceController 21 ) async -> SyncEngine { 22 let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") 23 let engine = SyncEngine(container: container, persistence: persistence) 24 await engine.start() 25 return engine 26 } 27 28 private func makePrivateGame( 29 in ctx: NSManagedObjectContext 30 ) throws -> (UUID, String) { 31 let id = UUID() 32 let zoneName = "game-\(id.uuidString)" 33 let entity = GameEntity(context: ctx) 34 entity.id = id 35 entity.title = "Private" 36 entity.puzzleSource = "" 37 entity.createdAt = Date() 38 entity.updatedAt = Date() 39 entity.ckRecordName = zoneName 40 entity.ckZoneName = zoneName 41 entity.databaseScope = 0 42 try ctx.save() 43 return (id, zoneName) 44 } 45 46 @Test("An un-buildable pending save is reaped instead of queued forever") 47 func unbuildableSaveIsReaped() async throws { 48 let persistence = makeTestPersistence() 49 let ctx = persistence.viewContext 50 let (gameID, zoneName) = try makePrivateGame(in: ctx) 51 let engine = await makeEngine(persistence: persistence) 52 53 await engine.enqueueGame(ckRecordName: zoneName) 54 let before = await engine.pendingSaveRecordNames(scope: .private) 55 #expect(before.contains(zoneName)) 56 57 // Delete the backing entity so `buildRecord` can no longer 58 // reconstruct the `game-` record — the same dead-end the engine hits 59 // for a ping whose payload didn't survive a relaunch. 60 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 61 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 62 let entity = try #require(try ctx.fetch(req).first) 63 ctx.delete(entity) 64 try ctx.save() 65 66 _ = await engine.makeRecordZoneChangeBatch(forTestingScope: .private) 67 68 let after = await engine.pendingSaveRecordNames(scope: .private) 69 #expect(!after.contains(zoneName)) 70 } 71 72 @Test("A buildable pending ping is preserved, not reaped") 73 func buildablePingIsPreserved() async throws { 74 let persistence = makeTestPersistence() 75 let ctx = persistence.viewContext 76 let (gameID, _) = try makePrivateGame(in: ctx) 77 let engine = await makeEngine(persistence: persistence) 78 79 await engine.enqueuePing( 80 kind: .join, 81 gameID: gameID, 82 authorID: "_localAuthor", 83 playerName: "Local" 84 ) 85 let before = await engine.pendingSaveRecordNames(scope: .private) 86 let pingName = try #require(before.first { $0.hasPrefix("ping-") }) 87 88 // The payload is still in `pendingPings`, so the record builds — the 89 // reap must not fire just because a record was materialized. 90 _ = await engine.makeRecordZoneChangeBatch(forTestingScope: .private) 91 92 let after = await engine.pendingSaveRecordNames(scope: .private) 93 #expect(after.contains(pingName)) 94 } 95 }