RecordSerializerTests.swift (53270B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import Testing 5 6 @testable import Crossmate 7 8 @Suite("RecordSerializer") 9 struct RecordSerializerTests { 10 11 // MARK: - Record name generation 12 13 @Test("Game record name uses expected format") 14 func gameRecordNameFormat() { 15 let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")! 16 let name = RecordSerializer.recordName(forGameID: id) 17 #expect(name == "game-12345678-1234-1234-1234-123456789ABC") 18 } 19 20 @Test("Record names are deterministic") 21 func recordNamesAreDeterministic() { 22 let id = UUID() 23 let a = RecordSerializer.recordName(forGameID: id) 24 let b = RecordSerializer.recordName(forGameID: id) 25 #expect(a == b) 26 } 27 28 @Test("gameID(fromGameRecordName:) round-trips and rejects non-game names") 29 func gameIDFromGameRecordName() { 30 let id = UUID() 31 // Round-trips the forward encoding — this is how a share's zone name 32 // ("game-<UUID>") is resolved back to the game it covers. 33 #expect(RecordSerializer.gameID(fromGameRecordName: RecordSerializer.recordName(forGameID: id)) == id) 34 // Non-game names and malformed UUIDs yield nil rather than a bogus id. 35 #expect(RecordSerializer.gameID(fromGameRecordName: "account") == nil) 36 #expect(RecordSerializer.gameID(fromGameRecordName: "friend-\(UUID().uuidString)") == nil) 37 #expect(RecordSerializer.gameID(fromGameRecordName: "game-not-a-uuid") == nil) 38 } 39 40 // MARK: - Per-game zone 41 42 @Test("zoneID(for:) uses game-<UUID> as zone name") 43 func perGameZoneName() { 44 let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")! 45 let zone = RecordSerializer.zoneID(for: id) 46 #expect(zone.zoneName == "game-12345678-1234-1234-1234-123456789ABC") 47 #expect(zone.ownerName == CKCurrentUserDefaultName) 48 } 49 50 @Test("zoneID(for:ownerName:) accepts explicit owner") 51 func perGameZoneExplicitOwner() { 52 let id = UUID() 53 let zone = RecordSerializer.zoneID(for: id, ownerName: "alice_record_id") 54 #expect(zone.ownerName == "alice_record_id") 55 } 56 57 // MARK: - Player round-trip 58 59 @Test("recordName(forPlayerInGame:authorID:) uses expected format") 60 func playerRecordNameFormat() { 61 let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")! 62 let name = RecordSerializer.recordName(forPlayerInGame: id, authorID: "_abc") 63 #expect(name == "player-12345678-1234-1234-1234-123456789ABC-_abc") 64 } 65 66 @Test("parsePlayerRecordName splits gameID and authorID") 67 func parsePlayerRecordRoundTrip() { 68 let gameID = UUID() 69 let authorID = "_someAuthorID" 70 let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID) 71 let parsed = RecordSerializer.parsePlayerRecordName(recordName) 72 #expect(parsed?.0 == gameID) 73 #expect(parsed?.1 == authorID) 74 } 75 76 @Test("parsePlayerRecordName rejects malformed names") 77 func parsePlayerRecordRejectsBadInput() { 78 #expect(RecordSerializer.parsePlayerRecordName("game-foo") == nil) 79 #expect(RecordSerializer.parsePlayerRecordName("player-not-a-uuid") == nil) 80 #expect(RecordSerializer.parsePlayerRecordName("player-12345678-1234-1234-1234-123456789ABC") == nil) 81 } 82 83 @Test("Direct fetch key sets include serialized fields") 84 func directFetchKeySetsIncludeSerializedFields() { 85 #expect(Set(RecordSerializer.gameDesiredKeys) == [ 86 "title", 87 "completedAt", 88 "completedBy", 89 "shareRecordName", 90 "engagement", 91 "notification", 92 "puzzleSource", 93 ]) 94 #expect(Set(RecordSerializer.movesDesiredKeys) == [ 95 "authorID", 96 "deviceID", 97 "cells", 98 "updatedAt", 99 ]) 100 #expect(Set(RecordSerializer.playerDesiredKeys) == [ 101 "authorID", 102 "name", 103 "updatedAt", 104 "selRow", 105 "selCol", 106 "selDir", 107 "readAt", 108 "readThrough", 109 "sessionSnapshot", 110 "timeLog", 111 "pushAddress", 112 ]) 113 #expect(Set(RecordSerializer.pingDesiredKeys) == [ 114 "authorID", 115 "deviceID", 116 "playerName", 117 "puzzleTitle", 118 "kind", 119 "payload", 120 "addressee", 121 ]) 122 } 123 124 @Test("playerRecord writes readAt and parses it back") 125 func playerRecordReadAtRoundTrip() { 126 let id = UUID() 127 let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName) 128 let readAt = Date(timeIntervalSince1970: 1_700_000_000) 129 let record = RecordSerializer.playerRecord( 130 gameID: id, 131 authorID: "alice", 132 name: "Alice", 133 updatedAt: Date(timeIntervalSince1970: 1_700_000_100), 134 selection: nil, 135 readAt: readAt, 136 zone: zone, 137 systemFields: nil 138 ) 139 #expect(record["readAt"] as? Date == readAt) 140 #expect(RecordSerializer.parsePlayerReadAt(from: record) == readAt) 141 } 142 143 @Test("playerRecord omits readAt when nil and parser returns nil") 144 func playerRecordReadAtNil() { 145 let id = UUID() 146 let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName) 147 let record = RecordSerializer.playerRecord( 148 gameID: id, 149 authorID: "alice", 150 name: "Alice", 151 updatedAt: Date(timeIntervalSince1970: 1_700_000_100), 152 selection: nil, 153 zone: zone, 154 systemFields: nil 155 ) 156 #expect(record["readAt"] == nil) 157 #expect(RecordSerializer.parsePlayerReadAt(from: record) == nil) 158 } 159 160 @Test("playerRecord writes pushAddress and parses it back") 161 func playerRecordPushAddressRoundTrip() { 162 let id = UUID() 163 let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName) 164 let address = "abc123_-XYZ" 165 let record = RecordSerializer.playerRecord( 166 gameID: id, 167 authorID: "alice", 168 name: "Alice", 169 updatedAt: Date(timeIntervalSince1970: 1_700_000_100), 170 selection: nil, 171 pushAddress: address, 172 zone: zone, 173 systemFields: nil 174 ) 175 #expect(record["pushAddress"] as? String == address) 176 #expect(RecordSerializer.parsePlayerPushAddress(from: record) == address) 177 } 178 179 @Test("playerRecord omits pushAddress when nil or empty and parser returns nil") 180 func playerRecordPushAddressNil() { 181 let id = UUID() 182 let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName) 183 let record = RecordSerializer.playerRecord( 184 gameID: id, 185 authorID: "alice", 186 name: "Alice", 187 updatedAt: Date(timeIntervalSince1970: 1_700_000_100), 188 selection: nil, 189 pushAddress: "", 190 zone: zone, 191 systemFields: nil 192 ) 193 #expect(record["pushAddress"] == nil) 194 #expect(RecordSerializer.parsePlayerPushAddress(from: record) == nil) 195 } 196 197 // MARK: - Ping 198 199 @Test("recordName(forPingInGame:authorID:deviceID:eventTimestampMs:) includes deviceID") 200 func pingRecordNameIncludesDeviceID() { 201 let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")! 202 let name = RecordSerializer.recordName( 203 forPingInGame: id, 204 authorID: "alice", 205 deviceID: "deviceA", 206 eventTimestampMs: 1700000000000 207 ) 208 #expect(name == "ping-12345678-1234-1234-1234-123456789ABC-alice-deviceA-1700000000000") 209 } 210 211 @Test("pingRecord writes authorID and deviceID fields") 212 func pingRecordWritesDeviceID() { 213 let id = UUID() 214 let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName) 215 let record = RecordSerializer.pingRecord( 216 gameID: id, 217 authorID: "alice", 218 deviceID: "deviceA", 219 playerName: "Alice", 220 puzzleTitle: "Puzzle", 221 eventTimestampMs: 1700000000000, 222 kind: .join, 223 zone: zone 224 ) 225 #expect(record["authorID"] as? String == "alice") 226 #expect(record["deviceID"] as? String == "deviceA") 227 #expect(record["kind"] as? String == "join") 228 } 229 230 @Test("pingRecord writes payload when provided and omits it when nil") 231 func pingRecordPayloadRoundTrip() { 232 let zone = CKRecordZone.ID(zoneName: "z", ownerName: CKCurrentUserDefaultName) 233 let withPayload = RecordSerializer.pingRecord( 234 gameID: UUID(), 235 authorID: "alice", 236 deviceID: "deviceA", 237 playerName: "Alice", 238 puzzleTitle: "Puzzle", 239 eventTimestampMs: 1700000000000, 240 kind: .invite, 241 payload: #"{"gameShareURL":"https://x"}"#, 242 zone: zone 243 ) 244 #expect(withPayload["payload"] as? String == #"{"gameShareURL":"https://x"}"#) 245 #expect(withPayload["kind"] as? String == "invite") 246 247 let withoutPayload = RecordSerializer.pingRecord( 248 gameID: UUID(), 249 authorID: "alice", 250 deviceID: "deviceA", 251 playerName: "Alice", 252 puzzleTitle: "Puzzle", 253 eventTimestampMs: 1700000000000, 254 kind: .join, 255 zone: zone 256 ) 257 #expect(withoutPayload["payload"] == nil) 258 } 259 260 @Test("pingRecord writes addressee when directed and omits it when nil") 261 func pingRecordAddresseeRoundTrip() { 262 let zone = CKRecordZone.ID(zoneName: "z", ownerName: CKCurrentUserDefaultName) 263 let directed = RecordSerializer.pingRecord( 264 gameID: UUID(), 265 authorID: "alice", 266 deviceID: "deviceA", 267 playerName: "Alice", 268 puzzleTitle: "Puzzle", 269 eventTimestampMs: 1700000000000, 270 kind: .invite, 271 addressee: "bob", 272 zone: zone 273 ) 274 #expect(directed["addressee"] as? String == "bob") 275 #expect(directed["kind"] as? String == "invite") 276 277 let broadcast = RecordSerializer.pingRecord( 278 gameID: UUID(), 279 authorID: "alice", 280 deviceID: "deviceA", 281 playerName: "Alice", 282 puzzleTitle: "Puzzle", 283 eventTimestampMs: 1700000000000, 284 kind: .join, 285 zone: zone 286 ) 287 #expect(broadcast["addressee"] == nil) 288 } 289 290 @Test("hail ping round-trips payload and device addressee") 291 func hailPingRoundTrip() throws { 292 let gameID = UUID() 293 let zone = RecordSerializer.zoneID(for: gameID) 294 let payload = #"{"role":"offer","engagementID":"01234567-89AB-CDEF-0123-456789ABCDEF","sdp":"v=0\r\n","candidates":["candidate:1"],"ver":1}"# 295 let record = RecordSerializer.pingRecord( 296 gameID: gameID, 297 authorID: "alice", 298 deviceID: "deviceA", 299 playerName: "Alice", 300 puzzleTitle: "Puzzle", 301 eventTimestampMs: 1700000000000, 302 kind: .hail, 303 payload: payload, 304 addressee: "bob:deviceB", 305 zone: zone 306 ) 307 308 let parsed = try #require(Ping.parseRecord(record)) 309 #expect(parsed.gameID == gameID) 310 #expect(parsed.authorID == "alice") 311 #expect(parsed.deviceID == "deviceA") 312 #expect(parsed.playerName == "Alice") 313 #expect(parsed.puzzleTitle == "Puzzle") 314 #expect(parsed.kind == .hail) 315 #expect(parsed.payload == payload) 316 #expect(parsed.addressee == "bob:deviceB") 317 } 318 319 @Test("hail ping parse requires fetched addressee for routing") 320 func hailPingRequiresFetchedAddressee() throws { 321 let gameID = UUID() 322 let zone = RecordSerializer.zoneID(for: gameID) 323 let record = RecordSerializer.pingRecord( 324 gameID: gameID, 325 authorID: "alice", 326 deviceID: "deviceA", 327 playerName: "Alice", 328 puzzleTitle: "Puzzle", 329 eventTimestampMs: 1700000000000, 330 kind: .hail, 331 payload: #"{"role":"offer","engagementID":"01234567-89AB-CDEF-0123-456789ABCDEF","sdp":"v=0\r\n","candidates":[],"ver":1}"#, 332 addressee: "alice", 333 zone: zone 334 ) 335 336 let parsed = try #require(Ping.parseRecord(record)) 337 #expect(parsed.addressee == "alice") 338 } 339 340 @Test("accountZoneID is named 'account' in the current user's private DB") 341 func accountZoneIDShape() { 342 let zone = RecordSerializer.accountZoneID 343 #expect(zone.zoneName == "account") 344 #expect(zone.ownerName == CKCurrentUserDefaultName) 345 } 346 347 // MARK: - applyGameRecord 348 349 /// Writes `source` to a temp file and returns a `CKAsset` pointing to it. 350 /// The caller is responsible for removing the file when done. 351 private func makePuzzleAsset(source: String = "dummy puzzle source") throws -> (CKAsset, URL) { 352 let url = FileManager.default.temporaryDirectory 353 .appendingPathComponent(UUID().uuidString) 354 try source.write(to: url, atomically: true, encoding: .utf8) 355 return (CKAsset(fileURL: url), url) 356 } 357 358 @Test("applyGameRecord creates entity with id derived from record name") 359 @MainActor func applyGameRecordCreatesEntity() throws { 360 let persistence = makeTestPersistence() 361 let ctx = persistence.viewContext 362 let gameID = UUID() 363 let zone = RecordSerializer.zoneID(for: gameID) 364 let recordName = RecordSerializer.recordName(forGameID: gameID) 365 let record = CKRecord(recordType: "Game", recordID: CKRecord.ID(recordName: recordName, zoneID: zone)) 366 record["title"] = "Test Title" as CKRecordValue 367 let (asset, tmpURL) = try makePuzzleAsset() 368 defer { try? FileManager.default.removeItem(at: tmpURL) } 369 record["puzzleSource"] = asset as CKRecordValue 370 371 let entity = RecordSerializer.applyGameRecord(record, to: ctx) 372 try ctx.save() 373 374 #expect(entity.id == gameID) 375 #expect(entity.title == "Test Title") 376 #expect(entity.ckRecordName == recordName) 377 } 378 379 @Test("applyGameRecord round-trips completedBy and clears it when absent") 380 @MainActor func applyGameRecordCompletedBy() throws { 381 let persistence = makeTestPersistence() 382 let ctx = persistence.viewContext 383 let gameID = UUID() 384 let recordID = CKRecord.ID( 385 recordName: RecordSerializer.recordName(forGameID: gameID), 386 zoneID: RecordSerializer.zoneID(for: gameID) 387 ) 388 389 // A win carries the solver's authorID. 390 let (asset, tmpURL) = try makePuzzleAsset() 391 defer { try? FileManager.default.removeItem(at: tmpURL) } 392 let win = CKRecord(recordType: "Game", recordID: recordID) 393 win["title"] = "T" as CKRecordValue 394 win["completedAt"] = Date() as CKRecordValue 395 win["completedBy"] = "alice" as CKRecordValue 396 win["puzzleSource"] = asset as CKRecordValue 397 let entity = RecordSerializer.applyGameRecord(win, to: ctx) 398 try ctx.save() 399 #expect(entity.completedBy == "alice") 400 401 // A later record without completedBy (a resignation) clears it, so 402 // wins stay distinguishable from resignations. 403 let resign = CKRecord(recordType: "Game", recordID: recordID) 404 resign["title"] = "T" as CKRecordValue 405 resign["completedAt"] = Date() as CKRecordValue 406 let merged = RecordSerializer.applyGameRecord(resign, to: ctx) 407 try ctx.save() 408 #expect(merged === entity) 409 #expect(merged.completedBy == nil) 410 } 411 412 @Test("applyGameRecord round-trips the notification push credential and clears it when absent") 413 @MainActor func applyGameRecordNotification() throws { 414 let persistence = makeTestPersistence() 415 let ctx = persistence.viewContext 416 let gameID = UUID() 417 let recordID = CKRecord.ID( 418 recordName: RecordSerializer.recordName(forGameID: gameID), 419 zoneID: RecordSerializer.zoneID(for: gameID) 420 ) 421 let (asset, tmpURL) = try makePuzzleAsset() 422 defer { try? FileManager.default.removeItem(at: tmpURL) } 423 424 // The credential carries both the worker auth secret and the worker-blind 425 // content key in one blob (the `notification` field). 426 let creds = try GamePushCredentials.fresh() 427 #expect(creds.contentKey != nil) 428 let record = CKRecord(recordType: "Game", recordID: recordID) 429 record["title"] = "T" as CKRecordValue 430 record["notification"] = try creds.encoded() as CKRecordValue 431 record["puzzleSource"] = asset as CKRecordValue 432 var contentKeyChanges: [UUID] = [] 433 let entity = RecordSerializer.applyGameRecord( 434 record, 435 to: ctx, 436 onContentKeyChange: { contentKeyChanges.append($0) } 437 ) 438 try ctx.save() 439 #expect(GamePushCredentials.decode(entity.notification) == creds) 440 #expect(contentKeyChanges == [gameID]) 441 442 // A later record without the field clears it (LWW convergence) and the 443 // change fires again so the key directory is re-mirrored. 444 let cleared = CKRecord(recordType: "Game", recordID: recordID) 445 cleared["title"] = "T" as CKRecordValue 446 let merged = RecordSerializer.applyGameRecord( 447 cleared, 448 to: ctx, 449 onContentKeyChange: { contentKeyChanges.append($0) } 450 ) 451 try ctx.save() 452 #expect(merged === entity) 453 #expect(merged.notification == nil) 454 #expect(contentKeyChanges == [gameID, gameID]) 455 } 456 457 @Test("applyGameRecord preserves id and createdAt on second apply, updates title") 458 @MainActor func applyGameRecordMergesOnServerRecordChanged() throws { 459 let persistence = makeTestPersistence() 460 let ctx = persistence.viewContext 461 let gameID = UUID() 462 let zone = RecordSerializer.zoneID(for: gameID) 463 let recordName = RecordSerializer.recordName(forGameID: gameID) 464 let recordID = CKRecord.ID(recordName: recordName, zoneID: zone) 465 466 let (asset1, tmpURL1) = try makePuzzleAsset(source: "original source") 467 defer { try? FileManager.default.removeItem(at: tmpURL1) } 468 469 // First apply — creates the entity. 470 let record1 = CKRecord(recordType: "Game", recordID: recordID) 471 record1["title"] = "Original" as CKRecordValue 472 record1["puzzleSource"] = asset1 as CKRecordValue 473 let entity = RecordSerializer.applyGameRecord(record1, to: ctx) 474 try ctx.save() 475 476 let frozenID = entity.id 477 let frozenCreatedAt = entity.createdAt 478 479 // Second apply — simulates a server record change with an updated title. 480 // puzzleSource is intentionally absent here to verify it isn't wiped. 481 let record2 = CKRecord(recordType: "Game", recordID: recordID) 482 record2["title"] = "Updated" as CKRecordValue 483 let merged = RecordSerializer.applyGameRecord(record2, to: ctx) 484 try ctx.save() 485 486 #expect(merged === entity) // same managed object 487 #expect(merged.id == frozenID) // id not overwritten 488 #expect(merged.createdAt == frozenCreatedAt) // createdAt not overwritten 489 #expect(merged.title == "Updated") // mutable field updated 490 } 491 492 /// A valid XD whose title ("Test Puzzle") differs from any `record["title"]` 493 /// the tests set, so the parse-derived title is observable. 494 private static let validXDSource = """ 495 Title: Test Puzzle 496 Author: Test 497 498 499 ABC 500 D#E 501 FGH 502 503 504 A1. Across 1 ~ ABC 505 A4. Across 4 ~ DE 506 A5. Across 5 ~ FGH 507 D1. Down 1 ~ ADF 508 D2. Down 2 ~ BG 509 D3. Down 3 ~ CEH 510 """ 511 512 @Test("applyGameRecord derives the title from the puzzle asset, overriding a stale record title") 513 @MainActor func applyGameRecordDerivesTitleFromAsset() throws { 514 let persistence = makeTestPersistence() 515 let ctx = persistence.viewContext 516 let gameID = UUID() 517 let recordID = CKRecord.ID( 518 recordName: RecordSerializer.recordName(forGameID: gameID), 519 zoneID: RecordSerializer.zoneID(for: gameID) 520 ) 521 522 // The record's title field carries a stale "Joining…" placeholder — the 523 // exact value a participant's Game-record push can clobber the shared 524 // record with — but the puzzleSource asset parses to "Test Puzzle". 525 let (asset, tmpURL) = try makePuzzleAsset(source: Self.validXDSource) 526 defer { try? FileManager.default.removeItem(at: tmpURL) } 527 let record = CKRecord(recordType: "Game", recordID: recordID) 528 record["title"] = "Joining\u{2026}" as CKRecordValue 529 record["puzzleSource"] = asset as CKRecordValue 530 531 let entity = RecordSerializer.applyGameRecord(record, to: ctx) 532 try ctx.save() 533 534 // The asset wins: the stale title self-heals to the puzzle's real title. 535 #expect(entity.title == "Test Puzzle") 536 } 537 538 @Test("populateGameRecord writes the title for an owner but not a participant") 539 @MainActor func populateGameRecordGatesTitleOnOwnership() throws { 540 let persistence = makeTestPersistence() 541 let ctx = persistence.viewContext 542 543 func makeGame(databaseScope: Int16) -> GameEntity { 544 let entity = GameEntity(context: ctx) 545 entity.id = UUID() 546 entity.ckRecordName = "game-\(UUID().uuidString)" 547 entity.title = "Joining\u{2026}" 548 entity.ckShareRecordName = "share-marker" 549 entity.puzzleSource = "" 550 entity.databaseScope = databaseScope 551 return entity 552 } 553 554 // Owner (databaseScope == 0): title and share marker are written. 555 let ownerEntity = makeGame(databaseScope: 0) 556 let ownerRecord = CKRecord(recordType: "Game", recordID: CKRecord.ID(recordName: ownerEntity.ckRecordName!)) 557 RecordSerializer.populateGameRecord(ownerRecord, from: ownerEntity, includePuzzleSource: false) 558 #expect(ownerRecord["title"] as? String == "Joining\u{2026}") 559 #expect(ownerRecord["shareRecordName"] as? String == "share-marker") 560 561 // Participant (databaseScope == 1): the transient placeholder title is 562 // not written, so a cred-minting re-save can't clobber the owner's title. 563 let participantEntity = makeGame(databaseScope: 1) 564 let participantRecord = CKRecord(recordType: "Game", recordID: CKRecord.ID(recordName: participantEntity.ckRecordName!)) 565 RecordSerializer.populateGameRecord(participantRecord, from: participantEntity, includePuzzleSource: false) 566 #expect(participantRecord["title"] == nil) 567 #expect(participantRecord["shareRecordName"] == nil) 568 } 569 570 @Test("applyGameRecord preserves local mutable fields when a save is pending") 571 @MainActor func applyGameRecordPreservesLocalFieldsWhenSavePending() throws { 572 let persistence = makeTestPersistence() 573 let ctx = persistence.viewContext 574 let gameID = UUID() 575 let zone = RecordSerializer.zoneID(for: gameID) 576 let recordName = RecordSerializer.recordName(forGameID: gameID) 577 let recordID = CKRecord.ID(recordName: recordName, zoneID: zone) 578 579 // Local entity reflects a just-set completion: cells are solved, 580 // `markCompleted` wrote `completedAt` and `hasPendingSave` together, 581 // and a Game-record push is queued but hasn't landed yet. 582 let localCompletedAt = Date(timeIntervalSince1970: 1_700_000_500) 583 let entity = GameEntity(context: ctx) 584 entity.id = gameID 585 entity.ckRecordName = recordName 586 entity.title = "Local Title" 587 entity.completedAt = localCompletedAt 588 entity.hasPendingSave = true 589 entity.puzzleSource = "" 590 entity.createdAt = Date(timeIntervalSince1970: 1_700_000_000) 591 entity.updatedAt = Date(timeIntervalSince1970: 1_700_000_400) 592 593 // Stale server snapshot (the push hasn't landed): no completedAt, 594 // older title. Applying it without the pending-save guard would 595 // clobber the local fields and the next outbound push would then 596 // serialise the clobbered values, permanently losing them. 597 let record = CKRecord(recordType: "Game", recordID: recordID) 598 record["title"] = "Remote Stale Title" as CKRecordValue 599 600 let merged = RecordSerializer.applyGameRecord(record, to: ctx) 601 try ctx.save() 602 603 #expect(merged === entity) 604 #expect(merged.completedAt == localCompletedAt) 605 #expect(merged.title == "Local Title") 606 // The fresher etag is still adopted so the next push uses a current 607 // change tag and doesn't oplock-fail. 608 #expect(merged.ckSystemFields != nil) 609 } 610 611 @Test("applyGameRecord does not lower an existing updatedAt") 612 @MainActor func applyGameRecordPreservesFresherUpdatedAt() throws { 613 let persistence = makeTestPersistence() 614 let ctx = persistence.viewContext 615 let gameID = UUID() 616 let zone = RecordSerializer.zoneID(for: gameID) 617 let recordName = RecordSerializer.recordName(forGameID: gameID) 618 let recordID = CKRecord.ID(recordName: recordName, zoneID: zone) 619 620 let entity = GameEntity(context: ctx) 621 let newerUpdatedAt = Date(timeIntervalSince1970: 1_700_000_500) 622 entity.id = gameID 623 entity.ckRecordName = recordName 624 entity.title = "Local" 625 entity.puzzleSource = "" 626 entity.createdAt = Date(timeIntervalSince1970: 1_700_000_000) 627 entity.updatedAt = newerUpdatedAt 628 629 let record = CKRecord(recordType: "Game", recordID: recordID) 630 record["title"] = "Remote" as CKRecordValue 631 632 let merged = RecordSerializer.applyGameRecord(record, to: ctx) 633 try ctx.save() 634 635 #expect(merged === entity) 636 #expect(merged.title == "Remote") 637 #expect(merged.updatedAt == newerUpdatedAt) 638 } 639 640 // MARK: - System fields round-trip 641 642 @Test("Encode and decode system fields preserves record type and zone") 643 func systemFieldsRoundTrip() { 644 let gameID = UUID() 645 let zoneID = RecordSerializer.zoneID(for: gameID) 646 let recordID = CKRecord.ID(recordName: "test-record", zoneID: zoneID) 647 let original = CKRecord(recordType: "Cell", recordID: recordID) 648 649 let encoded = RecordSerializer.encodeSystemFields(of: original) 650 #expect(encoded != nil) 651 652 let decoded = RecordSerializer.decodeRecord(from: encoded!) 653 #expect(decoded != nil) 654 #expect(decoded?.recordType == "Cell") 655 #expect(decoded?.recordID.zoneID.zoneName == "game-\(gameID.uuidString)") 656 #expect(decoded?.recordID.recordName == "test-record") 657 } 658 659 // MARK: - Decision records 660 661 @Test("Decision record name uses decision-<kind>-<key> format") 662 func decisionRecordNameFormat() { 663 let name = RecordSerializer.decisionRecordName(kind: "block", key: "_bob") 664 #expect(name == "decision-block-_bob") 665 } 666 667 @Test("parseDecisionRecordName round-trips, preserving dashes in the key") 668 func decisionNameRoundTrip() { 669 let name = RecordSerializer.decisionRecordName(kind: "block", key: "_b-o-b") 670 let parsed = RecordSerializer.parseDecisionRecordName(name) 671 #expect(parsed?.kind == "block") 672 #expect(parsed?.key == "_b-o-b") 673 } 674 675 @Test("parseDecisionRecordName rejects non-decision and malformed names") 676 func decisionNameRejectsOthers() { 677 #expect(RecordSerializer.parseDecisionRecordName("ping-1234") == nil) 678 #expect(RecordSerializer.parseDecisionRecordName("decision-block") == nil) 679 #expect(RecordSerializer.parseDecisionRecordName("decision--k") == nil) 680 } 681 682 @Test("decisionRecord keeps identity in the name, not a key field") 683 func decisionRecordFields() { 684 let record = RecordSerializer.decisionRecord( 685 kind: "block", 686 key: "_bob", 687 zone: RecordSerializer.accountZoneID 688 ) 689 #expect(record.recordType == "Decision") 690 // Identity (kind + key) lives in the record name. 691 #expect(record.recordID.recordName == "decision-block-_bob") 692 #expect(record.recordID.zoneID.zoneName == "account") 693 #expect(record["kind"] as? String == "block") 694 // `key` is not duplicated as a field; `payload` is unused for block. 695 #expect(record["key"] == nil) 696 #expect(record["payload"] == nil) 697 #expect(record["createdAt"] as? Date != nil) 698 } 699 700 @Test("decisionRecord carries an optional payload when provided") 701 func decisionRecordPayload() { 702 let record = RecordSerializer.decisionRecord( 703 kind: "snooze", 704 key: "_bob", 705 payload: "{\"until\":1}", 706 zone: RecordSerializer.accountZoneID 707 ) 708 #expect(record.recordID.recordName == "decision-snooze-_bob") 709 #expect(record["payload"] as? String == "{\"until\":1}") 710 } 711 712 @Test("decisionRecord writes the version field only when provided") 713 func decisionRecordVersion() { 714 let unversioned = RecordSerializer.decisionRecord( 715 kind: "account", 716 key: "pushSecret", 717 payload: "s", 718 zone: RecordSerializer.accountZoneID 719 ) 720 #expect(unversioned["version"] == nil) 721 722 let versioned = RecordSerializer.decisionRecord( 723 kind: "account", 724 key: "pushSecret", 725 payload: "s", 726 zone: RecordSerializer.accountZoneID, 727 version: 3 728 ) 729 #expect(versioned["version"] as? Int64 == 3) 730 } 731 732 @Test("decisionVersion defaults to the base generation for a version-less record") 733 func decisionVersionDefault() { 734 let record = RecordSerializer.decisionRecord( 735 kind: "account", 736 key: "pushSecret", 737 payload: "s", 738 zone: RecordSerializer.accountZoneID 739 ) 740 #expect(RecordSerializer.decisionVersion(record) == RecordSerializer.decisionBaseVersion) 741 } 742 743 @Test("parseAccountPushSecretDecision returns the secret and its generation") 744 func parseAccountPushSecretDecisionReadsVersion() { 745 let record = RecordSerializer.decisionRecord( 746 kind: RecordSerializer.accountDecisionKind, 747 key: RecordSerializer.accountPushSecretDecisionKey, 748 payload: "the-secret", 749 zone: RecordSerializer.accountZoneID, 750 version: 5 751 ) 752 let parsed = RecordSerializer.parseAccountPushSecretDecision(record) 753 #expect(parsed?.secret == "the-secret") 754 #expect(parsed?.version == 5) 755 } 756 757 @Test("parseAccountPushSecretDecision treats a missing version as the base generation") 758 func parseAccountPushSecretDecisionDefaultsVersion() { 759 let record = RecordSerializer.decisionRecord( 760 kind: RecordSerializer.accountDecisionKind, 761 key: RecordSerializer.accountPushSecretDecisionKey, 762 payload: "legacy-secret", 763 zone: RecordSerializer.accountZoneID 764 ) 765 let parsed = RecordSerializer.parseAccountPushSecretDecision(record) 766 #expect(parsed?.version == RecordSerializer.decisionBaseVersion) 767 } 768 769 @Test("applyDecisionRecord(.block) creates a blocked FriendEntity with derived pairKey") 770 @MainActor func applyDecisionBlockCreatesTombstone() throws { 771 let persistence = makeTestPersistence() 772 let ctx = persistence.viewContext 773 let record = RecordSerializer.decisionRecord( 774 kind: "block", 775 key: "_bob", 776 zone: RecordSerializer.accountZoneID 777 ) 778 779 let wrote = RecordSerializer.applyDecisionRecord( 780 record, 781 to: ctx, 782 localAuthorID: "_alice" 783 ) 784 #expect(wrote) 785 786 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 787 req.predicate = NSPredicate(format: "authorID == %@", "_bob") 788 let friend = try ctx.fetch(req).first 789 #expect(friend?.isBlocked == true) 790 #expect(friend?.pairKey == FriendZone.pairKey("_alice", "_bob")) 791 } 792 793 @Test("applyDecisionRecord(.block) flips an existing active friend to blocked") 794 @MainActor func applyDecisionBlockMarksExistingFriend() throws { 795 let persistence = makeTestPersistence() 796 let ctx = persistence.viewContext 797 798 let existing = FriendEntity(context: ctx) 799 existing.authorID = "_bob" 800 existing.pairKey = "k-existing" 801 existing.friendZoneName = "friend-k-existing" 802 existing.friendZoneOwnerName = CKCurrentUserDefaultName 803 existing.databaseScope = 0 804 existing.isBlocked = false 805 existing.createdAt = Date() 806 try ctx.save() 807 808 let record = RecordSerializer.decisionRecord( 809 kind: "block", 810 key: "_bob", 811 zone: RecordSerializer.accountZoneID 812 ) 813 RecordSerializer.applyDecisionRecord(record, to: ctx, localAuthorID: "_alice") 814 815 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 816 req.predicate = NSPredicate(format: "authorID == %@", "_bob") 817 let rows = try ctx.fetch(req) 818 // Upsert, not insert: the existing row flips rather than duplicating, 819 // and its original pairKey/zone are left intact. 820 #expect(rows.count == 1) 821 #expect(rows.first?.isBlocked == true) 822 #expect(rows.first?.pairKey == "k-existing") 823 } 824 825 @Test("applyDecisionRecord ignores unknown kinds") 826 @MainActor func applyDecisionIgnoresUnknownKind() throws { 827 let persistence = makeTestPersistence() 828 let ctx = persistence.viewContext 829 let record = RecordSerializer.decisionRecord( 830 kind: "future", 831 key: "_bob", 832 zone: RecordSerializer.accountZoneID 833 ) 834 let wrote = RecordSerializer.applyDecisionRecord( 835 record, 836 to: ctx, 837 localAuthorID: "_alice" 838 ) 839 #expect(!wrote) 840 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 841 #expect(try ctx.count(for: req) == 0) 842 } 843 844 // MARK: - Name decisions 845 846 /// The pairwise friend zone for (`local`, `remote`) — the only zone a 847 /// name Decision for `remote` is honored from. 848 private func friendZoneID(local: String, remote: String) -> CKRecordZone.ID { 849 CKRecordZone.ID( 850 zoneName: FriendZone.zoneName(pairKey: FriendZone.pairKey(local, remote)), 851 ownerName: "_zone-owner" 852 ) 853 } 854 855 private func nameDecisionRecord( 856 subject: String, 857 name: String, 858 version: Int64, 859 zone: CKRecordZone.ID 860 ) -> CKRecord { 861 RecordSerializer.decisionRecord( 862 kind: RecordSerializer.nameDecisionKind, 863 key: subject, 864 payload: name, 865 zone: zone, 866 version: version 867 ) 868 } 869 870 @Test("applyDecisionRecord(.name) updates an existing friend from its pair zone") 871 @MainActor func applyNameDecisionUpdatesFriend() throws { 872 let persistence = makeTestPersistence() 873 let ctx = persistence.viewContext 874 let pairKey = FriendZone.pairKey("_alice", "_bob") 875 let existing = FriendEntity(context: ctx) 876 existing.authorID = "_bob" 877 existing.pairKey = pairKey 878 existing.friendZoneName = FriendZone.zoneName(pairKey: pairKey) 879 existing.friendZoneOwnerName = CKCurrentUserDefaultName 880 existing.databaseScope = 0 881 existing.createdAt = Date() 882 try ctx.save() 883 884 let record = nameDecisionRecord( 885 subject: "_bob", 886 name: "Brandon", 887 version: 1, 888 zone: friendZoneID(local: "_alice", remote: "_bob") 889 ) 890 let wrote = RecordSerializer.applyDecisionRecord( 891 record, to: ctx, localAuthorID: "_alice" 892 ) 893 #expect(wrote) 894 #expect(existing.displayName == "Brandon") 895 #expect(existing.displayNameVersion == 1) 896 } 897 898 @Test("applyDecisionRecord(.name) is last-writer-wins on version") 899 @MainActor func applyNameDecisionVersionGate() throws { 900 let persistence = makeTestPersistence() 901 let ctx = persistence.viewContext 902 let pairKey = FriendZone.pairKey("_alice", "_bob") 903 let existing = FriendEntity(context: ctx) 904 existing.authorID = "_bob" 905 existing.pairKey = pairKey 906 existing.friendZoneName = FriendZone.zoneName(pairKey: pairKey) 907 existing.friendZoneOwnerName = CKCurrentUserDefaultName 908 existing.databaseScope = 0 909 existing.createdAt = Date() 910 existing.displayName = "Brandon" 911 existing.displayNameVersion = 3 912 try ctx.save() 913 914 let zone = friendZoneID(local: "_alice", remote: "_bob") 915 let stale = nameDecisionRecord(subject: "_bob", name: "Old", version: 2, zone: zone) 916 #expect(!RecordSerializer.applyDecisionRecord(stale, to: ctx, localAuthorID: "_alice")) 917 #expect(existing.displayName == "Brandon") 918 919 let equal = nameDecisionRecord(subject: "_bob", name: "Bran", version: 3, zone: zone) 920 #expect(RecordSerializer.applyDecisionRecord(equal, to: ctx, localAuthorID: "_alice")) 921 #expect(existing.displayName == "Bran") 922 923 let newer = nameDecisionRecord(subject: "_bob", name: "Brandon II", version: 4, zone: zone) 924 #expect(RecordSerializer.applyDecisionRecord(newer, to: ctx, localAuthorID: "_alice")) 925 #expect(existing.displayName == "Brandon II") 926 #expect(existing.displayNameVersion == 4) 927 } 928 929 @Test("applyDecisionRecord(.name) resurrects a friendship from its zone") 930 @MainActor func applyNameDecisionResurrectsFriend() throws { 931 let persistence = makeTestPersistence() 932 let ctx = persistence.viewContext 933 let zone = friendZoneID(local: "_alice", remote: "_bob") 934 935 let record = nameDecisionRecord(subject: "_bob", name: "Brandon", version: 2, zone: zone) 936 let wrote = RecordSerializer.applyDecisionRecord( 937 record, to: ctx, localAuthorID: "_alice", databaseScope: 1 938 ) 939 #expect(wrote) 940 941 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 942 req.predicate = NSPredicate(format: "authorID == %@", "_bob") 943 let friend = try ctx.fetch(req).first 944 #expect(friend?.displayName == "Brandon") 945 #expect(friend?.pairKey == FriendZone.pairKey("_alice", "_bob")) 946 #expect(friend?.friendZoneName == zone.zoneName) 947 #expect(friend?.friendZoneOwnerName == "_zone-owner") 948 #expect(friend?.databaseScope == 1) 949 #expect(friend?.isBlocked == false) 950 } 951 952 @Test("applyDecisionRecord(.name) rejects a record outside the pair's zone") 953 @MainActor func applyNameDecisionRejectsForeignZone() throws { 954 let persistence = makeTestPersistence() 955 let ctx = persistence.viewContext 956 957 // A name for _carol arriving in the (_alice, _bob) zone: the zone 958 // hash doesn't match the (_alice, _carol) pair, so it must be dropped 959 // — a friend can't assert names for third parties. 960 let record = nameDecisionRecord( 961 subject: "_carol", 962 name: "Mallory", 963 version: 9, 964 zone: friendZoneID(local: "_alice", remote: "_bob") 965 ) 966 let wrote = RecordSerializer.applyDecisionRecord( 967 record, to: ctx, localAuthorID: "_alice" 968 ) 969 #expect(!wrote) 970 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 971 #expect(try ctx.count(for: req) == 0) 972 } 973 974 @Test("applyDecisionRecord(.name) ignores our own name and writes no row") 975 @MainActor func applyNameDecisionIgnoresSelf() throws { 976 let persistence = makeTestPersistence() 977 let ctx = persistence.viewContext 978 979 let record = nameDecisionRecord( 980 subject: "_alice", 981 name: "Alice", 982 version: 5, 983 zone: RecordSerializer.accountZoneID 984 ) 985 let wrote = RecordSerializer.applyDecisionRecord( 986 record, to: ctx, localAuthorID: "_alice" 987 ) 988 #expect(!wrote) 989 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 990 #expect(try ctx.count(for: req) == 0) 991 } 992 993 @Test("applyDecisionRecord(.name) leaves a blocked friend untouched") 994 @MainActor func applyNameDecisionSkipsBlocked() throws { 995 let persistence = makeTestPersistence() 996 let ctx = persistence.viewContext 997 let pairKey = FriendZone.pairKey("_alice", "_bob") 998 let blocked = FriendEntity(context: ctx) 999 blocked.authorID = "_bob" 1000 blocked.pairKey = pairKey 1001 blocked.friendZoneName = FriendZone.zoneName(pairKey: pairKey) 1002 blocked.friendZoneOwnerName = CKCurrentUserDefaultName 1003 blocked.databaseScope = 0 1004 blocked.isBlocked = true 1005 blocked.createdAt = Date() 1006 try ctx.save() 1007 1008 let record = nameDecisionRecord( 1009 subject: "_bob", 1010 name: "Brandon", 1011 version: 1, 1012 zone: friendZoneID(local: "_alice", remote: "_bob") 1013 ) 1014 let wrote = RecordSerializer.applyDecisionRecord( 1015 record, to: ctx, localAuthorID: "_alice" 1016 ) 1017 #expect(!wrote) 1018 #expect(blocked.displayName?.isEmpty != false) 1019 #expect(blocked.isBlocked == true) 1020 } 1021 1022 // MARK: - Nickname decisions 1023 1024 private func nicknameDecisionRecord( 1025 subject: String, 1026 nickname: String?, 1027 version: Int64, 1028 zone: CKRecordZone.ID = RecordSerializer.accountZoneID 1029 ) -> CKRecord { 1030 RecordSerializer.decisionRecord( 1031 kind: RecordSerializer.nicknameDecisionKind, 1032 key: subject, 1033 payload: nickname, 1034 zone: zone, 1035 version: version 1036 ) 1037 } 1038 1039 @MainActor 1040 private func makeFriend( 1041 in ctx: NSManagedObjectContext, 1042 authorID: String, 1043 pairedWith localAuthorID: String 1044 ) throws -> FriendEntity { 1045 let pairKey = FriendZone.pairKey(localAuthorID, authorID) 1046 let friend = FriendEntity(context: ctx) 1047 friend.authorID = authorID 1048 friend.pairKey = pairKey 1049 friend.friendZoneName = FriendZone.zoneName(pairKey: pairKey) 1050 friend.friendZoneOwnerName = CKCurrentUserDefaultName 1051 friend.databaseScope = 0 1052 friend.createdAt = Date() 1053 try ctx.save() 1054 return friend 1055 } 1056 1057 @Test("applyDecisionRecord(.nickname) sets the nickname on an existing friend") 1058 @MainActor func applyNicknameDecisionUpdatesFriend() throws { 1059 let persistence = makeTestPersistence() 1060 let ctx = persistence.viewContext 1061 let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice") 1062 1063 let record = nicknameDecisionRecord(subject: "_bob", nickname: "Bobby", version: 1) 1064 let wrote = RecordSerializer.applyDecisionRecord( 1065 record, to: ctx, localAuthorID: "_alice" 1066 ) 1067 #expect(wrote) 1068 #expect(friend.nickname == "Bobby") 1069 #expect(friend.nicknameVersion == 1) 1070 #expect(friend.resolvedDisplayName == "Bobby") 1071 } 1072 1073 @Test("applyDecisionRecord(.nickname) is last-writer-wins on version") 1074 @MainActor func applyNicknameDecisionVersionGate() throws { 1075 let persistence = makeTestPersistence() 1076 let ctx = persistence.viewContext 1077 let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice") 1078 friend.nickname = "Bobby" 1079 friend.nicknameVersion = 3 1080 try ctx.save() 1081 1082 let stale = nicknameDecisionRecord(subject: "_bob", nickname: "Old", version: 2) 1083 #expect(!RecordSerializer.applyDecisionRecord(stale, to: ctx, localAuthorID: "_alice")) 1084 #expect(friend.nickname == "Bobby") 1085 1086 let equal = nicknameDecisionRecord(subject: "_bob", nickname: "Rob", version: 3) 1087 #expect(RecordSerializer.applyDecisionRecord(equal, to: ctx, localAuthorID: "_alice")) 1088 #expect(friend.nickname == "Rob") 1089 1090 let newer = nicknameDecisionRecord(subject: "_bob", nickname: "Robert", version: 4) 1091 #expect(RecordSerializer.applyDecisionRecord(newer, to: ctx, localAuthorID: "_alice")) 1092 #expect(friend.nickname == "Robert") 1093 #expect(friend.nicknameVersion == 4) 1094 } 1095 1096 @Test("applyDecisionRecord(.nickname) clears the nickname on an empty payload") 1097 @MainActor func applyNicknameDecisionClears() throws { 1098 let persistence = makeTestPersistence() 1099 let ctx = persistence.viewContext 1100 let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice") 1101 friend.displayName = "Brandon" 1102 friend.displayNameVersion = 1 1103 friend.nickname = "Bobby" 1104 friend.nicknameVersion = 1 1105 try ctx.save() 1106 1107 let record = nicknameDecisionRecord(subject: "_bob", nickname: nil, version: 2) 1108 let wrote = RecordSerializer.applyDecisionRecord( 1109 record, to: ctx, localAuthorID: "_alice" 1110 ) 1111 #expect(wrote) 1112 #expect(friend.nickname == nil) 1113 #expect(friend.nicknameVersion == 2) 1114 // Cleared nickname falls back to the friend's own synced name. 1115 #expect(friend.resolvedDisplayName == "Brandon") 1116 } 1117 1118 @Test("applyDecisionRecord(.nickname) rejects a record outside the account zone") 1119 @MainActor func applyNicknameDecisionRejectsFriendZone() throws { 1120 let persistence = makeTestPersistence() 1121 let ctx = persistence.viewContext 1122 let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice") 1123 1124 // A "nickname" decision planted by the friend in the shared pairwise 1125 // zone must not relabel anyone in this user's list. 1126 let record = nicknameDecisionRecord( 1127 subject: "_bob", 1128 nickname: "Gotcha", 1129 version: 9, 1130 zone: friendZoneID(local: "_alice", remote: "_bob") 1131 ) 1132 let wrote = RecordSerializer.applyDecisionRecord( 1133 record, to: ctx, localAuthorID: "_alice" 1134 ) 1135 #expect(!wrote) 1136 #expect(friend.nickname?.isEmpty != false) 1137 } 1138 1139 @Test("applyDecisionRecord(.nickname) applies a record fetched with a concrete owner name") 1140 @MainActor func applyNicknameDecisionConcreteOwnerName() throws { 1141 let persistence = makeTestPersistence() 1142 let ctx = persistence.viewContext 1143 let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice") 1144 1145 // A Decision written with the `CKCurrentUserDefaultName` placeholder 1146 // comes back from CloudKit with the concrete user-record ID as its 1147 // zone owner. The apply path must still recognise it as the account 1148 // zone (zone *name* + private scope), or no nickname ever syncs. 1149 let fetchedZone = CKRecordZone.ID( 1150 zoneName: RecordSerializer.accountZoneID.zoneName, 1151 ownerName: "_alice" 1152 ) 1153 let record = nicknameDecisionRecord( 1154 subject: "_bob", nickname: "Bobby", version: 1, zone: fetchedZone 1155 ) 1156 let wrote = RecordSerializer.applyDecisionRecord( 1157 record, to: ctx, localAuthorID: "_alice", databaseScope: 0 1158 ) 1159 #expect(wrote) 1160 #expect(friend.nickname == "Bobby") 1161 #expect(friend.nicknameVersion == 1) 1162 } 1163 1164 @Test("applyDecisionRecord(.nickname) rejects an account-named zone in the shared DB") 1165 @MainActor func applyNicknameDecisionRejectsSharedAccountZone() throws { 1166 let persistence = makeTestPersistence() 1167 let ctx = persistence.viewContext 1168 let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice") 1169 1170 // A friend cannot reach our private DB; spoofing the relabel means 1171 // sharing a zone they named "account" into our *shared* DB. The 1172 // private-scope gate must reject it even though the zone name matches. 1173 let spoofZone = CKRecordZone.ID( 1174 zoneName: RecordSerializer.accountZoneID.zoneName, 1175 ownerName: "_bob" 1176 ) 1177 let record = nicknameDecisionRecord( 1178 subject: "_bob", nickname: "Gotcha", version: 9, zone: spoofZone 1179 ) 1180 let wrote = RecordSerializer.applyDecisionRecord( 1181 record, to: ctx, localAuthorID: "_alice", databaseScope: 1 1182 ) 1183 #expect(!wrote) 1184 #expect(friend.nickname?.isEmpty != false) 1185 } 1186 1187 @Test("applyDecisionRecord(.nickname) writes no row for an unknown friend") 1188 @MainActor func applyNicknameDecisionSkipsUnknownFriend() throws { 1189 let persistence = makeTestPersistence() 1190 let ctx = persistence.viewContext 1191 1192 let record = nicknameDecisionRecord(subject: "_bob", nickname: "Bobby", version: 1) 1193 let wrote = RecordSerializer.applyDecisionRecord( 1194 record, to: ctx, localAuthorID: "_alice" 1195 ) 1196 #expect(!wrote) 1197 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 1198 #expect(try ctx.count(for: req) == 0) 1199 } 1200 1201 @Test("parseNameDecision reads subject, name, and version") 1202 func parseNameDecisionFields() { 1203 let record = nameDecisionRecord( 1204 subject: "_bob", 1205 name: "Brandon", 1206 version: 7, 1207 zone: RecordSerializer.accountZoneID 1208 ) 1209 let parsed = RecordSerializer.parseNameDecision(record) 1210 #expect(parsed?.authorID == "_bob") 1211 #expect(parsed?.name == "Brandon") 1212 #expect(parsed?.version == 7) 1213 // Non-name decisions don't parse. 1214 let block = RecordSerializer.decisionRecord( 1215 kind: "block", key: "_bob", zone: RecordSerializer.accountZoneID 1216 ) 1217 #expect(RecordSerializer.parseNameDecision(block) == nil) 1218 } 1219 1220 @Test("applyDecisionRecord(.left) hard-deletes the participant game row") 1221 @MainActor func applyDecisionLeftDeletesParticipantGame() throws { 1222 let persistence = makeTestPersistence() 1223 let ctx = persistence.viewContext 1224 let gameID = UUID() 1225 let entity = GameEntity(context: ctx) 1226 entity.id = gameID 1227 entity.title = "Shared" 1228 entity.puzzleSource = "" 1229 entity.databaseScope = 1 1230 entity.createdAt = Date() 1231 entity.updatedAt = Date() 1232 try ctx.save() 1233 1234 let record = RecordSerializer.decisionRecord( 1235 kind: "left", 1236 key: gameID.uuidString, 1237 zone: RecordSerializer.accountZoneID 1238 ) 1239 let wrote = RecordSerializer.applyDecisionRecord( 1240 record, to: ctx, localAuthorID: "_alice" 1241 ) 1242 #expect(wrote) 1243 1244 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1245 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1246 #expect(try ctx.count(for: req) == 0) 1247 } 1248 1249 @Test("applyDecisionRecord(.left) leaves an owned (scope 0) row intact") 1250 @MainActor func applyDecisionLeftSkipsOwnedGame() throws { 1251 let persistence = makeTestPersistence() 1252 let ctx = persistence.viewContext 1253 let gameID = UUID() 1254 let entity = GameEntity(context: ctx) 1255 entity.id = gameID 1256 entity.title = "Owned" 1257 entity.puzzleSource = "" 1258 entity.databaseScope = 0 1259 entity.createdAt = Date() 1260 entity.updatedAt = Date() 1261 try ctx.save() 1262 1263 let record = RecordSerializer.decisionRecord( 1264 kind: "left", 1265 key: gameID.uuidString, 1266 zone: RecordSerializer.accountZoneID 1267 ) 1268 let wrote = RecordSerializer.applyDecisionRecord( 1269 record, to: ctx, localAuthorID: "_alice" 1270 ) 1271 // A `left` fact never applies to an owned copy — the participant who 1272 // left can't be the owner of the same game id. 1273 #expect(!wrote) 1274 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1275 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1276 #expect(try ctx.count(for: req) == 1) 1277 } 1278 1279 @Test("applyDecisionRecord(.left) is a no-op when the row is already gone") 1280 @MainActor func applyDecisionLeftIdempotent() { 1281 let persistence = makeTestPersistence() 1282 let ctx = persistence.viewContext 1283 let record = RecordSerializer.decisionRecord( 1284 kind: "left", 1285 key: UUID().uuidString, 1286 zone: RecordSerializer.accountZoneID 1287 ) 1288 let wrote = RecordSerializer.applyDecisionRecord( 1289 record, to: ctx, localAuthorID: "_alice" 1290 ) 1291 #expect(!wrote) 1292 } 1293 1294 @Test("applyDecisionRecord(.left) rejects a non-UUID key") 1295 @MainActor func applyDecisionLeftRejectsBadKey() { 1296 let persistence = makeTestPersistence() 1297 let ctx = persistence.viewContext 1298 let record = RecordSerializer.decisionRecord( 1299 kind: "left", 1300 key: "not-a-uuid", 1301 zone: RecordSerializer.accountZoneID 1302 ) 1303 #expect( 1304 !RecordSerializer.applyDecisionRecord( 1305 record, to: ctx, localAuthorID: "_alice" 1306 ) 1307 ) 1308 } 1309 }