crossmate

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

GameStoreRevocationLockTests.swift (4294B)


      1 import CoreData
      2 import Foundation
      3 import Testing
      4 
      5 @testable import Crossmate
      6 
      7 /// Revocation makes a shared game read-only for the removed participant.
      8 /// The mutator refuses every local mutation — not just the sync emit — and
      9 /// the store refuses to resign or latch a revoked copy into a terminal
     10 /// completed state, so a revoked open puzzle can't fabricate progress or a
     11 /// completion the shared game never reached.
     12 @Suite("GameStore revocation lock", .isolatedNotificationState)
     13 @MainActor
     14 struct GameStoreRevocationLockTests {
     15 
     16     private static let puzzleSource = """
     17     Title: Test Puzzle
     18     Author: Test
     19 
     20 
     21     AB
     22     CD
     23 
     24 
     25     A1. Across 1 ~ AB
     26     A3. Across 3 ~ CD
     27     D1. Down 1 ~ AC
     28     D2. Down 2 ~ BD
     29     """
     30 
     31     private func makeSharedGame(
     32         revoked: Bool,
     33         in ctx: NSManagedObjectContext
     34     ) throws -> (GameEntity, UUID) {
     35         let xd = try XD.parse(Self.puzzleSource)
     36         let puzzle = Puzzle(xd: xd)
     37         let gameID = UUID()
     38         let entity = GameEntity(context: ctx)
     39         entity.id = gameID
     40         entity.title = "Test"
     41         entity.puzzleSource = Self.puzzleSource
     42         entity.createdAt = Date()
     43         entity.updatedAt = Date()
     44         entity.ckRecordName = "game-\(gameID.uuidString)"
     45         entity.databaseScope = DatabaseScope.shared.rawValue
     46         entity.isAccessRevoked = revoked
     47         entity.populateCachedSummaryFields(from: puzzle)
     48         try ctx.save()
     49         return (entity, gameID)
     50     }
     51 
     52     @Test("Loading a revoked game returns a read-only mutator")
     53     func loadedRevokedMutatorIsLocked() throws {
     54         let persistence = makeTestPersistence()
     55         let store = makeTestStore(persistence: persistence)
     56         let (_, gameID) = try makeSharedGame(revoked: true, in: persistence.viewContext)
     57 
     58         let (game, mutator) = try store.loadGame(id: gameID)
     59         #expect(mutator.isAccessRevoked)
     60         #expect(!mutator.isEditable)
     61 
     62         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
     63         mutator.revealCells(game.puzzle.cells.flatMap { $0 })
     64         #expect(game.squares.allSatisfy { row in row.allSatisfy { $0.entry.isEmpty } })
     65     }
     66 
     67     @Test("Mid-session revocation locks the open mutator against further input")
     68     func liveRevocationLocksOpenMutator() throws {
     69         let persistence = makeTestPersistence()
     70         let store = makeTestStore(persistence: persistence)
     71         let (_, gameID) = try makeSharedGame(revoked: false, in: persistence.viewContext)
     72 
     73         let (game, mutator) = try store.loadGame(id: gameID)
     74         mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
     75         #expect(game.squares[0][0].entry == "A")
     76 
     77         store.markAccessRevoked(gameID: gameID)
     78 
     79         mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false)
     80         mutator.revealCells([game.puzzle.cells[1][1]])
     81         #expect(game.squares[0][1].entry == "")
     82         #expect(game.squares[1][1].entry == "")
     83     }
     84 
     85     @Test("A revoked game cannot be marked completed or observed-completed")
     86     func revokedGameRefusesCompletion() throws {
     87         let persistence = makeTestPersistence()
     88         let store = makeTestStore(
     89             persistence: persistence,
     90             authorIDProvider: { "alice" }
     91         )
     92         let (entity, gameID) = try makeSharedGame(revoked: true, in: persistence.viewContext)
     93         var journalCompletions = 0
     94         store.onJournalComplete = { _, _, _, _ in journalCompletions += 1 }
     95 
     96         #expect(try store.markCompleted(id: gameID) == false)
     97         #expect(try store.markCompletedFromObservedSolvedState(id: gameID) == false)
     98 
     99         #expect(entity.completedAt == nil)
    100         #expect(entity.completedBy == nil)
    101         #expect(!entity.hasPendingSave)
    102         #expect(journalCompletions == 0)
    103     }
    104 
    105     @Test("A revoked game cannot be resigned")
    106     func revokedGameRefusesResignation() throws {
    107         let persistence = makeTestPersistence()
    108         let store = makeTestStore(persistence: persistence)
    109         let (entity, gameID) = try makeSharedGame(revoked: true, in: persistence.viewContext)
    110 
    111         try store.resignGame(id: gameID)
    112 
    113         #expect(entity.completedAt == nil)
    114         let (game, _) = try store.loadGame(id: gameID)
    115         #expect(game.squares.allSatisfy { row in row.allSatisfy { $0.entry.isEmpty } })
    116     }
    117 }