ShareRoutingTests.swift (14263B)
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 /// narrowed to the current authorID when the engine knows one — the 61 /// account-switch tests below seed a second author on the same device to 62 /// pin that narrowing. 63 private static func seedMovesEntity( 64 for game: GameEntity, 65 gameID: UUID, 66 in ctx: NSManagedObjectContext, 67 confirmed: Bool = false, 68 authorID: String = writerAuthorID 69 ) { 70 let entity = MovesEntity(context: ctx) 71 entity.game = game 72 entity.authorID = authorID 73 entity.deviceID = RecordSerializer.localDeviceID 74 entity.cells = Data() 75 entity.updatedAt = Date() 76 entity.ckRecordName = RecordSerializer.recordName( 77 forMovesInGame: gameID, 78 authorID: authorID, 79 deviceID: RecordSerializer.localDeviceID 80 ) 81 if confirmed { 82 entity.ckSystemFields = Data([0xff]) 83 } 84 } 85 86 private func makeEngineWithPersistedMove() async throws -> (SyncEngine, UUID) { 87 let persistence = makeTestPersistence() 88 let ctx = persistence.viewContext 89 90 let gameID = UUID() 91 let game = GameEntity(context: ctx) 92 game.id = gameID 93 game.title = "Private" 94 game.puzzleSource = "" 95 game.createdAt = Date() 96 game.updatedAt = Date() 97 game.ckRecordName = "game-\(gameID.uuidString)" 98 game.ckZoneName = "game-\(gameID.uuidString)" 99 game.databaseScope = 0 100 101 // Unconfirmed: ckSystemFields is nil so enqueueUnconfirmedMoves picks it up. 102 Self.seedMovesEntity(for: game, gameID: gameID, in: ctx) 103 104 try ctx.save() 105 106 let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") 107 let engine = SyncEngine(container: container, persistence: persistence) 108 await engine.start() 109 110 return (engine, gameID) 111 } 112 113 private func movesRecordName(for gameID: UUID) -> String { 114 RecordSerializer.recordName( 115 forMovesInGame: gameID, 116 authorID: Self.writerAuthorID, 117 deviceID: RecordSerializer.localDeviceID 118 ) 119 } 120 121 @Test("Private-game moves land on the private engine only") 122 func privateMoveEnqueue() async throws { 123 let (engine, privateID, _) = try await makeEngineWithGames() 124 await engine.enqueueMoves(gameIDs: [privateID]) 125 126 let privateNames = await engine.pendingSaveRecordNames(scope: .private) 127 let sharedNames = await engine.pendingSaveRecordNames(scope: .shared) 128 129 #expect(privateNames.contains(movesRecordName(for: privateID))) 130 #expect(sharedNames.isEmpty) 131 } 132 133 @Test("Shared-game moves land on the shared engine only") 134 func sharedMoveEnqueue() async throws { 135 let (engine, _, sharedID) = try await makeEngineWithGames() 136 await engine.enqueueMoves(gameIDs: [sharedID]) 137 138 let privateNames = await engine.pendingSaveRecordNames(scope: .private) 139 let sharedNames = await engine.pendingSaveRecordNames(scope: .shared) 140 141 #expect(sharedNames.contains(movesRecordName(for: sharedID))) 142 #expect(privateNames.isEmpty) 143 } 144 145 @Test("Mixed-scope batch fans out to the correct engines") 146 func mixedScopeEnqueue() async throws { 147 let (engine, privateID, sharedID) = try await makeEngineWithGames() 148 await engine.enqueueMoves(gameIDs: [privateID, sharedID]) 149 150 let privateNames = await engine.pendingSaveRecordNames(scope: .private) 151 let sharedNames = await engine.pendingSaveRecordNames(scope: .shared) 152 153 let privateMoves = movesRecordName(for: privateID) 154 let sharedMoves = movesRecordName(for: sharedID) 155 #expect(privateNames.contains(privateMoves)) 156 #expect(!privateNames.contains(sharedMoves)) 157 #expect(sharedNames.contains(sharedMoves)) 158 #expect(!sharedNames.contains(privateMoves)) 159 } 160 161 @Test("Deleting a private game queues its zone for private CloudKit deletion") 162 func privateGameDeleteEnqueue() async throws { 163 let persistence = makeTestPersistence() 164 let ctx = persistence.viewContext 165 let gameID = UUID() 166 let zoneName = "game-\(gameID.uuidString)" 167 168 let game = GameEntity(context: ctx) 169 game.id = gameID 170 game.title = "Private" 171 game.puzzleSource = "" 172 game.createdAt = Date() 173 game.updatedAt = Date() 174 game.ckRecordName = zoneName 175 game.ckZoneName = zoneName 176 game.databaseScope = 0 177 try ctx.save() 178 179 let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") 180 let engine = SyncEngine(container: container, persistence: persistence) 181 await engine.start() 182 183 var capturedDeletion: GameCloudDeletion? 184 let store = makeTestStore( 185 persistence: persistence, 186 onGameDeleted: { deletion in 187 capturedDeletion = deletion 188 } 189 ) 190 try store.deleteGame(id: gameID) 191 let deletion = try #require(capturedDeletion) 192 await engine.enqueueDeleteGame(deletion) 193 194 let privateDeletes = await engine.pendingDeletedZoneNames(scope: .private) 195 let sharedDeletes = await engine.pendingDeletedZoneNames(scope: .shared) 196 #expect(privateDeletes.contains(zoneName)) 197 #expect(sharedDeletes.isEmpty) 198 } 199 200 @Test("Deleting a shared game queues its zone for shared CloudKit deletion") 201 func sharedGameDeleteEnqueue() async throws { 202 let persistence = makeTestPersistence() 203 let ctx = persistence.viewContext 204 let gameID = UUID() 205 let zoneName = "game-\(gameID.uuidString)" 206 207 let game = GameEntity(context: ctx) 208 game.id = gameID 209 game.title = "Shared" 210 game.puzzleSource = "" 211 game.createdAt = Date() 212 game.updatedAt = Date() 213 game.ckRecordName = zoneName 214 game.ckZoneName = zoneName 215 game.ckZoneOwnerName = "_someOtherUser" 216 game.databaseScope = 1 217 try ctx.save() 218 219 let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") 220 let engine = SyncEngine(container: container, persistence: persistence) 221 await engine.start() 222 223 var capturedDeletion: GameCloudDeletion? 224 let store = makeTestStore( 225 persistence: persistence, 226 onGameDeleted: { deletion in 227 capturedDeletion = deletion 228 } 229 ) 230 try store.deleteGame(id: gameID) 231 let deletion = try #require(capturedDeletion) 232 await engine.enqueueDeleteGame(deletion) 233 234 let privateDeletes = await engine.pendingDeletedZoneNames(scope: .private) 235 let sharedDeletes = await engine.pendingDeletedZoneNames(scope: .shared) 236 #expect(sharedDeletes.contains(zoneName)) 237 #expect(privateDeletes.isEmpty) 238 } 239 240 @Test("Unknown game IDs enqueue nothing") 241 func unknownGameIsDropped() async throws { 242 let (engine, _, _) = try await makeEngineWithGames() 243 let orphan = UUID() 244 await engine.enqueueMoves(gameIDs: [orphan]) 245 246 let privateNames = await engine.pendingSaveRecordNames(scope: .private) 247 let sharedNames = await engine.pendingSaveRecordNames(scope: .shared) 248 249 #expect(privateNames.isEmpty) 250 #expect(sharedNames.isEmpty) 251 } 252 253 @Test("Duplicate enqueue keeps one pending save") 254 func duplicateMoveEnqueueIsDeduped() async throws { 255 let (engine, privateID, _) = try await makeEngineWithGames() 256 257 await engine.enqueueMoves(gameIDs: [privateID]) 258 await engine.enqueueMoves(gameIDs: [privateID]) 259 260 let recordName = movesRecordName(for: privateID) 261 let privateNames = await engine.pendingSaveRecordNames(scope: .private) 262 #expect(privateNames.filter { $0 == recordName }.count == 1) 263 } 264 265 @Test("Unconfirmed persisted Moves rows are re-enqueued") 266 func unconfirmedPersistedMovesAreReEnqueued() async throws { 267 let (engine, gameID) = try await makeEngineWithPersistedMove() 268 269 let count = await engine.enqueueUnconfirmedMoves() 270 271 let recordName = movesRecordName(for: gameID) 272 let privateNames = await engine.pendingSaveRecordNames(scope: .private) 273 #expect(count == 1) 274 #expect(privateNames.contains(recordName)) 275 } 276 277 /// Simulates the residue of an iCloud account switch: the same device 278 /// holds Moves rows under two authorIDs. With the previous device-only 279 /// lookup, the `fetchLimit = 1` fetch could return the old account's row 280 /// and enqueue a record the current user no longer owns. 281 @Test("Enqueue targets the current author's row, not an old account's") 282 func enqueueScopedToCurrentAuthorAfterAccountSwitch() async throws { 283 let persistence = makeTestPersistence() 284 let ctx = persistence.viewContext 285 286 let gameID = UUID() 287 let game = GameEntity(context: ctx) 288 game.id = gameID 289 game.title = "Private" 290 game.puzzleSource = "" 291 game.createdAt = Date() 292 game.updatedAt = Date() 293 game.ckRecordName = "game-\(gameID.uuidString)" 294 game.ckZoneName = "game-\(gameID.uuidString)" 295 game.databaseScope = 0 296 Self.seedMovesEntity(for: game, gameID: gameID, in: ctx, authorID: "old-account") 297 Self.seedMovesEntity(for: game, gameID: gameID, in: ctx) 298 try ctx.save() 299 300 let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") 301 let engine = SyncEngine(container: container, persistence: persistence) 302 await engine.start() 303 await engine.setLocalAuthorIDProvider { Self.writerAuthorID } 304 305 await engine.enqueueMoves(gameIDs: [gameID]) 306 307 let privateNames = await engine.pendingSaveRecordNames(scope: .private) 308 #expect(privateNames.contains(movesRecordName(for: gameID))) 309 #expect(!privateNames.contains(RecordSerializer.recordName( 310 forMovesInGame: gameID, 311 authorID: "old-account", 312 deviceID: RecordSerializer.localDeviceID 313 ))) 314 } 315 316 @Test("Unconfirmed recovery skips an old account's leftover rows") 317 func unconfirmedRecoveryScopedToCurrentAuthor() async throws { 318 let persistence = makeTestPersistence() 319 let ctx = persistence.viewContext 320 321 // Game A carries only the old account's unconfirmed row; game B 322 // carries the current author's. Recovery must re-enqueue B alone. 323 func makeGame(_ title: String) -> (GameEntity, UUID) { 324 let id = UUID() 325 let game = GameEntity(context: ctx) 326 game.id = id 327 game.title = title 328 game.puzzleSource = "" 329 game.createdAt = Date() 330 game.updatedAt = Date() 331 game.ckRecordName = "game-\(id.uuidString)" 332 game.ckZoneName = "game-\(id.uuidString)" 333 game.databaseScope = 0 334 return (game, id) 335 } 336 let (gameA, idA) = makeGame("Old account's") 337 Self.seedMovesEntity(for: gameA, gameID: idA, in: ctx, authorID: "old-account") 338 let (gameB, idB) = makeGame("Current author's") 339 Self.seedMovesEntity(for: gameB, gameID: idB, in: ctx) 340 try ctx.save() 341 342 let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") 343 let engine = SyncEngine(container: container, persistence: persistence) 344 await engine.start() 345 await engine.setLocalAuthorIDProvider { Self.writerAuthorID } 346 347 let count = await engine.enqueueUnconfirmedMoves() 348 349 let privateNames = await engine.pendingSaveRecordNames(scope: .private) 350 #expect(count == 1) 351 #expect(privateNames.contains(movesRecordName(for: idB))) 352 #expect(!privateNames.contains(RecordSerializer.recordName( 353 forMovesInGame: idA, 354 authorID: "old-account", 355 deviceID: RecordSerializer.localDeviceID 356 ))) 357 } 358 }