crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }