crossmate

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

JournalUploadTests.swift (12532B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import Testing
      5 
      6 @testable import Crossmate
      7 
      8 /// Phase 2 upload pipeline: the `JournalCodec` wire format, the `Journal`
      9 /// record naming/building in `RecordSerializer`, and the end-to-end enqueue +
     10 /// `buildRecord` path through `SyncEngine`. The replay viewer (Phase 2b) is not
     11 /// covered here.
     12 
     13 // MARK: - Wire format
     14 
     15 @Suite("JournalCodec")
     16 struct JournalCodecTests {
     17 
     18     private func value(
     19         seq: Int64,
     20         row: Int,
     21         col: Int,
     22         letter: String,
     23         mark: CellMark,
     24         kind: JournalKind,
     25         actingAuthorID: String? = nil,
     26         cellAuthorID: String? = nil,
     27         targetSeq: Int64? = nil,
     28         batchID: UUID? = nil,
     29         prevSeqAtCell: Int64? = nil,
     30         direction: Puzzle.Direction? = nil
     31     ) -> JournalValue {
     32         JournalValue(
     33             seq: seq,
     34             timestamp: Date(timeIntervalSince1970: 1_700_000_000 + Double(seq)),
     35             position: GridPosition(row: row, col: col),
     36             state: JournalCellState(letter: letter, mark: mark, cellAuthorID: cellAuthorID),
     37             actingAuthorID: actingAuthorID,
     38             kind: kind,
     39             targetSeq: targetSeq,
     40             batchID: batchID,
     41             prevSeqAtCell: prevSeqAtCell,
     42             direction: direction
     43         )
     44     }
     45 
     46     @Test("encode/decode round-trips every field, including nil optionals")
     47     func roundTrip() throws {
     48         let batch = UUID()
     49         let values = [
     50             value(seq: 0, row: 0, col: 0, letter: "A", mark: .pen(checked: nil), kind: .input,
     51                   actingAuthorID: "alice", cellAuthorID: "alice", direction: .across),
     52             value(seq: 1, row: 1, col: 2, letter: "B", mark: .pencil(checked: .wrong), kind: .clear,
     53                   actingAuthorID: "alice", cellAuthorID: nil, batchID: batch, prevSeqAtCell: 0, direction: .down),
     54             value(seq: 2, row: 2, col: 2, letter: "", mark: .none, kind: .undo,
     55                   targetSeq: 0, batchID: batch),
     56             value(seq: 3, row: 0, col: 1, letter: "C", mark: .revealed, kind: .reveal),
     57         ]
     58         let data = try JournalCodec.encode(values)
     59         let decoded = try JournalCodec.decode(data)
     60         #expect(decoded == values)
     61     }
     62 
     63     @Test("encode sorts entries by seq")
     64     func encodeSortsBySeq() throws {
     65         let values = [
     66             value(seq: 2, row: 0, col: 0, letter: "C", mark: .none, kind: .input),
     67             value(seq: 0, row: 0, col: 0, letter: "A", mark: .none, kind: .input),
     68             value(seq: 1, row: 0, col: 0, letter: "B", mark: .none, kind: .input),
     69         ]
     70         let decoded = try JournalCodec.decode(try JournalCodec.encode(values))
     71         #expect(decoded.map(\.seq) == [0, 1, 2])
     72     }
     73 
     74     @Test("decode tolerates a payload missing the optional keys (forward-compat)")
     75     func decodeToleratesMissingOptionals() throws {
     76         let ts = Date(timeIntervalSince1970: 1_700_000_000)
     77         let json: [String: Any] = ["entries": [[
     78             "seq": 5,
     79             "timestamp": ts.timeIntervalSinceReferenceDate,
     80             "row": 1,
     81             "col": 2,
     82             "letter": "Z",
     83             "markCode": 7,
     84             "kind": 2,
     85         ]]]
     86         let data = try JSONSerialization.data(withJSONObject: json)
     87         let decoded = try JournalCodec.decode(data)
     88         let entry = try #require(decoded.first)
     89         #expect(entry.seq == 5)
     90         #expect(entry.position == GridPosition(row: 1, col: 2))
     91         #expect(entry.state.letter == "Z")
     92         #expect(entry.state.mark == .revealed)
     93         #expect(entry.kind == .reveal)
     94         #expect(entry.state.cellAuthorID == nil)
     95         #expect(entry.actingAuthorID == nil)
     96         #expect(entry.targetSeq == nil)
     97         #expect(entry.batchID == nil)
     98         #expect(entry.prevSeqAtCell == nil)
     99         #expect(entry.direction == nil)
    100     }
    101 
    102     @Test("decode rejects a blob over the byte cap before parsing")
    103     func decodeRejectsOversizedBlob() {
    104         let oversized = Data(count: JournalCodec.maxAssetBytes + 1)
    105         #expect(throws: JournalCodec.LimitError.self) {
    106             _ = try JournalCodec.decode(oversized)
    107         }
    108     }
    109 
    110     @Test("decode rejects a blob over the entry-count cap")
    111     func decodeRejectsTooManyEntries() throws {
    112         let values = (0...JournalCodec.maxEntryCount).map {
    113             value(seq: Int64($0), row: 0, col: 0, letter: "A", mark: .none, kind: .input)
    114         }
    115         let data = try JournalCodec.encode(values)
    116         // Stays under the byte cap so the entry-count gate is what fires.
    117         #expect(data.count <= JournalCodec.maxAssetBytes)
    118         #expect(throws: JournalCodec.LimitError.self) {
    119             _ = try JournalCodec.decode(data)
    120         }
    121     }
    122 
    123     @Test("decode accepts a blob at the entry-count cap")
    124     func decodeAcceptsEntryCountAtCap() throws {
    125         let values = (0..<JournalCodec.maxEntryCount).map {
    126             value(seq: Int64($0), row: 0, col: 0, letter: "A", mark: .none, kind: .input)
    127         }
    128         let decoded = try JournalCodec.decode(try JournalCodec.encode(values))
    129         #expect(decoded.count == JournalCodec.maxEntryCount)
    130     }
    131 }
    132 
    133 // MARK: - Record naming + building
    134 
    135 @Suite("RecordSerializer Journal")
    136 struct RecordSerializerJournalTests {
    137 
    138     private let gameID = UUID(uuidString: "AABBCCDD-0000-0000-0000-111122223333")!
    139     private var zoneID: CKRecordZone.ID { RecordSerializer.zoneID(for: gameID) }
    140 
    141     @Test("Journal record name uses the expected format")
    142     func nameFormat() {
    143         let name = RecordSerializer.recordName(
    144             forJournalInGame: gameID,
    145             authorID: "alice",
    146             deviceID: "deadbeef"
    147         )
    148         #expect(name == "journal-\(gameID.uuidString)-alice-deadbeef")
    149     }
    150 
    151     @Test("Journal record name parses back, even when authorID contains dashes")
    152     func nameRoundTrip() {
    153         let name = RecordSerializer.recordName(
    154             forJournalInGame: gameID,
    155             authorID: "alice-with-dashes",
    156             deviceID: "deadbeef"
    157         )
    158         let parsed = RecordSerializer.parseJournalRecordName(name)
    159         #expect(parsed?.0 == gameID)
    160         #expect(parsed?.1 == "alice-with-dashes")
    161         #expect(parsed?.2 == "deadbeef")
    162     }
    163 
    164     @Test("parse rejects a non-journal name")
    165     func parseRejectsOtherPrefix() {
    166         let moves = RecordSerializer.recordName(
    167             forMovesInGame: gameID, authorID: "alice", deviceID: "deadbeef"
    168         )
    169         #expect(RecordSerializer.parseJournalRecordName(moves) == nil)
    170     }
    171 
    172     @Test("Journal record carries the entries asset, which decodes back")
    173     func recordAssetRoundTrips() throws {
    174         let entries = [
    175             JournalValue(
    176                 seq: 0,
    177                 timestamp: Date(timeIntervalSince1970: 1_700_000_000),
    178                 position: GridPosition(row: 0, col: 0),
    179                 state: JournalCellState(letter: "A", mark: .pen(checked: nil), cellAuthorID: "alice"),
    180                 actingAuthorID: "alice",
    181                 kind: .input,
    182                 targetSeq: nil,
    183                 batchID: nil,
    184                 prevSeqAtCell: nil,
    185                 direction: nil
    186             ),
    187             JournalValue(
    188                 seq: 1,
    189                 timestamp: Date(timeIntervalSince1970: 1_700_000_010),
    190                 position: GridPosition(row: 1, col: 2),
    191                 state: JournalCellState(letter: "B", mark: .revealed, cellAuthorID: nil),
    192                 actingAuthorID: "bob",
    193                 kind: .reveal,
    194                 targetSeq: nil,
    195                 batchID: UUID(),
    196                 prevSeqAtCell: nil,
    197                 direction: nil
    198             ),
    199         ]
    200         let updatedAt = Date(timeIntervalSince1970: 1_700_000_010)
    201         let record = try RecordSerializer.journalRecord(
    202             gameID: gameID,
    203             authorID: "alice",
    204             deviceID: "deadbeef",
    205             updatedAt: updatedAt,
    206             entries: entries,
    207             zone: zoneID
    208         )
    209 
    210         #expect(record.recordType == "Journal")
    211         // authorID/deviceID live in the record name, not as fields.
    212         let parsed = try #require(RecordSerializer.parseJournalRecordName(record.recordID.recordName))
    213         #expect(parsed.1 == "alice")
    214         #expect(parsed.2 == "deadbeef")
    215         #expect(record["updatedAt"] as? Date == updatedAt)
    216 
    217         let asset = try #require(record["entries"] as? CKAsset)
    218         let url = try #require(asset.fileURL)
    219         let data = try Data(contentsOf: url)
    220         #expect(try JournalCodec.decode(data) == entries)
    221     }
    222 }
    223 
    224 // MARK: - End-to-end enqueue + build
    225 
    226 @Suite("Journal upload via SyncEngine", .serialized)
    227 @MainActor
    228 struct JournalUploadEngineTests {
    229 
    230     private func makeEngine(persistence: PersistenceController) async -> SyncEngine {
    231         let container = CloudContainer.container
    232         let engine = SyncEngine(container: container, persistence: persistence)
    233         await engine.start()
    234         return engine
    235     }
    236 
    237     private func makePrivateGame(in ctx: NSManagedObjectContext) throws -> UUID {
    238         let id = UUID()
    239         let zoneName = "game-\(id.uuidString)"
    240         let entity = GameEntity(context: ctx)
    241         entity.id = id
    242         entity.title = "Private"
    243         entity.puzzleSource = ""
    244         entity.createdAt = Date()
    245         entity.updatedAt = Date()
    246         entity.ckRecordName = zoneName
    247         entity.ckZoneName = zoneName
    248         entity.databaseScope = 0
    249         try ctx.save()
    250         return id
    251     }
    252 
    253     private func seedJournalRow(gameID: UUID, seq: Int64, in ctx: NSManagedObjectContext) throws {
    254         let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    255         gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    256         gameReq.fetchLimit = 1
    257         let game = try #require(try ctx.fetch(gameReq).first)
    258         let row = JournalEntity(context: ctx)
    259         row.game = game
    260         row.gameID = gameID
    261         row.seq = seq
    262         row.timestamp = Date(timeIntervalSince1970: 1_700_000_000 + Double(seq))
    263         row.row = 0
    264         row.col = Int16(seq)
    265         row.letter = "A"
    266         row.markCode = 0
    267         row.kind = JournalKind.input.rawValue
    268         try ctx.save()
    269     }
    270 
    271     @Test("enqueue registers a buildable Journal save that survives a batch build")
    272     func enqueuePreservesBuildableJournal() async throws {
    273         let persistence = makeTestPersistence()
    274         let ctx = persistence.viewContext
    275         let gameID = try makePrivateGame(in: ctx)
    276         try seedJournalRow(gameID: gameID, seq: 0, in: ctx)
    277         try seedJournalRow(gameID: gameID, seq: 1, in: ctx)
    278         let engine = await makeEngine(persistence: persistence)
    279 
    280         await engine.enqueueJournalUpload(gameID: gameID, authorID: "alice")
    281         let before = await engine.pendingSaveRecordNames(scope: .private)
    282         let journalName = try #require(before.first { $0.hasPrefix("journal-") })
    283 
    284         // The JournalEntity rows back the record, so `buildRecord` materializes
    285         // it and the reap must not fire.
    286         _ = await engine.makeRecordZoneChangeBatch(forTestingScope: .private)
    287 
    288         let after = await engine.pendingSaveRecordNames(scope: .private)
    289         #expect(after.contains(journalName))
    290     }
    291 
    292     @Test("an enqueued journal with no rows is reaped (nothing to upload)")
    293     func emptyJournalIsReaped() async throws {
    294         let persistence = makeTestPersistence()
    295         let ctx = persistence.viewContext
    296         let gameID = try makePrivateGame(in: ctx)
    297         let engine = await makeEngine(persistence: persistence)
    298 
    299         await engine.enqueueJournalUpload(gameID: gameID, authorID: "alice")
    300         let before = await engine.pendingSaveRecordNames(scope: .private)
    301         let journalName = try #require(before.first { $0.hasPrefix("journal-") })
    302 
    303         _ = await engine.makeRecordZoneChangeBatch(forTestingScope: .private)
    304 
    305         let after = await engine.pendingSaveRecordNames(scope: .private)
    306         #expect(!after.contains(journalName))
    307     }
    308 
    309     @Test("late journals invoke Chronicle reconciliation")
    310     func lateJournalInvokesChronicleReconciliation() async {
    311         let persistence = makeTestPersistence()
    312         let engine = await makeEngine(persistence: persistence)
    313         let first = UUID()
    314         let second = UUID()
    315         var reconciled = Set<UUID>()
    316 
    317         await engine.setOnReplayJournalsSynced { gameIDs in
    318             reconciled = gameIDs
    319         }
    320         await engine.notifyReplayJournalsSynced([first, second])
    321 
    322         #expect(reconciled == [first, second])
    323     }
    324 }