crossmate

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

ZoneOrphaningTests.swift (6063B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import Testing
      5 
      6 @testable import Crossmate
      7 
      8 /// Pins down the recovery path for per-record `.zoneNotFound` failures
      9 /// discovered while pushing changes. Without it the engine retried the same
     10 /// record on every push and `Last Error` stayed stuck on
     11 /// `Failed to send changes` (see `iphone-reset.log`).
     12 @Suite("ZoneOrphaning", .serialized)
     13 @MainActor
     14 struct ZoneOrphaningTests {
     15 
     16     private func makeEngine(
     17         persistence: PersistenceController
     18     ) async -> SyncEngine {
     19         let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3")
     20         let engine = SyncEngine(container: container, persistence: persistence)
     21         await engine.start()
     22         return engine
     23     }
     24 
     25     private func makePrivateGame(
     26         in ctx: NSManagedObjectContext
     27     ) throws -> (UUID, String) {
     28         let id = UUID()
     29         let zoneName = "game-\(id.uuidString)"
     30         let entity = GameEntity(context: ctx)
     31         entity.id = id
     32         entity.title = "Private"
     33         entity.puzzleSource = ""
     34         entity.createdAt = Date()
     35         entity.updatedAt = Date()
     36         entity.ckRecordName = zoneName
     37         entity.ckZoneName = zoneName
     38         entity.databaseScope = 0
     39         try ctx.save()
     40         return (id, zoneName)
     41     }
     42 
     43     private func makeSharedGame(
     44         in ctx: NSManagedObjectContext
     45     ) throws -> (UUID, String, String) {
     46         let id = UUID()
     47         let zoneName = "game-\(id.uuidString)"
     48         let owner = "_someOtherUser"
     49         let entity = GameEntity(context: ctx)
     50         entity.id = id
     51         entity.title = "Shared"
     52         entity.puzzleSource = ""
     53         entity.createdAt = Date()
     54         entity.updatedAt = Date()
     55         entity.ckRecordName = zoneName
     56         entity.ckZoneName = zoneName
     57         entity.ckZoneOwnerName = owner
     58         entity.databaseScope = 1
     59         try ctx.save()
     60         return (id, zoneName, owner)
     61     }
     62 
     63     @Test("Private orphaning hard-deletes the game and clears its pending sends")
     64     func privateOrphaning() async throws {
     65         let persistence = makeTestPersistence()
     66         let ctx = persistence.viewContext
     67         let (gameID, zoneName) = try makePrivateGame(in: ctx)
     68         let engine = await makeEngine(persistence: persistence)
     69 
     70         await engine.enqueueGame(ckRecordName: zoneName)
     71         let beforeNames = await engine.pendingSaveRecordNames(scope: .private)
     72         #expect(beforeNames.contains(zoneName))
     73 
     74         var removed: [UUID] = []
     75         await engine.setOnGameRemoved { id in removed.append(id) }
     76 
     77         let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
     78         await engine.applyZoneOrphaning([zoneID], isPrivate: true)
     79 
     80         let afterNames = await engine.pendingSaveRecordNames(scope: .private)
     81         #expect(!afterNames.contains(zoneName))
     82         #expect(removed == [gameID])
     83 
     84         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
     85         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
     86         #expect(try ctx.fetch(req).isEmpty)
     87     }
     88 
     89     @Test("Shared orphaning marks the game access-revoked and clears its pending sends")
     90     func sharedOrphaning() async throws {
     91         let persistence = makeTestPersistence()
     92         let ctx = persistence.viewContext
     93         let (gameID, zoneName, owner) = try makeSharedGame(in: ctx)
     94         let engine = await makeEngine(persistence: persistence)
     95 
     96         await engine.enqueuePlayerRecord(gameID: gameID, authorID: "_localAuthor")
     97         let beforeNames = await engine.pendingSaveRecordNames(scope: .shared)
     98         let playerRecordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: "_localAuthor")
     99         #expect(beforeNames.contains(playerRecordName))
    100 
    101         var revoked: [UUID] = []
    102         await engine.setOnGameAccessRevoked { id in revoked.append(id) }
    103 
    104         let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: owner)
    105         await engine.applyZoneOrphaning([zoneID], isPrivate: false)
    106 
    107         let afterNames = await engine.pendingSaveRecordNames(scope: .shared)
    108         #expect(!afterNames.contains(playerRecordName))
    109         #expect(revoked == [gameID])
    110 
    111         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    112         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    113         let entity = try #require(try ctx.fetch(req).first)
    114         ctx.refresh(entity, mergeChanges: true)
    115         #expect(entity.isAccessRevoked == true)
    116     }
    117 
    118     @Test("Already-revoked shared game does not re-fire the callback")
    119     func sharedOrphaningIsIdempotent() async throws {
    120         let persistence = makeTestPersistence()
    121         let ctx = persistence.viewContext
    122         let (gameID, zoneName, owner) = try makeSharedGame(in: ctx)
    123         // Simulate the prior fetch-side path having already marked it revoked.
    124         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    125         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    126         let entity = try #require(try ctx.fetch(req).first)
    127         entity.isAccessRevoked = true
    128         try ctx.save()
    129 
    130         let engine = await makeEngine(persistence: persistence)
    131 
    132         var revoked: [UUID] = []
    133         await engine.setOnGameAccessRevoked { id in revoked.append(id) }
    134 
    135         let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: owner)
    136         await engine.applyZoneOrphaning([zoneID], isPrivate: false)
    137 
    138         #expect(revoked.isEmpty)
    139     }
    140 
    141     @Test("Unknown orphan zones are ignored without error")
    142     func unknownZoneIsNoOp() async throws {
    143         let persistence = makeTestPersistence()
    144         let engine = await makeEngine(persistence: persistence)
    145 
    146         var removed: [UUID] = []
    147         await engine.setOnGameRemoved { id in removed.append(id) }
    148 
    149         let zoneID = CKRecordZone.ID(
    150             zoneName: "game-\(UUID().uuidString)",
    151             ownerName: CKCurrentUserDefaultName
    152         )
    153         await engine.applyZoneOrphaning([zoneID], isPrivate: true)
    154 
    155         #expect(removed.isEmpty)
    156     }
    157 }