ZoneOrphaningTests.swift (6063B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import Testing 5 6 @testable import Crossmate 7 8 /// Pins down the recovery path for per-record `.zoneNotFound` failures 9 /// discovered while pushing changes. Without it the engine retried the same 10 /// record on every push and `Last Error` stayed stuck on 11 /// `Failed to send changes` (see `iphone-reset.log`). 12 @Suite("ZoneOrphaning", .serialized) 13 @MainActor 14 struct ZoneOrphaningTests { 15 16 private func makeEngine( 17 persistence: PersistenceController 18 ) async -> SyncEngine { 19 let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") 20 let engine = SyncEngine(container: container, persistence: persistence) 21 await engine.start() 22 return engine 23 } 24 25 private func makePrivateGame( 26 in ctx: NSManagedObjectContext 27 ) throws -> (UUID, String) { 28 let id = UUID() 29 let zoneName = "game-\(id.uuidString)" 30 let entity = GameEntity(context: ctx) 31 entity.id = id 32 entity.title = "Private" 33 entity.puzzleSource = "" 34 entity.createdAt = Date() 35 entity.updatedAt = Date() 36 entity.ckRecordName = zoneName 37 entity.ckZoneName = zoneName 38 entity.databaseScope = 0 39 try ctx.save() 40 return (id, zoneName) 41 } 42 43 private func makeSharedGame( 44 in ctx: NSManagedObjectContext 45 ) throws -> (UUID, String, String) { 46 let id = UUID() 47 let zoneName = "game-\(id.uuidString)" 48 let owner = "_someOtherUser" 49 let entity = GameEntity(context: ctx) 50 entity.id = id 51 entity.title = "Shared" 52 entity.puzzleSource = "" 53 entity.createdAt = Date() 54 entity.updatedAt = Date() 55 entity.ckRecordName = zoneName 56 entity.ckZoneName = zoneName 57 entity.ckZoneOwnerName = owner 58 entity.databaseScope = 1 59 try ctx.save() 60 return (id, zoneName, owner) 61 } 62 63 @Test("Private orphaning hard-deletes the game and clears its pending sends") 64 func privateOrphaning() async throws { 65 let persistence = makeTestPersistence() 66 let ctx = persistence.viewContext 67 let (gameID, zoneName) = try makePrivateGame(in: ctx) 68 let engine = await makeEngine(persistence: persistence) 69 70 await engine.enqueueGame(ckRecordName: zoneName) 71 let beforeNames = await engine.pendingSaveRecordNames(scope: .private) 72 #expect(beforeNames.contains(zoneName)) 73 74 var removed: [UUID] = [] 75 await engine.setOnGameRemoved { id in removed.append(id) } 76 77 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName) 78 await engine.applyZoneOrphaning([zoneID], isPrivate: true) 79 80 let afterNames = await engine.pendingSaveRecordNames(scope: .private) 81 #expect(!afterNames.contains(zoneName)) 82 #expect(removed == [gameID]) 83 84 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 85 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 86 #expect(try ctx.fetch(req).isEmpty) 87 } 88 89 @Test("Shared orphaning marks the game access-revoked and clears its pending sends") 90 func sharedOrphaning() async throws { 91 let persistence = makeTestPersistence() 92 let ctx = persistence.viewContext 93 let (gameID, zoneName, owner) = try makeSharedGame(in: ctx) 94 let engine = await makeEngine(persistence: persistence) 95 96 await engine.enqueuePlayerRecord(gameID: gameID, authorID: "_localAuthor") 97 let beforeNames = await engine.pendingSaveRecordNames(scope: .shared) 98 let playerRecordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: "_localAuthor") 99 #expect(beforeNames.contains(playerRecordName)) 100 101 var revoked: [UUID] = [] 102 await engine.setOnGameAccessRevoked { id in revoked.append(id) } 103 104 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: owner) 105 await engine.applyZoneOrphaning([zoneID], isPrivate: false) 106 107 let afterNames = await engine.pendingSaveRecordNames(scope: .shared) 108 #expect(!afterNames.contains(playerRecordName)) 109 #expect(revoked == [gameID]) 110 111 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 112 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 113 let entity = try #require(try ctx.fetch(req).first) 114 ctx.refresh(entity, mergeChanges: true) 115 #expect(entity.isAccessRevoked == true) 116 } 117 118 @Test("Already-revoked shared game does not re-fire the callback") 119 func sharedOrphaningIsIdempotent() async throws { 120 let persistence = makeTestPersistence() 121 let ctx = persistence.viewContext 122 let (gameID, zoneName, owner) = try makeSharedGame(in: ctx) 123 // Simulate the prior fetch-side path having already marked it revoked. 124 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 125 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 126 let entity = try #require(try ctx.fetch(req).first) 127 entity.isAccessRevoked = true 128 try ctx.save() 129 130 let engine = await makeEngine(persistence: persistence) 131 132 var revoked: [UUID] = [] 133 await engine.setOnGameAccessRevoked { id in revoked.append(id) } 134 135 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: owner) 136 await engine.applyZoneOrphaning([zoneID], isPrivate: false) 137 138 #expect(revoked.isEmpty) 139 } 140 141 @Test("Unknown orphan zones are ignored without error") 142 func unknownZoneIsNoOp() async throws { 143 let persistence = makeTestPersistence() 144 let engine = await makeEngine(persistence: persistence) 145 146 var removed: [UUID] = [] 147 await engine.setOnGameRemoved { id in removed.append(id) } 148 149 let zoneID = CKRecordZone.ID( 150 zoneName: "game-\(UUID().uuidString)", 151 ownerName: CKCurrentUserDefaultName 152 ) 153 await engine.applyZoneOrphaning([zoneID], isPrivate: true) 154 155 #expect(removed.isEmpty) 156 } 157 }