PersistenceRecoveryTests.swift (3531B)
1 import CoreData 2 import Foundation 3 import Testing 4 5 @testable import Crossmate 6 7 /// `PersistenceController`'s destructive recovery path: when the on-disk store 8 /// can't be opened, the failing files must be moved aside — not destroyed — 9 /// before the empty rebuild. The store is the *only* copy of a user's games 10 /// when iCloud sync is disabled, so a silent wipe is permanent data loss. 11 @Suite("Persistence recovery", .serialized) 12 @MainActor 13 struct PersistenceRecoveryTests { 14 15 private func makeStoreDirectory() throws -> URL { 16 let directory = FileManager.default.temporaryDirectory 17 .appendingPathComponent("PersistenceRecoveryTests-\(UUID().uuidString)", isDirectory: true) 18 try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) 19 return directory 20 } 21 22 @Test func brokenStoreIsPreservedBeforeRebuild() throws { 23 let fileManager = FileManager.default 24 let directory = try makeStoreDirectory() 25 defer { try? fileManager.removeItem(at: directory) } 26 27 // Garbage well past the SQLite header size, so the open fails as 28 // not-a-database rather than being adopted as a fresh empty store. 29 let storeURL = directory.appendingPathComponent("CrossmateModel.sqlite") 30 let garbage = Data(repeating: 0x6E, count: 1024) 31 try garbage.write(to: storeURL) 32 33 let controller = PersistenceController(storeURL: storeURL) 34 35 // Recovery rebuilt a usable, empty store at the original URL. 36 #expect(controller.container.persistentStoreCoordinator.persistentStores.count == 1) 37 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 38 #expect(try controller.viewContext.count(for: request) == 0) 39 40 // The broken file was moved aside with its bytes intact. 41 let backups = try fileManager.contentsOfDirectory(atPath: directory.path) 42 .filter { $0.contains(".broken-") } 43 #expect(backups.count == 1) 44 let backup = try #require(backups.first) 45 #expect(try Data(contentsOf: directory.appendingPathComponent(backup)) == garbage) 46 } 47 48 @Test func healthyStoreLoadsWithoutBackup() throws { 49 let fileManager = FileManager.default 50 let directory = try makeStoreDirectory() 51 defer { try? fileManager.removeItem(at: directory) } 52 let storeURL = directory.appendingPathComponent("CrossmateModel.sqlite") 53 54 // First launch creates the store and saves a row. 55 let gameID = UUID() 56 do { 57 let controller = PersistenceController(storeURL: storeURL) 58 let entity = GameEntity(context: controller.viewContext) 59 entity.id = gameID 60 entity.title = "Persisted Game" 61 entity.puzzleSource = "Title: Persisted\n\n\nAB\n\n\nA1. _ ~ AB" 62 entity.createdAt = Date() 63 entity.updatedAt = Date() 64 try controller.viewContext.save() 65 } 66 67 // A relaunch against the same files reopens them without triggering 68 // recovery: the row survives and nothing was moved aside. 69 let controller = PersistenceController(storeURL: storeURL) 70 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 71 let games = try controller.viewContext.fetch(request) 72 #expect(games.compactMap { $0.id } == [gameID]) 73 let backups = try fileManager.contentsOfDirectory(atPath: directory.path) 74 .filter { $0.contains(".broken-") } 75 #expect(backups.isEmpty) 76 } 77 }