ShareRoutingTests.swift (10449B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import Testing 5 6 @testable import Crossmate 7 8 /// Verifies that `SyncEngine.enqueueMoves` routes to the correct engine based 9 /// on each game's `databaseScope`. Uses a real in-memory persistence store and 10 /// inspects each engine's pending-changes queue after enqueueing. No iCloud 11 /// account is required — `CKSyncEngine.init` and its local state APIs work 12 /// offline; only `fetchChanges` / `sendChanges` need an account. 13 @Suite("ShareRouting", .serialized) 14 @MainActor 15 struct ShareRoutingTests { 16 17 private static let writerAuthorID = "alice" 18 19 private func makeEngineWithGames() async throws -> (SyncEngine, UUID, UUID) { 20 let persistence = makeTestPersistence() 21 let ctx = persistence.viewContext 22 23 let privateID = UUID() 24 let privateEntity = GameEntity(context: ctx) 25 privateEntity.id = privateID 26 privateEntity.title = "Private" 27 privateEntity.puzzleSource = "" 28 privateEntity.createdAt = Date() 29 privateEntity.updatedAt = Date() 30 privateEntity.ckRecordName = "game-\(privateID.uuidString)" 31 privateEntity.ckZoneName = "game-\(privateID.uuidString)" 32 privateEntity.databaseScope = 0 33 Self.seedMovesEntity(for: privateEntity, gameID: privateID, in: ctx) 34 35 let sharedID = UUID() 36 let sharedEntity = GameEntity(context: ctx) 37 sharedEntity.id = sharedID 38 sharedEntity.title = "Shared" 39 sharedEntity.puzzleSource = "" 40 sharedEntity.createdAt = Date() 41 sharedEntity.updatedAt = Date() 42 sharedEntity.ckRecordName = "game-\(sharedID.uuidString)" 43 sharedEntity.ckZoneName = "game-\(sharedID.uuidString)" 44 sharedEntity.ckZoneOwnerName = "_someOtherUser" 45 sharedEntity.databaseScope = 1 46 Self.seedMovesEntity(for: sharedEntity, gameID: sharedID, in: ctx) 47 48 try ctx.save() 49 50 let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") 51 let engine = SyncEngine(container: container, persistence: persistence) 52 await engine.start() 53 54 return (engine, privateID, sharedID) 55 } 56 57 /// Inserts a confirmed (system-fields-bearing — simulated by setting non-nil 58 /// `ckSystemFields`-equivalent state) MovesEntity row for the local device. 59 /// `enqueueMoves` looks up rows by `(game.id, deviceID == localDeviceID)`. 60 private static func seedMovesEntity( 61 for game: GameEntity, 62 gameID: UUID, 63 in ctx: NSManagedObjectContext, 64 confirmed: Bool = false 65 ) { 66 let entity = MovesEntity(context: ctx) 67 entity.game = game 68 entity.authorID = writerAuthorID 69 entity.deviceID = RecordSerializer.localDeviceID 70 entity.cells = Data() 71 entity.updatedAt = Date() 72 entity.ckRecordName = RecordSerializer.recordName( 73 forMovesInGame: gameID, 74 authorID: writerAuthorID, 75 deviceID: RecordSerializer.localDeviceID 76 ) 77 if confirmed { 78 entity.ckSystemFields = Data([0xff]) 79 } 80 } 81 82 private func makeEngineWithPersistedMove() async throws -> (SyncEngine, UUID) { 83 let persistence = makeTestPersistence() 84 let ctx = persistence.viewContext 85 86 let gameID = UUID() 87 let game = GameEntity(context: ctx) 88 game.id = gameID 89 game.title = "Private" 90 game.puzzleSource = "" 91 game.createdAt = Date() 92 game.updatedAt = Date() 93 game.ckRecordName = "game-\(gameID.uuidString)" 94 game.ckZoneName = "game-\(gameID.uuidString)" 95 game.databaseScope = 0 96 97 // Unconfirmed: ckSystemFields is nil so enqueueUnconfirmedMoves picks it up. 98 Self.seedMovesEntity(for: game, gameID: gameID, in: ctx) 99 100 try ctx.save() 101 102 let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") 103 let engine = SyncEngine(container: container, persistence: persistence) 104 await engine.start() 105 106 return (engine, gameID) 107 } 108 109 private func movesRecordName(for gameID: UUID) -> String { 110 RecordSerializer.recordName( 111 forMovesInGame: gameID, 112 authorID: Self.writerAuthorID, 113 deviceID: RecordSerializer.localDeviceID 114 ) 115 } 116 117 @Test("Private-game moves land on the private engine only") 118 func privateMoveEnqueue() async throws { 119 let (engine, privateID, _) = try await makeEngineWithGames() 120 await engine.enqueueMoves(gameIDs: [privateID]) 121 122 let privateNames = await engine.pendingSaveRecordNames(scope: .private) 123 let sharedNames = await engine.pendingSaveRecordNames(scope: .shared) 124 125 #expect(privateNames.contains(movesRecordName(for: privateID))) 126 #expect(sharedNames.isEmpty) 127 } 128 129 @Test("Shared-game moves land on the shared engine only") 130 func sharedMoveEnqueue() async throws { 131 let (engine, _, sharedID) = try await makeEngineWithGames() 132 await engine.enqueueMoves(gameIDs: [sharedID]) 133 134 let privateNames = await engine.pendingSaveRecordNames(scope: .private) 135 let sharedNames = await engine.pendingSaveRecordNames(scope: .shared) 136 137 #expect(sharedNames.contains(movesRecordName(for: sharedID))) 138 #expect(privateNames.isEmpty) 139 } 140 141 @Test("Mixed-scope batch fans out to the correct engines") 142 func mixedScopeEnqueue() async throws { 143 let (engine, privateID, sharedID) = try await makeEngineWithGames() 144 await engine.enqueueMoves(gameIDs: [privateID, sharedID]) 145 146 let privateNames = await engine.pendingSaveRecordNames(scope: .private) 147 let sharedNames = await engine.pendingSaveRecordNames(scope: .shared) 148 149 let privateMoves = movesRecordName(for: privateID) 150 let sharedMoves = movesRecordName(for: sharedID) 151 #expect(privateNames.contains(privateMoves)) 152 #expect(!privateNames.contains(sharedMoves)) 153 #expect(sharedNames.contains(sharedMoves)) 154 #expect(!sharedNames.contains(privateMoves)) 155 } 156 157 @Test("Deleting a private game queues its zone for private CloudKit deletion") 158 func privateGameDeleteEnqueue() async throws { 159 let persistence = makeTestPersistence() 160 let ctx = persistence.viewContext 161 let gameID = UUID() 162 let zoneName = "game-\(gameID.uuidString)" 163 164 let game = GameEntity(context: ctx) 165 game.id = gameID 166 game.title = "Private" 167 game.puzzleSource = "" 168 game.createdAt = Date() 169 game.updatedAt = Date() 170 game.ckRecordName = zoneName 171 game.ckZoneName = zoneName 172 game.databaseScope = 0 173 try ctx.save() 174 175 let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") 176 let engine = SyncEngine(container: container, persistence: persistence) 177 await engine.start() 178 179 var capturedDeletion: GameCloudDeletion? 180 let store = makeTestStore( 181 persistence: persistence, 182 onGameDeleted: { deletion in 183 capturedDeletion = deletion 184 } 185 ) 186 try store.deleteGame(id: gameID) 187 let deletion = try #require(capturedDeletion) 188 await engine.enqueueDeleteGame(deletion) 189 190 let privateDeletes = await engine.pendingDeletedZoneNames(scope: .private) 191 let sharedDeletes = await engine.pendingDeletedZoneNames(scope: .shared) 192 #expect(privateDeletes.contains(zoneName)) 193 #expect(sharedDeletes.isEmpty) 194 } 195 196 @Test("Deleting a shared game queues its zone for shared CloudKit deletion") 197 func sharedGameDeleteEnqueue() async throws { 198 let persistence = makeTestPersistence() 199 let ctx = persistence.viewContext 200 let gameID = UUID() 201 let zoneName = "game-\(gameID.uuidString)" 202 203 let game = GameEntity(context: ctx) 204 game.id = gameID 205 game.title = "Shared" 206 game.puzzleSource = "" 207 game.createdAt = Date() 208 game.updatedAt = Date() 209 game.ckRecordName = zoneName 210 game.ckZoneName = zoneName 211 game.ckZoneOwnerName = "_someOtherUser" 212 game.databaseScope = 1 213 try ctx.save() 214 215 let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") 216 let engine = SyncEngine(container: container, persistence: persistence) 217 await engine.start() 218 219 var capturedDeletion: GameCloudDeletion? 220 let store = makeTestStore( 221 persistence: persistence, 222 onGameDeleted: { deletion in 223 capturedDeletion = deletion 224 } 225 ) 226 try store.deleteGame(id: gameID) 227 let deletion = try #require(capturedDeletion) 228 await engine.enqueueDeleteGame(deletion) 229 230 let privateDeletes = await engine.pendingDeletedZoneNames(scope: .private) 231 let sharedDeletes = await engine.pendingDeletedZoneNames(scope: .shared) 232 #expect(sharedDeletes.contains(zoneName)) 233 #expect(privateDeletes.isEmpty) 234 } 235 236 @Test("Unknown game IDs enqueue nothing") 237 func unknownGameIsDropped() async throws { 238 let (engine, _, _) = try await makeEngineWithGames() 239 let orphan = UUID() 240 await engine.enqueueMoves(gameIDs: [orphan]) 241 242 let privateNames = await engine.pendingSaveRecordNames(scope: .private) 243 let sharedNames = await engine.pendingSaveRecordNames(scope: .shared) 244 245 #expect(privateNames.isEmpty) 246 #expect(sharedNames.isEmpty) 247 } 248 249 @Test("Duplicate enqueue keeps one pending save") 250 func duplicateMoveEnqueueIsDeduped() async throws { 251 let (engine, privateID, _) = try await makeEngineWithGames() 252 253 await engine.enqueueMoves(gameIDs: [privateID]) 254 await engine.enqueueMoves(gameIDs: [privateID]) 255 256 let recordName = movesRecordName(for: privateID) 257 let privateNames = await engine.pendingSaveRecordNames(scope: .private) 258 #expect(privateNames.filter { $0 == recordName }.count == 1) 259 } 260 261 @Test("Unconfirmed persisted Moves rows are re-enqueued") 262 func unconfirmedPersistedMovesAreReEnqueued() async throws { 263 let (engine, gameID) = try await makeEngineWithPersistedMove() 264 265 let count = await engine.enqueueUnconfirmedMoves() 266 267 let recordName = movesRecordName(for: gameID) 268 let privateNames = await engine.pendingSaveRecordNames(scope: .private) 269 #expect(count == 1) 270 #expect(privateNames.contains(recordName)) 271 } 272 }