MovesInboundTests.swift (12518B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import Testing 5 6 @testable import Crossmate 7 8 /// Pins down `RecordSerializer.applyMovesRecord` — the inbound persistence path 9 /// that turns a `Moves` CKRecord into a `MovesEntity` row. 10 @Suite("RecordSerializer.applyMovesRecord") 11 @MainActor 12 struct MovesInboundTests { 13 14 private let gameID = UUID(uuidString: "AABBCCDD-0000-0000-0000-111122223333")! 15 16 private func record( 17 in ctx: NSManagedObjectContext, 18 authorID: String, 19 deviceID: String, 20 cells: [GridPosition: TimestampedCell], 21 updatedAt: Date 22 ) throws -> (CKRecord, MovesValue) { 23 let value = MovesValue( 24 gameID: gameID, 25 authorID: authorID, 26 deviceID: deviceID, 27 cells: cells, 28 updatedAt: updatedAt 29 ) 30 let record = try RecordSerializer.movesRecord( 31 from: value, 32 zone: RecordSerializer.zoneID(for: gameID), 33 systemFields: nil 34 ) 35 return (record, value) 36 } 37 38 private func fetchAll(_ ctx: NSManagedObjectContext) -> [MovesEntity] { 39 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 40 return (try? ctx.fetch(req)) ?? [] 41 } 42 43 @Test("Persists a MovesEntity with the record's fields") 44 func persistsEntity() throws { 45 let persistence = makeTestPersistence() 46 let ctx = persistence.viewContext 47 let (rec, value) = try record( 48 in: ctx, 49 authorID: "alice", 50 deviceID: "deadbeef", 51 cells: [ 52 GridPosition(row: 0, col: 0): TimestampedCell( 53 letter: "A", markKind: 0, checkedWrong: false, 54 updatedAt: Date(timeIntervalSince1970: 1_700_000_000) 55 ), 56 ], 57 updatedAt: Date(timeIntervalSince1970: 1_700_000_000) 58 ) 59 60 RecordSerializer.applyMovesRecord(rec, value: value, to: ctx) 61 62 let rows = fetchAll(ctx) 63 #expect(rows.count == 1) 64 let entity = try #require(rows.first) 65 #expect(entity.ckRecordName == rec.recordID.recordName) 66 #expect(entity.authorID == "alice") 67 #expect(entity.deviceID == "deadbeef") 68 #expect(entity.updatedAt == value.updatedAt) 69 #expect(entity.ckSystemFields != nil) 70 // `cells` should be the verbatim record blob — decode round-trip recovers 71 // the same cells we encoded. 72 let decoded = try MovesCodec.decode(entity.cells ?? Data()) 73 #expect(decoded == value.cells) 74 } 75 76 @Test("Re-applying the same record updates the existing row in place") 77 func reapplyUpdatesSameRow() throws { 78 let persistence = makeTestPersistence() 79 let ctx = persistence.viewContext 80 81 let cells1: [GridPosition: TimestampedCell] = [ 82 GridPosition(row: 0, col: 0): TimestampedCell( 83 letter: "A", markKind: 0, checkedWrong: false, 84 updatedAt: Date(timeIntervalSince1970: 1_700_000_000), 85 authorID: "alice" 86 ), 87 ] 88 let (rec1, value1) = try record( 89 in: ctx, 90 authorID: "alice", 91 deviceID: "d1", 92 cells: cells1, 93 updatedAt: Date(timeIntervalSince1970: 1_700_000_000) 94 ) 95 RecordSerializer.applyMovesRecord(rec1, value: value1, to: ctx) 96 let firstID = try #require(fetchAll(ctx).first?.objectID) 97 98 // A later update from the same device should land on the same row 99 // (matched by ckRecordName), not create a duplicate. 100 let cells2: [GridPosition: TimestampedCell] = [ 101 GridPosition(row: 0, col: 0): TimestampedCell( 102 letter: "B", markKind: 0, checkedWrong: false, 103 updatedAt: Date(timeIntervalSince1970: 1_700_000_500), 104 authorID: "alice" 105 ), 106 GridPosition(row: 1, col: 1): TimestampedCell( 107 letter: "C", markKind: 0, checkedWrong: false, 108 updatedAt: Date(timeIntervalSince1970: 1_700_000_500), 109 authorID: "alice" 110 ), 111 ] 112 let (rec2, value2) = try record( 113 in: ctx, 114 authorID: "alice", 115 deviceID: "d1", 116 cells: cells2, 117 updatedAt: Date(timeIntervalSince1970: 1_700_000_500) 118 ) 119 RecordSerializer.applyMovesRecord(rec2, value: value2, to: ctx) 120 121 let rows = fetchAll(ctx) 122 #expect(rows.count == 1) 123 #expect(rows.first?.objectID == firstID) 124 #expect(rows.first?.updatedAt == value2.updatedAt) 125 let decoded = try MovesCodec.decode(rows.first?.cells ?? Data()) 126 #expect(decoded == cells2) 127 } 128 129 @Test("Inbound local-device record does not clobber existing local row") 130 func inboundLocalDeviceRecordDoesNotClobberExistingLocalRow() throws { 131 let persistence = makeTestPersistence() 132 let ctx = persistence.viewContext 133 134 let game = GameEntity(context: ctx) 135 game.id = gameID 136 game.ckRecordName = "game-\(gameID.uuidString)" 137 game.title = "" 138 game.puzzleSource = "" 139 game.createdAt = Date(timeIntervalSince1970: 0) 140 game.updatedAt = Date(timeIntervalSince1970: 20) 141 142 let localCells: [GridPosition: TimestampedCell] = [ 143 GridPosition(row: 0, col: 0): TimestampedCell( 144 letter: "B", markKind: 0, checkedWrong: false, 145 updatedAt: Date(timeIntervalSince1970: 20), 146 authorID: "alice" 147 ), 148 ] 149 let local = MovesEntity(context: ctx) 150 local.game = game 151 local.ckRecordName = RecordSerializer.recordName( 152 forMovesInGame: gameID, 153 authorID: "alice", 154 deviceID: RecordSerializer.localDeviceID 155 ) 156 local.authorID = "alice" 157 local.deviceID = RecordSerializer.localDeviceID 158 local.updatedAt = Date(timeIntervalSince1970: 20) 159 local.cells = try MovesCodec.encode(localCells) 160 try ctx.save() 161 162 let serverCells: [GridPosition: TimestampedCell] = [ 163 GridPosition(row: 0, col: 0): TimestampedCell( 164 letter: "A", markKind: 0, checkedWrong: false, 165 updatedAt: Date(timeIntervalSince1970: 10), 166 authorID: "alice" 167 ), 168 ] 169 let (rec, value) = try record( 170 in: ctx, 171 authorID: "alice", 172 deviceID: RecordSerializer.localDeviceID, 173 cells: serverCells, 174 updatedAt: Date(timeIntervalSince1970: 10) 175 ) 176 177 RecordSerializer.applyMovesRecord( 178 rec, 179 value: value, 180 to: ctx, 181 localAuthorID: "alice" 182 ) 183 184 let row = try #require(fetchAll(ctx).first) 185 #expect(row.updatedAt == Date(timeIntervalSince1970: 20)) 186 let decoded = try MovesCodec.decode(row.cells ?? Data()) 187 #expect(decoded == localCells) 188 #expect(row.ckSystemFields != nil) 189 } 190 191 @Test("Inbound other-device record replaces cached row") 192 func inboundOtherDeviceRecordReplacesCachedRow() throws { 193 let persistence = makeTestPersistence() 194 let ctx = persistence.viewContext 195 196 let game = GameEntity(context: ctx) 197 game.id = gameID 198 game.ckRecordName = "game-\(gameID.uuidString)" 199 game.title = "" 200 game.puzzleSource = "" 201 game.createdAt = Date(timeIntervalSince1970: 0) 202 game.updatedAt = Date(timeIntervalSince1970: 20) 203 204 let cachedCells: [GridPosition: TimestampedCell] = [ 205 GridPosition(row: 0, col: 0): TimestampedCell( 206 letter: "B", markKind: 0, checkedWrong: false, 207 updatedAt: Date(timeIntervalSince1970: 20), 208 authorID: "bob" 209 ), 210 ] 211 let cached = MovesEntity(context: ctx) 212 cached.game = game 213 cached.ckRecordName = RecordSerializer.recordName( 214 forMovesInGame: gameID, 215 authorID: "bob", 216 deviceID: "phone" 217 ) 218 cached.authorID = "bob" 219 cached.deviceID = "phone" 220 cached.updatedAt = Date(timeIntervalSince1970: 20) 221 cached.cells = try MovesCodec.encode(cachedCells) 222 try ctx.save() 223 224 let serverCells: [GridPosition: TimestampedCell] = [ 225 GridPosition(row: 0, col: 0): TimestampedCell( 226 letter: "A", markKind: 0, checkedWrong: false, 227 updatedAt: Date(timeIntervalSince1970: 10), 228 authorID: "bob" 229 ), 230 ] 231 let (rec, value) = try record( 232 in: ctx, 233 authorID: "bob", 234 deviceID: "phone", 235 cells: serverCells, 236 updatedAt: Date(timeIntervalSince1970: 10) 237 ) 238 239 RecordSerializer.applyMovesRecord( 240 rec, 241 value: value, 242 to: ctx, 243 localAuthorID: "alice" 244 ) 245 246 let row = try #require(fetchAll(ctx).first) 247 #expect(row.updatedAt == Date(timeIntervalSince1970: 10)) 248 let decoded = try MovesCodec.decode(row.cells ?? Data()) 249 #expect(decoded == serverCells) 250 } 251 252 @Test("Two devices for the same game produce two distinct rows") 253 func twoDevicesYieldTwoRows() throws { 254 let persistence = makeTestPersistence() 255 let ctx = persistence.viewContext 256 257 let (rec1, value1) = try record( 258 in: ctx, 259 authorID: "alice", 260 deviceID: "phone", 261 cells: [ 262 GridPosition(row: 0, col: 0): TimestampedCell( 263 letter: "A", markKind: 0, checkedWrong: false, 264 updatedAt: Date(timeIntervalSince1970: 1), 265 authorID: "alice" 266 ), 267 ], 268 updatedAt: Date(timeIntervalSince1970: 1) 269 ) 270 let (rec2, value2) = try record( 271 in: ctx, 272 authorID: "alice", 273 deviceID: "ipad", 274 cells: [ 275 GridPosition(row: 1, col: 1): TimestampedCell( 276 letter: "B", markKind: 0, checkedWrong: false, 277 updatedAt: Date(timeIntervalSince1970: 2), 278 authorID: "alice" 279 ), 280 ], 281 updatedAt: Date(timeIntervalSince1970: 2) 282 ) 283 RecordSerializer.applyMovesRecord(rec1, value: value1, to: ctx) 284 RecordSerializer.applyMovesRecord(rec2, value: value2, to: ctx) 285 286 let rows = fetchAll(ctx) 287 #expect(rows.count == 2) 288 #expect(Set(rows.compactMap(\.deviceID)) == ["phone", "ipad"]) 289 } 290 291 @Test("Creates a stub GameEntity if none exists yet (lazy parent)") 292 func lazyParentStub() throws { 293 let persistence = makeTestPersistence() 294 let ctx = persistence.viewContext 295 296 let (rec, value) = try record( 297 in: ctx, 298 authorID: "alice", 299 deviceID: "d1", 300 cells: [:], 301 updatedAt: Date(timeIntervalSince1970: 1) 302 ) 303 RecordSerializer.applyMovesRecord(rec, value: value, to: ctx) 304 305 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 306 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 307 let games = try ctx.fetch(gameReq) 308 #expect(games.count == 1) 309 #expect(games.first?.title == "") 310 // The Moves row should be parented to the new stub. 311 let movesRows = fetchAll(ctx) 312 #expect(movesRows.count == 1) 313 #expect(movesRows.first?.game?.objectID == games.first?.objectID) 314 } 315 316 @Test("Bumps the parent game's updatedAt when the record is fresher") 317 func bumpsGameUpdatedAt() throws { 318 let persistence = makeTestPersistence() 319 let ctx = persistence.viewContext 320 321 // Pre-create the game with an old timestamp so the bump is observable. 322 let game = GameEntity(context: ctx) 323 game.id = gameID 324 game.ckRecordName = "game-\(gameID.uuidString)" 325 game.title = "" 326 game.puzzleSource = "" 327 game.createdAt = Date(timeIntervalSince1970: 0) 328 game.updatedAt = Date(timeIntervalSince1970: 0) 329 try ctx.save() 330 331 let (rec, value) = try record( 332 in: ctx, 333 authorID: "alice", 334 deviceID: "d1", 335 cells: [:], 336 updatedAt: Date(timeIntervalSince1970: 1_700_000_000) 337 ) 338 RecordSerializer.applyMovesRecord(rec, value: value, to: ctx) 339 340 #expect(game.updatedAt == value.updatedAt) 341 } 342 }