PendingChangeReapTests.swift (14552B)
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 durable 10 /// outbox row is missing/unreadable, or a deleted Core Data entity) must be 11 /// 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 = CloudContainer.container 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 durable outbox row is missing or unreadable. 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 96 @Test("Atomic Decision conflict re-enqueues its reconstructable Ping sibling") 97 func atomicDecisionConflictRecoversPing() async throws { 98 let persistence = makeTestPersistence() 99 let engine = await makeEngine(persistence: persistence) 100 let zoneID = CKRecordZone.ID( 101 zoneName: "friend-atomic-recovery", 102 ownerName: "_friend" 103 ) 104 105 let decisionName = RecordSerializer.decisionRecordName( 106 kind: RecordSerializer.nameDecisionKind, 107 key: "_localAuthor" 108 ) 109 let decisionID = CKRecord.ID(recordName: decisionName, zoneID: zoneID) 110 #expect(await engine.enqueueFriendDecision( 111 kind: RecordSerializer.nameDecisionKind, 112 key: "_localAuthor", 113 payload: "Local", 114 version: 1, 115 friendZoneID: zoneID, 116 friendZoneScope: .shared 117 )) 118 try await engine.enqueueFriendZonePing( 119 kind: .invite, 120 gameID: UUID(), 121 gameTitle: "Atomic", 122 authorID: "_localAuthor", 123 playerName: "Local", 124 addressee: "_friend", 125 friendZoneID: zoneID, 126 friendZoneScope: .shared, 127 payload: #"{"gameShareURL":"https://example.com/share"}"# 128 ) 129 130 let pingName = try #require( 131 await engine.pendingPingRecordNamesForTesting().first 132 ) 133 let pingID = CKRecord.ID(recordName: pingName, zoneID: zoneID) 134 135 // A sent atomic batch falls out of CKSyncEngine's pending state. The 136 // Decision's serverRecordChanged error is causal and settles because 137 // the write-once record already exists; the Ping receives only 138 // batchRequestFailed and must be added back explicitly afterward. 139 await engine.recoverAtomicBatchForTesting( 140 settledDecisionID: decisionID, 141 batchFailedRecordIDs: [pingID], 142 scope: .shared 143 ) 144 145 let pending = await engine.pendingSaveRecordNames(scope: .shared) 146 #expect(!pending.contains(decisionName)) 147 #expect(pending.contains(pingName)) 148 #expect(await engine.pendingPingRecordNamesForTesting() == [pingName]) 149 150 // The retry remains materializable rather than being reaped by the 151 // next record-provider pass. 152 _ = await engine.makeRecordZoneChangeBatch(forTestingScope: .shared) 153 #expect(await engine.pendingSaveRecordNames(scope: .shared).contains(pingName)) 154 } 155 156 @Test("Pending Ping outbox survives SyncEngine restart") 157 func pendingPingOutboxSurvivesRestart() async throws { 158 let persistence = makeTestPersistence() 159 let firstEngine = await makeEngine(persistence: persistence) 160 let zoneID = CKRecordZone.ID( 161 zoneName: "friend-durable-outbox", 162 ownerName: "_friend" 163 ) 164 try await firstEngine.enqueueFriendZonePing( 165 kind: .invite, 166 gameID: UUID(), 167 gameTitle: "Durable", 168 authorID: "_localAuthor", 169 playerName: "Local", 170 addressee: "_friend", 171 friendZoneID: zoneID, 172 friendZoneScope: .shared, 173 payload: #"{"gameShareURL":"https://example.com/share"}"# 174 ) 175 let recordName = try #require( 176 await firstEngine.pendingPingRecordNamesForTesting().first 177 ) 178 179 // A new engine mirrors an app relaunch. Its CKSyncEngine state may or 180 // may not have serialized the enqueue before termination, so startup 181 // restores the outbox row and independently re-adds the stable ID. 182 let relaunchedEngine = await makeEngine(persistence: persistence) 183 #expect(await relaunchedEngine.pendingPingRecordNamesForTesting() == [recordName]) 184 #expect( 185 await relaunchedEngine.pendingSaveRecordNames(scope: .shared) 186 .contains(recordName) 187 ) 188 } 189 190 @Test("Invite delivery advances from queued to CloudKit-confirmed sent") 191 func inviteDeliveryPhases() async throws { 192 let persistence = makeTestPersistence() 193 let engine = await makeEngine(persistence: persistence) 194 let deliveries = InviteDeliveryStore() 195 let gameID = UUID() 196 let friendAuthorID = "_friend" 197 let delivery = deliveries.delivery( 198 gameID: gameID, 199 friendAuthorID: friendAuthorID 200 ) 201 var rollbackFlags: [Bool] = [] 202 #expect(delivery.phase == .idle) 203 204 await engine.setOnPingDeliveryUpdate { update in 205 rollbackFlags.append(update.rollbackParticipantOnFailure) 206 switch update.state { 207 case .queued: 208 deliveries.markQueued( 209 recordName: update.recordName, 210 gameID: update.gameID, 211 friendAuthorID: update.addressee 212 ) 213 case .sent: 214 deliveries.markSent( 215 recordName: update.recordName, 216 gameID: update.gameID, 217 friendAuthorID: update.addressee 218 ) 219 case .failed: 220 deliveries.markFailed( 221 recordName: update.recordName, 222 gameID: update.gameID, 223 friendAuthorID: update.addressee, 224 failure: update.failure ?? .other 225 ) 226 } 227 } 228 229 let zoneID = CKRecordZone.ID( 230 zoneName: "friend-delivery-phases", 231 ownerName: friendAuthorID 232 ) 233 try await engine.enqueueFriendZonePing( 234 kind: .invite, 235 gameID: gameID, 236 gameTitle: "Phases", 237 authorID: "_localAuthor", 238 playerName: "Local", 239 addressee: friendAuthorID, 240 friendZoneID: zoneID, 241 friendZoneScope: .shared, 242 payload: #"{"gameShareURL":"https://example.com/share"}"#, 243 rollbackParticipantOnFailure: true 244 ) 245 let recordName = try #require( 246 await engine.pendingPingRecordNamesForTesting().first 247 ) 248 #expect(delivery.phase == .queued) 249 #expect(rollbackFlags == [true]) 250 251 await engine.confirmPendingPingForTesting(recordName: recordName) 252 #expect(delivery.phase == .sent) 253 #expect(await engine.pendingPingRecordNamesForTesting().isEmpty) 254 255 // A late/replayed queued update cannot regress a confirmed send. 256 deliveries.markQueued( 257 recordName: recordName, 258 gameID: gameID, 259 friendAuthorID: friendAuthorID 260 ) 261 #expect(delivery.phase == .sent) 262 } 263 264 @Test("Invitation failure detail remains scoped to one game and friend") 265 func invitationFailureState() { 266 let deliveries = InviteDeliveryStore() 267 let gameID = UUID() 268 let otherGameID = UUID() 269 let friendAuthorID = "_friend" 270 271 deliveries.markFailed( 272 recordName: "ping-failed", 273 gameID: gameID, 274 friendAuthorID: friendAuthorID, 275 failure: .quotaExceeded 276 ) 277 let delivery = deliveries.delivery( 278 gameID: gameID, 279 friendAuthorID: friendAuthorID 280 ) 281 #expect(delivery.phase == .failed) 282 #expect(delivery.failure == .quotaExceeded) 283 284 // The same friend on a different game is a separate invitation, and the 285 // announcement each one retracts is keyed the same way. 286 let other = deliveries.delivery( 287 gameID: otherGameID, 288 friendAuthorID: friendAuthorID 289 ) 290 #expect(other.phase == .idle) 291 #expect(other.failure == nil) 292 #expect( 293 InviteDeliveryStore.failureAnnouncementID( 294 gameID: gameID, 295 friendAuthorID: friendAuthorID 296 ) != InviteDeliveryStore.failureAnnouncementID( 297 gameID: otherGameID, 298 friendAuthorID: friendAuthorID 299 ) 300 ) 301 } 302 303 @Test("Confirmation timeout leaves the invite queued and non-destructive") 304 func inviteConfirmationTimeoutStaysQueued() async throws { 305 let persistence = makeTestPersistence() 306 let engine = await makeEngine(persistence: persistence) 307 await engine.setPingConfirmationTimeoutForTesting(.milliseconds(50)) 308 let deliveries = InviteDeliveryStore() 309 let gameID = UUID() 310 let friendAuthorID = "_friend" 311 let delivery = deliveries.delivery( 312 gameID: gameID, 313 friendAuthorID: friendAuthorID 314 ) 315 316 await engine.setOnPingDeliveryUpdate { update in 317 switch update.state { 318 case .queued: 319 deliveries.markQueued( 320 recordName: update.recordName, 321 gameID: update.gameID, 322 friendAuthorID: update.addressee 323 ) 324 case .sent: 325 deliveries.markSent( 326 recordName: update.recordName, 327 gameID: update.gameID, 328 friendAuthorID: update.addressee 329 ) 330 case .failed: 331 deliveries.markFailed( 332 recordName: update.recordName, 333 gameID: update.gameID, 334 friendAuthorID: update.addressee, 335 failure: update.failure ?? .other 336 ) 337 } 338 } 339 340 let zoneID = CKRecordZone.ID( 341 zoneName: "friend-timeout", 342 ownerName: friendAuthorID 343 ) 344 // The confirmed-send wait gives up after the (shortened) patience 345 // window and reports `.deliveryPending` rather than blocking forever. 346 await #expect(throws: SyncEngine.PingOutboxError.deliveryPending) { 347 try await engine.enqueueFriendZonePing( 348 kind: .invite, 349 gameID: gameID, 350 gameTitle: "Timeout", 351 authorID: "_localAuthor", 352 playerName: "Local", 353 addressee: friendAuthorID, 354 friendZoneID: zoneID, 355 friendZoneScope: .shared, 356 payload: #"{"gameShareURL":"https://example.com/share"}"#, 357 rollbackParticipantOnFailure: true, 358 waitForServerConfirmation: true 359 ) 360 } 361 362 // Non-destructive: the Ping is still queued and never marked failed. 363 let recordName = try #require( 364 await engine.pendingPingRecordNamesForTesting().first 365 ) 366 #expect(delivery.phase == .queued) 367 368 // The durable outbox still resolves the true outcome afterwards. 369 await engine.confirmPendingPingForTesting(recordName: recordName) 370 #expect(delivery.phase == .sent) 371 #expect(await engine.pendingPingRecordNamesForTesting().isEmpty) 372 } 373 }