ZoneOrphaningTests.swift (7184B)
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 = CloudContainer.container 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("Private orphaning clears queued ping payloads for the zone") 90 func privateOrphaningClearsQueuedPings() async throws { 91 let persistence = makeTestPersistence() 92 let ctx = persistence.viewContext 93 let (gameID, zoneName) = try makePrivateGame(in: ctx) 94 let engine = await makeEngine(persistence: persistence) 95 96 await engine.enqueuePing( 97 kind: .join, 98 gameID: gameID, 99 authorID: "_localAuthor", 100 playerName: "Local" 101 ) 102 let beforeNames = await engine.pendingSaveRecordNames(scope: .private) 103 let pingRecordName = try #require(beforeNames.first { $0.hasPrefix("ping-") }) 104 #expect(await engine.pendingPingRecordNamesForTesting() == [pingRecordName]) 105 106 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName) 107 await engine.applyZoneOrphaning([zoneID], isPrivate: true) 108 109 let afterNames = await engine.pendingSaveRecordNames(scope: .private) 110 #expect(!afterNames.contains(pingRecordName)) 111 #expect(await engine.pendingPingRecordNamesForTesting().isEmpty) 112 } 113 114 @Test("Shared orphaning marks the game access-revoked and clears its pending sends") 115 func sharedOrphaning() async throws { 116 let persistence = makeTestPersistence() 117 let ctx = persistence.viewContext 118 let (gameID, zoneName, owner) = try makeSharedGame(in: ctx) 119 let engine = await makeEngine(persistence: persistence) 120 121 await engine.enqueuePlayer(gameID: gameID, authorID: "_localAuthor", reason: "test") 122 let beforeNames = await engine.pendingSaveRecordNames(scope: .shared) 123 let playerRecordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: "_localAuthor") 124 #expect(beforeNames.contains(playerRecordName)) 125 126 var revoked: [UUID] = [] 127 await engine.setOnGameAccessRevoked { id in revoked.append(id) } 128 129 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: owner) 130 await engine.applyZoneOrphaning([zoneID], isPrivate: false) 131 132 let afterNames = await engine.pendingSaveRecordNames(scope: .shared) 133 #expect(!afterNames.contains(playerRecordName)) 134 #expect(revoked == [gameID]) 135 136 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 137 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 138 let entity = try #require(try ctx.fetch(req).first) 139 ctx.refresh(entity, mergeChanges: true) 140 #expect(entity.isAccessRevoked == true) 141 } 142 143 @Test("Already-revoked shared game does not re-fire the callback") 144 func sharedOrphaningIsIdempotent() async throws { 145 let persistence = makeTestPersistence() 146 let ctx = persistence.viewContext 147 let (gameID, zoneName, owner) = try makeSharedGame(in: ctx) 148 // Simulate the prior fetch-side path having already marked it revoked. 149 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 150 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 151 let entity = try #require(try ctx.fetch(req).first) 152 entity.isAccessRevoked = true 153 try ctx.save() 154 155 let engine = await makeEngine(persistence: persistence) 156 157 var revoked: [UUID] = [] 158 await engine.setOnGameAccessRevoked { id in revoked.append(id) } 159 160 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: owner) 161 await engine.applyZoneOrphaning([zoneID], isPrivate: false) 162 163 #expect(revoked.isEmpty) 164 } 165 166 @Test("Unknown orphan zones are ignored without error") 167 func unknownZoneIsNoOp() async throws { 168 let persistence = makeTestPersistence() 169 let engine = await makeEngine(persistence: persistence) 170 171 var removed: [UUID] = [] 172 await engine.setOnGameRemoved { id in removed.append(id) } 173 174 let zoneID = CKRecordZone.ID( 175 zoneName: "game-\(UUID().uuidString)", 176 ownerName: CKCurrentUserDefaultName 177 ) 178 await engine.applyZoneOrphaning([zoneID], isPrivate: true) 179 180 #expect(removed.isEmpty) 181 } 182 }