crossmate

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

EnsureGameEntityTests.swift (4948B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import Testing
      5 
      6 @testable import Crossmate
      7 
      8 /// Pins down the lazy-parent-creation invariant that prevents Move / Snapshot /
      9 /// Player records from being silently dropped when they arrive in a different
     10 /// CKSyncEngine fetch batch than the Game record that created the zone.
     11 @Suite("EnsureGameEntity")
     12 @MainActor
     13 struct EnsureGameEntityTests {
     14 
     15     @Test("creates a hidden stub when no GameEntity exists")
     16     func createsHiddenStub() throws {
     17         let persistence = makeTestPersistence()
     18         let ctx = persistence.viewContext
     19         let gameID = UUID()
     20         let zoneID = RecordSerializer.zoneID(for: gameID)
     21 
     22         let entity = RecordSerializer.ensureGameEntity(
     23             forGameID: gameID,
     24             zoneID: zoneID,
     25             in: ctx
     26         )
     27 
     28         #expect(entity.id == gameID)
     29         #expect(entity.ckRecordName == "game-\(gameID.uuidString)")
     30         #expect(entity.ckZoneName == zoneID.zoneName)
     31         #expect(entity.databaseScope == 0)
     32         #expect(entity.ckZoneOwnerName == nil)
     33         #expect(entity.title == "")
     34         // Empty puzzleSource is what GameSummary.init? uses to filter the row
     35         // out of the library until the real Game record arrives.
     36         #expect(entity.puzzleSource == "")
     37     }
     38 
     39     @Test("returns existing entity, no duplicate")
     40     func returnsExisting() throws {
     41         let persistence = makeTestPersistence()
     42         let ctx = persistence.viewContext
     43         let gameID = UUID()
     44         let zoneID = RecordSerializer.zoneID(for: gameID)
     45 
     46         let first = RecordSerializer.ensureGameEntity(
     47             forGameID: gameID,
     48             zoneID: zoneID,
     49             in: ctx
     50         )
     51         first.title = "preserved"
     52         let second = RecordSerializer.ensureGameEntity(
     53             forGameID: gameID,
     54             zoneID: zoneID,
     55             in: ctx
     56         )
     57 
     58         #expect(first.objectID == second.objectID)
     59         #expect(second.title == "preserved")
     60 
     61         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
     62         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
     63         let all = try ctx.fetch(req)
     64         #expect(all.count == 1)
     65     }
     66 
     67     @Test("shared scope stores the zone owner")
     68     func sharedScope() throws {
     69         let persistence = makeTestPersistence()
     70         let ctx = persistence.viewContext
     71         let gameID = UUID()
     72         let zoneID = RecordSerializer.zoneID(for: gameID, ownerName: "_someOwnerID")
     73 
     74         let entity = RecordSerializer.ensureGameEntity(
     75             forGameID: gameID,
     76             zoneID: zoneID,
     77             databaseScope: .shared,
     78             in: ctx
     79         )
     80 
     81         #expect(entity.databaseScope == 1)
     82         #expect(entity.ckZoneOwnerName == "_someOwnerID")
     83     }
     84 
     85     @Test("private scope stores a nil owner even when the zone ID carries a concrete one")
     86     func privateScopeNormalizesOwner() throws {
     87         let persistence = makeTestPersistence()
     88         let ctx = persistence.viewContext
     89         let gameID = UUID()
     90         // CloudKit round-trips a private zone's owner as either the
     91         // placeholder or the current user's concrete record ID; the stored
     92         // row must not depend on which spelling arrived.
     93         let zoneID = RecordSerializer.zoneID(for: gameID, ownerName: "_myOwnRecordID")
     94 
     95         let entity = RecordSerializer.ensureGameEntity(
     96             forGameID: gameID,
     97             zoneID: zoneID,
     98             databaseScope: .private,
     99             in: ctx
    100         )
    101 
    102         #expect(entity.databaseScope == 0)
    103         #expect(entity.ckZoneOwnerName == nil)
    104     }
    105 
    106     @Test("later Game record populates the same stub row")
    107     func gameRecordPopulatesStub() throws {
    108         let persistence = makeTestPersistence()
    109         let ctx = persistence.viewContext
    110         let gameID = UUID()
    111         let zoneID = RecordSerializer.zoneID(for: gameID)
    112 
    113         let stub = RecordSerializer.ensureGameEntity(
    114             forGameID: gameID,
    115             zoneID: zoneID,
    116             in: ctx
    117         )
    118         let stubObjectID = stub.objectID
    119 
    120         // Simulate the Game record arriving in a later batch. applyGameRecord
    121         // matches by ckRecordName via fetchOrCreate, so the stub row should be
    122         // updated rather than a new row created.
    123         let recordID = CKRecord.ID(
    124             recordName: RecordSerializer.recordName(forGameID: gameID),
    125             zoneID: zoneID
    126         )
    127         let record = CKRecord(recordType: "Game", recordID: recordID)
    128         record["title"] = "Real Title" as CKRecordValue
    129 
    130         let updated = RecordSerializer.applyGameRecord(record, to: ctx)
    131 
    132         #expect(updated.objectID == stubObjectID)
    133         #expect(updated.title == "Real Title")
    134 
    135         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    136         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    137         let all = try ctx.fetch(req)
    138         #expect(all.count == 1)
    139     }
    140 }