crossmate

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

ShareRoutingTests.swift (14263B)


      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     /// narrowed to the current authorID when the engine knows one — the
     61     /// account-switch tests below seed a second author on the same device to
     62     /// pin that narrowing.
     63     private static func seedMovesEntity(
     64         for game: GameEntity,
     65         gameID: UUID,
     66         in ctx: NSManagedObjectContext,
     67         confirmed: Bool = false,
     68         authorID: String = writerAuthorID
     69     ) {
     70         let entity = MovesEntity(context: ctx)
     71         entity.game = game
     72         entity.authorID = authorID
     73         entity.deviceID = RecordSerializer.localDeviceID
     74         entity.cells = Data()
     75         entity.updatedAt = Date()
     76         entity.ckRecordName = RecordSerializer.recordName(
     77             forMovesInGame: gameID,
     78             authorID: authorID,
     79             deviceID: RecordSerializer.localDeviceID
     80         )
     81         if confirmed {
     82             entity.ckSystemFields = Data([0xff])
     83         }
     84     }
     85 
     86     private func makeEngineWithPersistedMove() async throws -> (SyncEngine, UUID) {
     87         let persistence = makeTestPersistence()
     88         let ctx = persistence.viewContext
     89 
     90         let gameID = UUID()
     91         let game = GameEntity(context: ctx)
     92         game.id = gameID
     93         game.title = "Private"
     94         game.puzzleSource = ""
     95         game.createdAt = Date()
     96         game.updatedAt = Date()
     97         game.ckRecordName = "game-\(gameID.uuidString)"
     98         game.ckZoneName = "game-\(gameID.uuidString)"
     99         game.databaseScope = 0
    100 
    101         // Unconfirmed: ckSystemFields is nil so enqueueUnconfirmedMoves picks it up.
    102         Self.seedMovesEntity(for: game, gameID: gameID, in: ctx)
    103 
    104         try ctx.save()
    105 
    106         let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3")
    107         let engine = SyncEngine(container: container, persistence: persistence)
    108         await engine.start()
    109 
    110         return (engine, gameID)
    111     }
    112 
    113     private func movesRecordName(for gameID: UUID) -> String {
    114         RecordSerializer.recordName(
    115             forMovesInGame: gameID,
    116             authorID: Self.writerAuthorID,
    117             deviceID: RecordSerializer.localDeviceID
    118         )
    119     }
    120 
    121     @Test("Private-game moves land on the private engine only")
    122     func privateMoveEnqueue() async throws {
    123         let (engine, privateID, _) = try await makeEngineWithGames()
    124         await engine.enqueueMoves(gameIDs: [privateID])
    125 
    126         let privateNames = await engine.pendingSaveRecordNames(scope: .private)
    127         let sharedNames = await engine.pendingSaveRecordNames(scope: .shared)
    128 
    129         #expect(privateNames.contains(movesRecordName(for: privateID)))
    130         #expect(sharedNames.isEmpty)
    131     }
    132 
    133     @Test("Shared-game moves land on the shared engine only")
    134     func sharedMoveEnqueue() async throws {
    135         let (engine, _, sharedID) = try await makeEngineWithGames()
    136         await engine.enqueueMoves(gameIDs: [sharedID])
    137 
    138         let privateNames = await engine.pendingSaveRecordNames(scope: .private)
    139         let sharedNames = await engine.pendingSaveRecordNames(scope: .shared)
    140 
    141         #expect(sharedNames.contains(movesRecordName(for: sharedID)))
    142         #expect(privateNames.isEmpty)
    143     }
    144 
    145     @Test("Mixed-scope batch fans out to the correct engines")
    146     func mixedScopeEnqueue() async throws {
    147         let (engine, privateID, sharedID) = try await makeEngineWithGames()
    148         await engine.enqueueMoves(gameIDs: [privateID, sharedID])
    149 
    150         let privateNames = await engine.pendingSaveRecordNames(scope: .private)
    151         let sharedNames = await engine.pendingSaveRecordNames(scope: .shared)
    152 
    153         let privateMoves = movesRecordName(for: privateID)
    154         let sharedMoves = movesRecordName(for: sharedID)
    155         #expect(privateNames.contains(privateMoves))
    156         #expect(!privateNames.contains(sharedMoves))
    157         #expect(sharedNames.contains(sharedMoves))
    158         #expect(!sharedNames.contains(privateMoves))
    159     }
    160 
    161     @Test("Deleting a private game queues its zone for private CloudKit deletion")
    162     func privateGameDeleteEnqueue() async throws {
    163         let persistence = makeTestPersistence()
    164         let ctx = persistence.viewContext
    165         let gameID = UUID()
    166         let zoneName = "game-\(gameID.uuidString)"
    167 
    168         let game = GameEntity(context: ctx)
    169         game.id = gameID
    170         game.title = "Private"
    171         game.puzzleSource = ""
    172         game.createdAt = Date()
    173         game.updatedAt = Date()
    174         game.ckRecordName = zoneName
    175         game.ckZoneName = zoneName
    176         game.databaseScope = 0
    177         try ctx.save()
    178 
    179         let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3")
    180         let engine = SyncEngine(container: container, persistence: persistence)
    181         await engine.start()
    182 
    183         var capturedDeletion: GameCloudDeletion?
    184         let store = makeTestStore(
    185             persistence: persistence,
    186             onGameDeleted: { deletion in
    187                 capturedDeletion = deletion
    188             }
    189         )
    190         try store.deleteGame(id: gameID)
    191         let deletion = try #require(capturedDeletion)
    192         await engine.enqueueDeleteGame(deletion)
    193 
    194         let privateDeletes = await engine.pendingDeletedZoneNames(scope: .private)
    195         let sharedDeletes = await engine.pendingDeletedZoneNames(scope: .shared)
    196         #expect(privateDeletes.contains(zoneName))
    197         #expect(sharedDeletes.isEmpty)
    198     }
    199 
    200     @Test("Deleting a shared game queues its zone for shared CloudKit deletion")
    201     func sharedGameDeleteEnqueue() async throws {
    202         let persistence = makeTestPersistence()
    203         let ctx = persistence.viewContext
    204         let gameID = UUID()
    205         let zoneName = "game-\(gameID.uuidString)"
    206 
    207         let game = GameEntity(context: ctx)
    208         game.id = gameID
    209         game.title = "Shared"
    210         game.puzzleSource = ""
    211         game.createdAt = Date()
    212         game.updatedAt = Date()
    213         game.ckRecordName = zoneName
    214         game.ckZoneName = zoneName
    215         game.ckZoneOwnerName = "_someOtherUser"
    216         game.databaseScope = 1
    217         try ctx.save()
    218 
    219         let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3")
    220         let engine = SyncEngine(container: container, persistence: persistence)
    221         await engine.start()
    222 
    223         var capturedDeletion: GameCloudDeletion?
    224         let store = makeTestStore(
    225             persistence: persistence,
    226             onGameDeleted: { deletion in
    227                 capturedDeletion = deletion
    228             }
    229         )
    230         try store.deleteGame(id: gameID)
    231         let deletion = try #require(capturedDeletion)
    232         await engine.enqueueDeleteGame(deletion)
    233 
    234         let privateDeletes = await engine.pendingDeletedZoneNames(scope: .private)
    235         let sharedDeletes = await engine.pendingDeletedZoneNames(scope: .shared)
    236         #expect(sharedDeletes.contains(zoneName))
    237         #expect(privateDeletes.isEmpty)
    238     }
    239 
    240     @Test("Unknown game IDs enqueue nothing")
    241     func unknownGameIsDropped() async throws {
    242         let (engine, _, _) = try await makeEngineWithGames()
    243         let orphan = UUID()
    244         await engine.enqueueMoves(gameIDs: [orphan])
    245 
    246         let privateNames = await engine.pendingSaveRecordNames(scope: .private)
    247         let sharedNames = await engine.pendingSaveRecordNames(scope: .shared)
    248 
    249         #expect(privateNames.isEmpty)
    250         #expect(sharedNames.isEmpty)
    251     }
    252 
    253     @Test("Duplicate enqueue keeps one pending save")
    254     func duplicateMoveEnqueueIsDeduped() async throws {
    255         let (engine, privateID, _) = try await makeEngineWithGames()
    256 
    257         await engine.enqueueMoves(gameIDs: [privateID])
    258         await engine.enqueueMoves(gameIDs: [privateID])
    259 
    260         let recordName = movesRecordName(for: privateID)
    261         let privateNames = await engine.pendingSaveRecordNames(scope: .private)
    262         #expect(privateNames.filter { $0 == recordName }.count == 1)
    263     }
    264 
    265     @Test("Unconfirmed persisted Moves rows are re-enqueued")
    266     func unconfirmedPersistedMovesAreReEnqueued() async throws {
    267         let (engine, gameID) = try await makeEngineWithPersistedMove()
    268 
    269         let count = await engine.enqueueUnconfirmedMoves()
    270 
    271         let recordName = movesRecordName(for: gameID)
    272         let privateNames = await engine.pendingSaveRecordNames(scope: .private)
    273         #expect(count == 1)
    274         #expect(privateNames.contains(recordName))
    275     }
    276 
    277     /// Simulates the residue of an iCloud account switch: the same device
    278     /// holds Moves rows under two authorIDs. With the previous device-only
    279     /// lookup, the `fetchLimit = 1` fetch could return the old account's row
    280     /// and enqueue a record the current user no longer owns.
    281     @Test("Enqueue targets the current author's row, not an old account's")
    282     func enqueueScopedToCurrentAuthorAfterAccountSwitch() async throws {
    283         let persistence = makeTestPersistence()
    284         let ctx = persistence.viewContext
    285 
    286         let gameID = UUID()
    287         let game = GameEntity(context: ctx)
    288         game.id = gameID
    289         game.title = "Private"
    290         game.puzzleSource = ""
    291         game.createdAt = Date()
    292         game.updatedAt = Date()
    293         game.ckRecordName = "game-\(gameID.uuidString)"
    294         game.ckZoneName = "game-\(gameID.uuidString)"
    295         game.databaseScope = 0
    296         Self.seedMovesEntity(for: game, gameID: gameID, in: ctx, authorID: "old-account")
    297         Self.seedMovesEntity(for: game, gameID: gameID, in: ctx)
    298         try ctx.save()
    299 
    300         let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3")
    301         let engine = SyncEngine(container: container, persistence: persistence)
    302         await engine.start()
    303         await engine.setLocalAuthorIDProvider { Self.writerAuthorID }
    304 
    305         await engine.enqueueMoves(gameIDs: [gameID])
    306 
    307         let privateNames = await engine.pendingSaveRecordNames(scope: .private)
    308         #expect(privateNames.contains(movesRecordName(for: gameID)))
    309         #expect(!privateNames.contains(RecordSerializer.recordName(
    310             forMovesInGame: gameID,
    311             authorID: "old-account",
    312             deviceID: RecordSerializer.localDeviceID
    313         )))
    314     }
    315 
    316     @Test("Unconfirmed recovery skips an old account's leftover rows")
    317     func unconfirmedRecoveryScopedToCurrentAuthor() async throws {
    318         let persistence = makeTestPersistence()
    319         let ctx = persistence.viewContext
    320 
    321         // Game A carries only the old account's unconfirmed row; game B
    322         // carries the current author's. Recovery must re-enqueue B alone.
    323         func makeGame(_ title: String) -> (GameEntity, UUID) {
    324             let id = UUID()
    325             let game = GameEntity(context: ctx)
    326             game.id = id
    327             game.title = title
    328             game.puzzleSource = ""
    329             game.createdAt = Date()
    330             game.updatedAt = Date()
    331             game.ckRecordName = "game-\(id.uuidString)"
    332             game.ckZoneName = "game-\(id.uuidString)"
    333             game.databaseScope = 0
    334             return (game, id)
    335         }
    336         let (gameA, idA) = makeGame("Old account's")
    337         Self.seedMovesEntity(for: gameA, gameID: idA, in: ctx, authorID: "old-account")
    338         let (gameB, idB) = makeGame("Current author's")
    339         Self.seedMovesEntity(for: gameB, gameID: idB, in: ctx)
    340         try ctx.save()
    341 
    342         let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3")
    343         let engine = SyncEngine(container: container, persistence: persistence)
    344         await engine.start()
    345         await engine.setLocalAuthorIDProvider { Self.writerAuthorID }
    346 
    347         let count = await engine.enqueueUnconfirmedMoves()
    348 
    349         let privateNames = await engine.pendingSaveRecordNames(scope: .private)
    350         #expect(count == 1)
    351         #expect(privateNames.contains(movesRecordName(for: idB)))
    352         #expect(!privateNames.contains(RecordSerializer.recordName(
    353             forMovesInGame: idA,
    354             authorID: "old-account",
    355             deviceID: RecordSerializer.localDeviceID
    356         )))
    357     }
    358 }