crossmate

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

ShareRoutingTests.swift (10449B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import Testing
      5 
      6 @testable import Crossmate
      7 
      8 /// Verifies that `SyncEngine.enqueueMoves` routes to the correct engine based
      9 /// on each game's `databaseScope`. Uses a real in-memory persistence store and
     10 /// inspects each engine's pending-changes queue after enqueueing. No iCloud
     11 /// account is required — `CKSyncEngine.init` and its local state APIs work
     12 /// offline; only `fetchChanges` / `sendChanges` need an account.
     13 @Suite("ShareRouting", .serialized)
     14 @MainActor
     15 struct ShareRoutingTests {
     16 
     17     private static let writerAuthorID = "alice"
     18 
     19     private func makeEngineWithGames() async throws -> (SyncEngine, UUID, UUID) {
     20         let persistence = makeTestPersistence()
     21         let ctx = persistence.viewContext
     22 
     23         let privateID = UUID()
     24         let privateEntity = GameEntity(context: ctx)
     25         privateEntity.id = privateID
     26         privateEntity.title = "Private"
     27         privateEntity.puzzleSource = ""
     28         privateEntity.createdAt = Date()
     29         privateEntity.updatedAt = Date()
     30         privateEntity.ckRecordName = "game-\(privateID.uuidString)"
     31         privateEntity.ckZoneName = "game-\(privateID.uuidString)"
     32         privateEntity.databaseScope = 0
     33         Self.seedMovesEntity(for: privateEntity, gameID: privateID, in: ctx)
     34 
     35         let sharedID = UUID()
     36         let sharedEntity = GameEntity(context: ctx)
     37         sharedEntity.id = sharedID
     38         sharedEntity.title = "Shared"
     39         sharedEntity.puzzleSource = ""
     40         sharedEntity.createdAt = Date()
     41         sharedEntity.updatedAt = Date()
     42         sharedEntity.ckRecordName = "game-\(sharedID.uuidString)"
     43         sharedEntity.ckZoneName = "game-\(sharedID.uuidString)"
     44         sharedEntity.ckZoneOwnerName = "_someOtherUser"
     45         sharedEntity.databaseScope = 1
     46         Self.seedMovesEntity(for: sharedEntity, gameID: sharedID, in: ctx)
     47 
     48         try ctx.save()
     49 
     50         let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3")
     51         let engine = SyncEngine(container: container, persistence: persistence)
     52         await engine.start()
     53 
     54         return (engine, privateID, sharedID)
     55     }
     56 
     57     /// Inserts a confirmed (system-fields-bearing — simulated by setting non-nil
     58     /// `ckSystemFields`-equivalent state) MovesEntity row for the local device.
     59     /// `enqueueMoves` looks up rows by `(game.id, deviceID == localDeviceID)`.
     60     private static func seedMovesEntity(
     61         for game: GameEntity,
     62         gameID: UUID,
     63         in ctx: NSManagedObjectContext,
     64         confirmed: Bool = false
     65     ) {
     66         let entity = MovesEntity(context: ctx)
     67         entity.game = game
     68         entity.authorID = writerAuthorID
     69         entity.deviceID = RecordSerializer.localDeviceID
     70         entity.cells = Data()
     71         entity.updatedAt = Date()
     72         entity.ckRecordName = RecordSerializer.recordName(
     73             forMovesInGame: gameID,
     74             authorID: writerAuthorID,
     75             deviceID: RecordSerializer.localDeviceID
     76         )
     77         if confirmed {
     78             entity.ckSystemFields = Data([0xff])
     79         }
     80     }
     81 
     82     private func makeEngineWithPersistedMove() async throws -> (SyncEngine, UUID) {
     83         let persistence = makeTestPersistence()
     84         let ctx = persistence.viewContext
     85 
     86         let gameID = UUID()
     87         let game = GameEntity(context: ctx)
     88         game.id = gameID
     89         game.title = "Private"
     90         game.puzzleSource = ""
     91         game.createdAt = Date()
     92         game.updatedAt = Date()
     93         game.ckRecordName = "game-\(gameID.uuidString)"
     94         game.ckZoneName = "game-\(gameID.uuidString)"
     95         game.databaseScope = 0
     96 
     97         // Unconfirmed: ckSystemFields is nil so enqueueUnconfirmedMoves picks it up.
     98         Self.seedMovesEntity(for: game, gameID: gameID, in: ctx)
     99 
    100         try ctx.save()
    101 
    102         let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3")
    103         let engine = SyncEngine(container: container, persistence: persistence)
    104         await engine.start()
    105 
    106         return (engine, gameID)
    107     }
    108 
    109     private func movesRecordName(for gameID: UUID) -> String {
    110         RecordSerializer.recordName(
    111             forMovesInGame: gameID,
    112             authorID: Self.writerAuthorID,
    113             deviceID: RecordSerializer.localDeviceID
    114         )
    115     }
    116 
    117     @Test("Private-game moves land on the private engine only")
    118     func privateMoveEnqueue() async throws {
    119         let (engine, privateID, _) = try await makeEngineWithGames()
    120         await engine.enqueueMoves(gameIDs: [privateID])
    121 
    122         let privateNames = await engine.pendingSaveRecordNames(scope: .private)
    123         let sharedNames = await engine.pendingSaveRecordNames(scope: .shared)
    124 
    125         #expect(privateNames.contains(movesRecordName(for: privateID)))
    126         #expect(sharedNames.isEmpty)
    127     }
    128 
    129     @Test("Shared-game moves land on the shared engine only")
    130     func sharedMoveEnqueue() async throws {
    131         let (engine, _, sharedID) = try await makeEngineWithGames()
    132         await engine.enqueueMoves(gameIDs: [sharedID])
    133 
    134         let privateNames = await engine.pendingSaveRecordNames(scope: .private)
    135         let sharedNames = await engine.pendingSaveRecordNames(scope: .shared)
    136 
    137         #expect(sharedNames.contains(movesRecordName(for: sharedID)))
    138         #expect(privateNames.isEmpty)
    139     }
    140 
    141     @Test("Mixed-scope batch fans out to the correct engines")
    142     func mixedScopeEnqueue() async throws {
    143         let (engine, privateID, sharedID) = try await makeEngineWithGames()
    144         await engine.enqueueMoves(gameIDs: [privateID, sharedID])
    145 
    146         let privateNames = await engine.pendingSaveRecordNames(scope: .private)
    147         let sharedNames = await engine.pendingSaveRecordNames(scope: .shared)
    148 
    149         let privateMoves = movesRecordName(for: privateID)
    150         let sharedMoves = movesRecordName(for: sharedID)
    151         #expect(privateNames.contains(privateMoves))
    152         #expect(!privateNames.contains(sharedMoves))
    153         #expect(sharedNames.contains(sharedMoves))
    154         #expect(!sharedNames.contains(privateMoves))
    155     }
    156 
    157     @Test("Deleting a private game queues its zone for private CloudKit deletion")
    158     func privateGameDeleteEnqueue() async throws {
    159         let persistence = makeTestPersistence()
    160         let ctx = persistence.viewContext
    161         let gameID = UUID()
    162         let zoneName = "game-\(gameID.uuidString)"
    163 
    164         let game = GameEntity(context: ctx)
    165         game.id = gameID
    166         game.title = "Private"
    167         game.puzzleSource = ""
    168         game.createdAt = Date()
    169         game.updatedAt = Date()
    170         game.ckRecordName = zoneName
    171         game.ckZoneName = zoneName
    172         game.databaseScope = 0
    173         try ctx.save()
    174 
    175         let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3")
    176         let engine = SyncEngine(container: container, persistence: persistence)
    177         await engine.start()
    178 
    179         var capturedDeletion: GameCloudDeletion?
    180         let store = makeTestStore(
    181             persistence: persistence,
    182             onGameDeleted: { deletion in
    183                 capturedDeletion = deletion
    184             }
    185         )
    186         try store.deleteGame(id: gameID)
    187         let deletion = try #require(capturedDeletion)
    188         await engine.enqueueDeleteGame(deletion)
    189 
    190         let privateDeletes = await engine.pendingDeletedZoneNames(scope: .private)
    191         let sharedDeletes = await engine.pendingDeletedZoneNames(scope: .shared)
    192         #expect(privateDeletes.contains(zoneName))
    193         #expect(sharedDeletes.isEmpty)
    194     }
    195 
    196     @Test("Deleting a shared game queues its zone for shared CloudKit deletion")
    197     func sharedGameDeleteEnqueue() async throws {
    198         let persistence = makeTestPersistence()
    199         let ctx = persistence.viewContext
    200         let gameID = UUID()
    201         let zoneName = "game-\(gameID.uuidString)"
    202 
    203         let game = GameEntity(context: ctx)
    204         game.id = gameID
    205         game.title = "Shared"
    206         game.puzzleSource = ""
    207         game.createdAt = Date()
    208         game.updatedAt = Date()
    209         game.ckRecordName = zoneName
    210         game.ckZoneName = zoneName
    211         game.ckZoneOwnerName = "_someOtherUser"
    212         game.databaseScope = 1
    213         try ctx.save()
    214 
    215         let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3")
    216         let engine = SyncEngine(container: container, persistence: persistence)
    217         await engine.start()
    218 
    219         var capturedDeletion: GameCloudDeletion?
    220         let store = makeTestStore(
    221             persistence: persistence,
    222             onGameDeleted: { deletion in
    223                 capturedDeletion = deletion
    224             }
    225         )
    226         try store.deleteGame(id: gameID)
    227         let deletion = try #require(capturedDeletion)
    228         await engine.enqueueDeleteGame(deletion)
    229 
    230         let privateDeletes = await engine.pendingDeletedZoneNames(scope: .private)
    231         let sharedDeletes = await engine.pendingDeletedZoneNames(scope: .shared)
    232         #expect(sharedDeletes.contains(zoneName))
    233         #expect(privateDeletes.isEmpty)
    234     }
    235 
    236     @Test("Unknown game IDs enqueue nothing")
    237     func unknownGameIsDropped() async throws {
    238         let (engine, _, _) = try await makeEngineWithGames()
    239         let orphan = UUID()
    240         await engine.enqueueMoves(gameIDs: [orphan])
    241 
    242         let privateNames = await engine.pendingSaveRecordNames(scope: .private)
    243         let sharedNames = await engine.pendingSaveRecordNames(scope: .shared)
    244 
    245         #expect(privateNames.isEmpty)
    246         #expect(sharedNames.isEmpty)
    247     }
    248 
    249     @Test("Duplicate enqueue keeps one pending save")
    250     func duplicateMoveEnqueueIsDeduped() async throws {
    251         let (engine, privateID, _) = try await makeEngineWithGames()
    252 
    253         await engine.enqueueMoves(gameIDs: [privateID])
    254         await engine.enqueueMoves(gameIDs: [privateID])
    255 
    256         let recordName = movesRecordName(for: privateID)
    257         let privateNames = await engine.pendingSaveRecordNames(scope: .private)
    258         #expect(privateNames.filter { $0 == recordName }.count == 1)
    259     }
    260 
    261     @Test("Unconfirmed persisted Moves rows are re-enqueued")
    262     func unconfirmedPersistedMovesAreReEnqueued() async throws {
    263         let (engine, gameID) = try await makeEngineWithPersistedMove()
    264 
    265         let count = await engine.enqueueUnconfirmedMoves()
    266 
    267         let recordName = movesRecordName(for: gameID)
    268         let privateNames = await engine.pendingSaveRecordNames(scope: .private)
    269         #expect(count == 1)
    270         #expect(privateNames.contains(recordName))
    271     }
    272 }