PersistenceControllerHealTests.swift (6829B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import Testing 5 6 @testable import Crossmate 7 8 /// Covers the TEMPORARY v1.1 heal for `GameEntity` rows stranded at 9 /// `databaseScope == 1` with the owner placeholder. Delete this file as one 10 /// block with `PersistenceController.healStrandedSharedGameRows_v1`. 11 @Suite("Stranded shared game row heal") 12 @MainActor 13 struct PersistenceControllerHealTests { 14 15 private static let puzzleSource = """ 16 Title: Test Puzzle 17 Author: Test 18 19 20 AB 21 CD 22 23 24 A1. Top row ~ AB 25 A3. Bottom row ~ CD 26 27 D1. Left column ~ AC 28 D2. Right column ~ BD 29 """ 30 31 /// The shape the mis-scoped placeholder branch left behind: shared scope, 32 /// owner placeholder, no puzzle. 33 @discardableResult 34 private func makeStranded( 35 id: UUID, 36 in ctx: NSManagedObjectContext 37 ) -> GameEntity { 38 let entity = GameEntity(context: ctx) 39 entity.id = id 40 entity.ckRecordName = "game-\(id.uuidString)" 41 entity.ckZoneName = "game-\(id.uuidString)" 42 entity.ckZoneOwnerName = CKCurrentUserDefaultName 43 entity.databaseScope = 1 44 entity.title = "Joining\u{2026}" 45 entity.puzzleSource = "" 46 entity.createdAt = .now 47 entity.updatedAt = .now 48 return entity 49 } 50 51 @discardableResult 52 private func makeReal( 53 id: UUID, 54 in ctx: NSManagedObjectContext 55 ) -> GameEntity { 56 let entity = GameEntity(context: ctx) 57 entity.id = id 58 entity.ckRecordName = "game-\(id.uuidString)" 59 entity.ckZoneName = "game-\(id.uuidString)" 60 entity.ckZoneOwnerName = nil 61 entity.databaseScope = 0 62 entity.title = "Test Puzzle" 63 entity.puzzleSource = Self.puzzleSource 64 entity.createdAt = .now 65 entity.updatedAt = .now 66 return entity 67 } 68 69 @discardableResult 70 private func makeMoves( 71 recordName: String, 72 on game: GameEntity, 73 in ctx: NSManagedObjectContext 74 ) -> MovesEntity { 75 let moves = MovesEntity(context: ctx) 76 moves.ckRecordName = recordName 77 moves.authorID = "alice" 78 moves.deviceID = "devicea" 79 moves.cells = Data() 80 moves.updatedAt = .now 81 moves.game = game 82 return moves 83 } 84 85 private func games(in ctx: NSManagedObjectContext, id: UUID) throws -> [GameEntity] { 86 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 87 req.predicate = NSPredicate(format: "id == %@", id as CVarArg) 88 return try ctx.fetch(req) 89 } 90 91 @Test("a stranded row is merged away when the real row exists") 92 func mergesStrandedRowIntoSibling() throws { 93 let persistence = makeTestPersistence() 94 let ctx = persistence.viewContext 95 let id = UUID() 96 makeStranded(id: id, in: ctx) 97 makeReal(id: id, in: ctx) 98 try ctx.save() 99 #expect(try games(in: ctx, id: id).count == 2) 100 101 persistence.healStrandedSharedGameRows_v1(force: true) 102 ctx.refreshAllObjects() 103 104 let survivors = try games(in: ctx, id: id) 105 #expect(survivors.count == 1) 106 // The row that survives is the playable one — that is the whole point. 107 #expect(survivors.first?.puzzleSource == Self.puzzleSource) 108 #expect(survivors.first?.databaseScope == 0) 109 } 110 111 @Test("moves parented to the stranded row move across, without duplicating") 112 func adoptsStrandedChildren() throws { 113 let persistence = makeTestPersistence() 114 let ctx = persistence.viewContext 115 let id = UUID() 116 let stranded = makeStranded(id: id, in: ctx) 117 let real = makeReal(id: id, in: ctx) 118 // One row only the stranded parent holds, and one both hold — the 119 // latter is what a doubly-applied Moves record leaves behind. 120 makeMoves(recordName: "moves-\(id.uuidString)-alice-devicea", on: stranded, in: ctx) 121 makeMoves(recordName: "moves-\(id.uuidString)-bob-deviceb", on: stranded, in: ctx) 122 makeMoves(recordName: "moves-\(id.uuidString)-bob-deviceb", on: real, in: ctx) 123 try ctx.save() 124 125 persistence.healStrandedSharedGameRows_v1(force: true) 126 ctx.refreshAllObjects() 127 128 let survivor = try #require(try games(in: ctx, id: id).first) 129 let names = ((survivor.moves as? Set<MovesEntity>) ?? []).compactMap(\.ckRecordName) 130 #expect(Set(names) == [ 131 "moves-\(id.uuidString)-alice-devicea", 132 "moves-\(id.uuidString)-bob-deviceb", 133 ]) 134 // The already-present row is not duplicated by the adoption. 135 #expect(names.count == 2) 136 137 let allMoves = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 138 #expect(try ctx.fetch(allMoves).count == 2) 139 } 140 141 @Test("a stranded row with no sibling is repaired, never deleted") 142 func repairsLoneStrandedRow() throws { 143 let persistence = makeTestPersistence() 144 let ctx = persistence.viewContext 145 let id = UUID() 146 makeStranded(id: id, in: ctx) 147 try ctx.save() 148 149 persistence.healStrandedSharedGameRows_v1(force: true) 150 ctx.refreshAllObjects() 151 152 // Deleting would lose the only row for a game the user owns; repairing 153 // it to private scope lets the ordinary sync path fill it in. 154 let rows = try games(in: ctx, id: id) 155 #expect(rows.count == 1) 156 #expect(rows.first?.databaseScope == 0) 157 #expect(rows.first?.ckZoneOwnerName == nil) 158 } 159 160 @Test("a shared row that carries a puzzle is left alone") 161 func leavesPopulatedSharedRowsAlone() throws { 162 let persistence = makeTestPersistence() 163 let ctx = persistence.viewContext 164 let id = UUID() 165 // Same stranded owner spelling, but with real content: not this bug's 166 // work, and scope 0 would wrongly claim ownership of a joined game. 167 let joined = makeStranded(id: id, in: ctx) 168 joined.title = "Test Puzzle" 169 joined.puzzleSource = Self.puzzleSource 170 try ctx.save() 171 172 persistence.healStrandedSharedGameRows_v1(force: true) 173 ctx.refreshAllObjects() 174 175 let rows = try games(in: ctx, id: id) 176 #expect(rows.count == 1) 177 #expect(rows.first?.databaseScope == 1) 178 #expect(rows.first?.ckZoneOwnerName == CKCurrentUserDefaultName) 179 } 180 181 @Test("a healthy store is untouched") 182 func healthyStoreIsUntouched() throws { 183 let persistence = makeTestPersistence() 184 let ctx = persistence.viewContext 185 let id = UUID() 186 makeReal(id: id, in: ctx) 187 try ctx.save() 188 189 persistence.healStrandedSharedGameRows_v1(force: true) 190 ctx.refreshAllObjects() 191 192 let rows = try games(in: ctx, id: id) 193 #expect(rows.count == 1) 194 #expect(rows.first?.puzzleSource == Self.puzzleSource) 195 } 196 }