crossmate

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

ZoneOrphaningTests.swift (7184B)


      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 = CloudContainer.container
     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("Private orphaning clears queued ping payloads for the zone")
     90     func privateOrphaningClearsQueuedPings() async throws {
     91         let persistence = makeTestPersistence()
     92         let ctx = persistence.viewContext
     93         let (gameID, zoneName) = try makePrivateGame(in: ctx)
     94         let engine = await makeEngine(persistence: persistence)
     95 
     96         await engine.enqueuePing(
     97             kind: .join,
     98             gameID: gameID,
     99             authorID: "_localAuthor",
    100             playerName: "Local"
    101         )
    102         let beforeNames = await engine.pendingSaveRecordNames(scope: .private)
    103         let pingRecordName = try #require(beforeNames.first { $0.hasPrefix("ping-") })
    104         #expect(await engine.pendingPingRecordNamesForTesting() == [pingRecordName])
    105 
    106         let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
    107         await engine.applyZoneOrphaning([zoneID], isPrivate: true)
    108 
    109         let afterNames = await engine.pendingSaveRecordNames(scope: .private)
    110         #expect(!afterNames.contains(pingRecordName))
    111         #expect(await engine.pendingPingRecordNamesForTesting().isEmpty)
    112     }
    113 
    114     @Test("Shared orphaning marks the game access-revoked and clears its pending sends")
    115     func sharedOrphaning() async throws {
    116         let persistence = makeTestPersistence()
    117         let ctx = persistence.viewContext
    118         let (gameID, zoneName, owner) = try makeSharedGame(in: ctx)
    119         let engine = await makeEngine(persistence: persistence)
    120 
    121         await engine.enqueuePlayer(gameID: gameID, authorID: "_localAuthor", reason: "test")
    122         let beforeNames = await engine.pendingSaveRecordNames(scope: .shared)
    123         let playerRecordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: "_localAuthor")
    124         #expect(beforeNames.contains(playerRecordName))
    125 
    126         var revoked: [UUID] = []
    127         await engine.setOnGameAccessRevoked { id in revoked.append(id) }
    128 
    129         let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: owner)
    130         await engine.applyZoneOrphaning([zoneID], isPrivate: false)
    131 
    132         let afterNames = await engine.pendingSaveRecordNames(scope: .shared)
    133         #expect(!afterNames.contains(playerRecordName))
    134         #expect(revoked == [gameID])
    135 
    136         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    137         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    138         let entity = try #require(try ctx.fetch(req).first)
    139         ctx.refresh(entity, mergeChanges: true)
    140         #expect(entity.isAccessRevoked == true)
    141     }
    142 
    143     @Test("Already-revoked shared game does not re-fire the callback")
    144     func sharedOrphaningIsIdempotent() async throws {
    145         let persistence = makeTestPersistence()
    146         let ctx = persistence.viewContext
    147         let (gameID, zoneName, owner) = try makeSharedGame(in: ctx)
    148         // Simulate the prior fetch-side path having already marked it revoked.
    149         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    150         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    151         let entity = try #require(try ctx.fetch(req).first)
    152         entity.isAccessRevoked = true
    153         try ctx.save()
    154 
    155         let engine = await makeEngine(persistence: persistence)
    156 
    157         var revoked: [UUID] = []
    158         await engine.setOnGameAccessRevoked { id in revoked.append(id) }
    159 
    160         let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: owner)
    161         await engine.applyZoneOrphaning([zoneID], isPrivate: false)
    162 
    163         #expect(revoked.isEmpty)
    164     }
    165 
    166     @Test("Unknown orphan zones are ignored without error")
    167     func unknownZoneIsNoOp() async throws {
    168         let persistence = makeTestPersistence()
    169         let engine = await makeEngine(persistence: persistence)
    170 
    171         var removed: [UUID] = []
    172         await engine.setOnGameRemoved { id in removed.append(id) }
    173 
    174         let zoneID = CKRecordZone.ID(
    175             zoneName: "game-\(UUID().uuidString)",
    176             ownerName: CKCurrentUserDefaultName
    177         )
    178         await engine.applyZoneOrphaning([zoneID], isPrivate: true)
    179 
    180         #expect(removed.isEmpty)
    181     }
    182 }