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 }