EnsureGameEntityTests.swift (4948B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import Testing 5 6 @testable import Crossmate 7 8 /// Pins down the lazy-parent-creation invariant that prevents Move / Snapshot / 9 /// Player records from being silently dropped when they arrive in a different 10 /// CKSyncEngine fetch batch than the Game record that created the zone. 11 @Suite("EnsureGameEntity") 12 @MainActor 13 struct EnsureGameEntityTests { 14 15 @Test("creates a hidden stub when no GameEntity exists") 16 func createsHiddenStub() throws { 17 let persistence = makeTestPersistence() 18 let ctx = persistence.viewContext 19 let gameID = UUID() 20 let zoneID = RecordSerializer.zoneID(for: gameID) 21 22 let entity = RecordSerializer.ensureGameEntity( 23 forGameID: gameID, 24 zoneID: zoneID, 25 in: ctx 26 ) 27 28 #expect(entity.id == gameID) 29 #expect(entity.ckRecordName == "game-\(gameID.uuidString)") 30 #expect(entity.ckZoneName == zoneID.zoneName) 31 #expect(entity.databaseScope == 0) 32 #expect(entity.ckZoneOwnerName == nil) 33 #expect(entity.title == "") 34 // Empty puzzleSource is what GameSummary.init? uses to filter the row 35 // out of the library until the real Game record arrives. 36 #expect(entity.puzzleSource == "") 37 } 38 39 @Test("returns existing entity, no duplicate") 40 func returnsExisting() throws { 41 let persistence = makeTestPersistence() 42 let ctx = persistence.viewContext 43 let gameID = UUID() 44 let zoneID = RecordSerializer.zoneID(for: gameID) 45 46 let first = RecordSerializer.ensureGameEntity( 47 forGameID: gameID, 48 zoneID: zoneID, 49 in: ctx 50 ) 51 first.title = "preserved" 52 let second = RecordSerializer.ensureGameEntity( 53 forGameID: gameID, 54 zoneID: zoneID, 55 in: ctx 56 ) 57 58 #expect(first.objectID == second.objectID) 59 #expect(second.title == "preserved") 60 61 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 62 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 63 let all = try ctx.fetch(req) 64 #expect(all.count == 1) 65 } 66 67 @Test("shared scope stores the zone owner") 68 func sharedScope() throws { 69 let persistence = makeTestPersistence() 70 let ctx = persistence.viewContext 71 let gameID = UUID() 72 let zoneID = RecordSerializer.zoneID(for: gameID, ownerName: "_someOwnerID") 73 74 let entity = RecordSerializer.ensureGameEntity( 75 forGameID: gameID, 76 zoneID: zoneID, 77 databaseScope: .shared, 78 in: ctx 79 ) 80 81 #expect(entity.databaseScope == 1) 82 #expect(entity.ckZoneOwnerName == "_someOwnerID") 83 } 84 85 @Test("private scope stores a nil owner even when the zone ID carries a concrete one") 86 func privateScopeNormalizesOwner() throws { 87 let persistence = makeTestPersistence() 88 let ctx = persistence.viewContext 89 let gameID = UUID() 90 // CloudKit round-trips a private zone's owner as either the 91 // placeholder or the current user's concrete record ID; the stored 92 // row must not depend on which spelling arrived. 93 let zoneID = RecordSerializer.zoneID(for: gameID, ownerName: "_myOwnRecordID") 94 95 let entity = RecordSerializer.ensureGameEntity( 96 forGameID: gameID, 97 zoneID: zoneID, 98 databaseScope: .private, 99 in: ctx 100 ) 101 102 #expect(entity.databaseScope == 0) 103 #expect(entity.ckZoneOwnerName == nil) 104 } 105 106 @Test("later Game record populates the same stub row") 107 func gameRecordPopulatesStub() throws { 108 let persistence = makeTestPersistence() 109 let ctx = persistence.viewContext 110 let gameID = UUID() 111 let zoneID = RecordSerializer.zoneID(for: gameID) 112 113 let stub = RecordSerializer.ensureGameEntity( 114 forGameID: gameID, 115 zoneID: zoneID, 116 in: ctx 117 ) 118 let stubObjectID = stub.objectID 119 120 // Simulate the Game record arriving in a later batch. applyGameRecord 121 // matches by ckRecordName via fetchOrCreate, so the stub row should be 122 // updated rather than a new row created. 123 let recordID = CKRecord.ID( 124 recordName: RecordSerializer.recordName(forGameID: gameID), 125 zoneID: zoneID 126 ) 127 let record = CKRecord(recordType: "Game", recordID: recordID) 128 record["title"] = "Real Title" as CKRecordValue 129 130 let updated = RecordSerializer.applyGameRecord(record, to: ctx) 131 132 #expect(updated.objectID == stubObjectID) 133 #expect(updated.title == "Real Title") 134 135 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 136 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 137 let all = try ctx.fetch(req) 138 #expect(all.count == 1) 139 } 140 }