crossmate

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

PendingChangeReapTests.swift (3762B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import Testing
      5 
      6 @testable import Crossmate
      7 
      8 /// Pins down the reap path in `makeRecordZoneChangeBatch`. A pending
      9 /// `.saveRecord` whose record can't be reconstructed (a ping whose in-memory
     10 /// `pendingPings` payload was lost across a relaunch, or a deleted Core Data
     11 /// entity) must be dropped from the engine's persisted pending changes rather
     12 /// than left queued forever. Without this the device showed a permanent
     13 /// `Pending Changes: 1` that never drained and surfaced no error — every push
     14 /// "succeeded" while silently sending nothing (see the stuck-ping log).
     15 @Suite("PendingChangeReap", .serialized)
     16 @MainActor
     17 struct PendingChangeReapTests {
     18 
     19     private func makeEngine(
     20         persistence: PersistenceController
     21     ) async -> SyncEngine {
     22         let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3")
     23         let engine = SyncEngine(container: container, persistence: persistence)
     24         await engine.start()
     25         return engine
     26     }
     27 
     28     private func makePrivateGame(
     29         in ctx: NSManagedObjectContext
     30     ) throws -> (UUID, String) {
     31         let id = UUID()
     32         let zoneName = "game-\(id.uuidString)"
     33         let entity = GameEntity(context: ctx)
     34         entity.id = id
     35         entity.title = "Private"
     36         entity.puzzleSource = ""
     37         entity.createdAt = Date()
     38         entity.updatedAt = Date()
     39         entity.ckRecordName = zoneName
     40         entity.ckZoneName = zoneName
     41         entity.databaseScope = 0
     42         try ctx.save()
     43         return (id, zoneName)
     44     }
     45 
     46     @Test("An un-buildable pending save is reaped instead of queued forever")
     47     func unbuildableSaveIsReaped() async throws {
     48         let persistence = makeTestPersistence()
     49         let ctx = persistence.viewContext
     50         let (gameID, zoneName) = try makePrivateGame(in: ctx)
     51         let engine = await makeEngine(persistence: persistence)
     52 
     53         await engine.enqueueGame(ckRecordName: zoneName)
     54         let before = await engine.pendingSaveRecordNames(scope: .private)
     55         #expect(before.contains(zoneName))
     56 
     57         // Delete the backing entity so `buildRecord` can no longer
     58         // reconstruct the `game-` record — the same dead-end the engine hits
     59         // for a ping whose payload didn't survive a relaunch.
     60         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
     61         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
     62         let entity = try #require(try ctx.fetch(req).first)
     63         ctx.delete(entity)
     64         try ctx.save()
     65 
     66         _ = await engine.makeRecordZoneChangeBatch(forTestingScope: .private)
     67 
     68         let after = await engine.pendingSaveRecordNames(scope: .private)
     69         #expect(!after.contains(zoneName))
     70     }
     71 
     72     @Test("A buildable pending ping is preserved, not reaped")
     73     func buildablePingIsPreserved() async throws {
     74         let persistence = makeTestPersistence()
     75         let ctx = persistence.viewContext
     76         let (gameID, _) = try makePrivateGame(in: ctx)
     77         let engine = await makeEngine(persistence: persistence)
     78 
     79         await engine.enqueuePing(
     80             kind: .join,
     81             gameID: gameID,
     82             authorID: "_localAuthor",
     83             playerName: "Local"
     84         )
     85         let before = await engine.pendingSaveRecordNames(scope: .private)
     86         let pingName = try #require(before.first { $0.hasPrefix("ping-") })
     87 
     88         // The payload is still in `pendingPings`, so the record builds — the
     89         // reap must not fire just because a record was materialized.
     90         _ = await engine.makeRecordZoneChangeBatch(forTestingScope: .private)
     91 
     92         let after = await engine.pendingSaveRecordNames(scope: .private)
     93         #expect(after.contains(pingName))
     94     }
     95 }