RecordSerializerTests.swift (70961B)
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 "roomCredential", 91 "pushCredential", 92 "puzzleSource", 93 "syncVersion", 94 ]) 95 #expect(Set(RecordSerializer.movesDesiredKeys) == [ 96 "cells", 97 "updatedAt", 98 ]) 99 #expect(Set(RecordSerializer.playerDesiredKeys) == [ 100 "name", 101 "updatedAt", 102 "selRow", 103 "selCol", 104 "selDir", 105 "presenceUntil", 106 "readThrough", 107 "viewedAt", 108 "timeLog", 109 "pushAddress", 110 ]) 111 #expect(Set(RecordSerializer.pingDesiredKeys) == [ 112 "playerName", 113 "puzzleTitle", 114 "kind", 115 "payload", 116 "addressee", 117 ]) 118 } 119 120 @Test("playerRecord writes presenceUntil and parses it back") 121 func playerRecordPresenceUntilRoundTrip() { 122 let id = UUID() 123 let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName) 124 let presenceUntil = Date(timeIntervalSince1970: 1_700_000_000) 125 let record = RecordSerializer.playerRecord( 126 gameID: id, 127 authorID: "alice", 128 name: "Alice", 129 updatedAt: Date(timeIntervalSince1970: 1_700_000_100), 130 selection: nil, 131 presenceUntil: presenceUntil, 132 zone: zone, 133 systemFields: nil 134 ) 135 #expect(record["presenceUntil"] as? Date == presenceUntil) 136 #expect(RecordSerializer.parsePlayerPresenceUntil(from: record) == presenceUntil) 137 } 138 139 @Test("playerRecord omits presenceUntil when nil and parser returns nil") 140 func playerRecordPresenceUntilNil() { 141 let id = UUID() 142 let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName) 143 let record = RecordSerializer.playerRecord( 144 gameID: id, 145 authorID: "alice", 146 name: "Alice", 147 updatedAt: Date(timeIntervalSince1970: 1_700_000_100), 148 selection: nil, 149 zone: zone, 150 systemFields: nil 151 ) 152 #expect(record["presenceUntil"] == nil) 153 #expect(RecordSerializer.parsePlayerPresenceUntil(from: record) == nil) 154 } 155 156 @Test("playerRecord writes pushAddress and parses it back") 157 func playerRecordPushAddressRoundTrip() { 158 let id = UUID() 159 let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName) 160 let address = "abc123_-XYZ" 161 let record = RecordSerializer.playerRecord( 162 gameID: id, 163 authorID: "alice", 164 name: "Alice", 165 updatedAt: Date(timeIntervalSince1970: 1_700_000_100), 166 selection: nil, 167 pushAddress: address, 168 zone: zone, 169 systemFields: nil 170 ) 171 #expect(record["pushAddress"] as? String == address) 172 #expect(RecordSerializer.parsePlayerPushAddress(from: record) == address) 173 } 174 175 @Test("playerRecord omits pushAddress when nil or empty and parser returns nil") 176 func playerRecordPushAddressNil() { 177 let id = UUID() 178 let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName) 179 let record = RecordSerializer.playerRecord( 180 gameID: id, 181 authorID: "alice", 182 name: "Alice", 183 updatedAt: Date(timeIntervalSince1970: 1_700_000_100), 184 selection: nil, 185 pushAddress: "", 186 zone: zone, 187 systemFields: nil 188 ) 189 #expect(record["pushAddress"] == nil) 190 #expect(RecordSerializer.parsePlayerPushAddress(from: record) == nil) 191 } 192 193 // MARK: - Ping 194 195 @Test("recordName(forPingInGame:authorID:deviceID:eventTimestampMs:) includes deviceID") 196 func pingRecordNameIncludesDeviceID() { 197 let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")! 198 let name = RecordSerializer.recordName( 199 forPingInGame: id, 200 authorID: "alice", 201 deviceID: "deviceA", 202 eventTimestampMs: 1700000000000 203 ) 204 #expect(name == "ping-12345678-1234-1234-1234-123456789ABC-alice-deviceA-1700000000000") 205 } 206 207 @Test("pingRecord encodes authorID/deviceID in the record name, not fields") 208 func pingRecordEncodesIdentityInName() { 209 let id = UUID() 210 let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName) 211 let record = RecordSerializer.pingRecord( 212 gameID: id, 213 authorID: "alice", 214 deviceID: "deviceA", 215 playerName: "Alice", 216 puzzleTitle: "Puzzle", 217 eventTimestampMs: 1700000000000, 218 kind: .join, 219 zone: zone 220 ) 221 #expect(record["authorID"] == nil) 222 #expect(record["deviceID"] == nil) 223 let parsed = RecordSerializer.parsePingRecordName(record.recordID.recordName) 224 #expect(parsed?.0 == id) 225 #expect(parsed?.1 == "alice") 226 #expect(parsed?.2 == "deviceA") 227 #expect(record["kind"] as? String == "join") 228 } 229 230 @Test("parsePingRecordName round-trips names, including a dashed authorID") 231 func parsePingRecordNameHandlesDashedAuthor() { 232 let gameID = UUID() 233 let name = RecordSerializer.recordName( 234 forPingInGame: gameID, 235 authorID: "au-th-or", 236 deviceID: "cafef00d", 237 eventTimestampMs: 42 238 ) 239 let parsed = RecordSerializer.parsePingRecordName(name) 240 #expect(parsed?.0 == gameID) 241 #expect(parsed?.1 == "au-th-or") 242 #expect(parsed?.2 == "cafef00d") 243 // A non-ping name parses to nil. 244 #expect(RecordSerializer.parsePingRecordName("player-\(gameID.uuidString)-alice") == nil) 245 } 246 247 @Test("pingRecord writes payload when provided and omits it when nil") 248 func pingRecordPayloadRoundTrip() { 249 let zone = CKRecordZone.ID(zoneName: "z", ownerName: CKCurrentUserDefaultName) 250 let withPayload = RecordSerializer.pingRecord( 251 gameID: UUID(), 252 authorID: "alice", 253 deviceID: "deviceA", 254 playerName: "Alice", 255 puzzleTitle: "Puzzle", 256 eventTimestampMs: 1700000000000, 257 kind: .invite, 258 payload: #"{"gameShareURL":"https://x"}"#, 259 zone: zone 260 ) 261 #expect(withPayload["payload"] as? String == #"{"gameShareURL":"https://x"}"#) 262 #expect(withPayload["kind"] as? String == "invite") 263 264 let withoutPayload = RecordSerializer.pingRecord( 265 gameID: UUID(), 266 authorID: "alice", 267 deviceID: "deviceA", 268 playerName: "Alice", 269 puzzleTitle: "Puzzle", 270 eventTimestampMs: 1700000000000, 271 kind: .join, 272 zone: zone 273 ) 274 #expect(withoutPayload["payload"] == nil) 275 } 276 277 @Test("pingRecord writes addressee when directed and omits it when nil") 278 func pingRecordAddresseeRoundTrip() { 279 let zone = CKRecordZone.ID(zoneName: "z", ownerName: CKCurrentUserDefaultName) 280 let directed = RecordSerializer.pingRecord( 281 gameID: UUID(), 282 authorID: "alice", 283 deviceID: "deviceA", 284 playerName: "Alice", 285 puzzleTitle: "Puzzle", 286 eventTimestampMs: 1700000000000, 287 kind: .invite, 288 addressee: "bob", 289 zone: zone 290 ) 291 #expect(directed["addressee"] as? String == "bob") 292 #expect(directed["kind"] as? String == "invite") 293 294 let broadcast = RecordSerializer.pingRecord( 295 gameID: UUID(), 296 authorID: "alice", 297 deviceID: "deviceA", 298 playerName: "Alice", 299 puzzleTitle: "Puzzle", 300 eventTimestampMs: 1700000000000, 301 kind: .join, 302 zone: zone 303 ) 304 #expect(broadcast["addressee"] == nil) 305 } 306 307 @Test("hail ping round-trips payload and device addressee") 308 func hailPingRoundTrip() throws { 309 let gameID = UUID() 310 let zone = RecordSerializer.zoneID(for: gameID) 311 let payload = #"{"role":"offer","engagementID":"01234567-89AB-CDEF-0123-456789ABCDEF","sdp":"v=0\r\n","candidates":["candidate:1"],"ver":1}"# 312 let record = RecordSerializer.pingRecord( 313 gameID: gameID, 314 authorID: "alice", 315 deviceID: "deviceA", 316 playerName: "Alice", 317 puzzleTitle: "Puzzle", 318 eventTimestampMs: 1700000000000, 319 kind: .hail, 320 payload: payload, 321 addressee: "bob:deviceB", 322 zone: zone 323 ) 324 325 let parsed = try #require(Ping.parseRecord(record, fetchedFrom: .shared)) 326 #expect(parsed.gameID == gameID) 327 #expect(parsed.authorID == "alice") 328 #expect(parsed.deviceID == "deviceA") 329 #expect(parsed.playerName == "Alice") 330 #expect(parsed.puzzleTitle == "Puzzle") 331 #expect(parsed.kind == .hail) 332 #expect(parsed.payload == payload) 333 #expect(parsed.addressee == "bob:deviceB") 334 #expect(parsed.sourceDatabaseScope == .shared) 335 } 336 337 @Test("hail ping parse requires fetched addressee for routing") 338 func hailPingRequiresFetchedAddressee() throws { 339 let gameID = UUID() 340 let zone = RecordSerializer.zoneID(for: gameID) 341 let record = RecordSerializer.pingRecord( 342 gameID: gameID, 343 authorID: "alice", 344 deviceID: "deviceA", 345 playerName: "Alice", 346 puzzleTitle: "Puzzle", 347 eventTimestampMs: 1700000000000, 348 kind: .hail, 349 payload: #"{"role":"offer","engagementID":"01234567-89AB-CDEF-0123-456789ABCDEF","sdp":"v=0\r\n","candidates":[],"ver":1}"#, 350 addressee: "alice", 351 zone: zone 352 ) 353 354 let parsed = try #require(Ping.parseRecord(record, fetchedFrom: .shared)) 355 #expect(parsed.addressee == "alice") 356 } 357 358 @Test("accountZoneID is named 'account' in the current user's private DB") 359 func accountZoneIDShape() { 360 let zone = RecordSerializer.accountZoneID 361 #expect(zone.zoneName == "account") 362 #expect(zone.ownerName == CKCurrentUserDefaultName) 363 } 364 365 // MARK: - applyGameRecord 366 367 /// Writes `source` to a temp file and returns a `CKAsset` pointing to it. 368 /// The caller is responsible for removing the file when done. 369 private func makePuzzleAsset(source: String = "dummy puzzle source") throws -> (CKAsset, URL) { 370 let url = FileManager.default.temporaryDirectory 371 .appendingPathComponent(UUID().uuidString) 372 try source.write(to: url, atomically: true, encoding: .utf8) 373 return (CKAsset(fileURL: url), url) 374 } 375 376 @Test("applyGameRecord creates entity with id derived from record name") 377 @MainActor func applyGameRecordCreatesEntity() throws { 378 let persistence = makeTestPersistence() 379 let ctx = persistence.viewContext 380 let gameID = UUID() 381 let zone = RecordSerializer.zoneID(for: gameID) 382 let recordName = RecordSerializer.recordName(forGameID: gameID) 383 let record = CKRecord(recordType: "Game", recordID: CKRecord.ID(recordName: recordName, zoneID: zone)) 384 record["title"] = "Test Title" as CKRecordValue 385 let (asset, tmpURL) = try makePuzzleAsset() 386 defer { try? FileManager.default.removeItem(at: tmpURL) } 387 record["puzzleSource"] = asset as CKRecordValue 388 389 let entity = RecordSerializer.applyGameRecord(record, to: ctx) 390 try ctx.save() 391 392 #expect(entity.id == gameID) 393 #expect(entity.title == "Test Title") 394 #expect(entity.ckRecordName == recordName) 395 } 396 397 @Test("applyGameRecord round-trips completedBy and clears it when absent") 398 @MainActor func applyGameRecordCompletedBy() throws { 399 let persistence = makeTestPersistence() 400 let ctx = persistence.viewContext 401 let gameID = UUID() 402 let recordID = CKRecord.ID( 403 recordName: RecordSerializer.recordName(forGameID: gameID), 404 zoneID: RecordSerializer.zoneID(for: gameID) 405 ) 406 407 // A win carries the solver's authorID. 408 let (asset, tmpURL) = try makePuzzleAsset() 409 defer { try? FileManager.default.removeItem(at: tmpURL) } 410 let win = CKRecord(recordType: "Game", recordID: recordID) 411 win["title"] = "T" as CKRecordValue 412 win["completedAt"] = Date() as CKRecordValue 413 win["completedBy"] = "alice" as CKRecordValue 414 win["puzzleSource"] = asset as CKRecordValue 415 let entity = RecordSerializer.applyGameRecord(win, to: ctx) 416 try ctx.save() 417 #expect(entity.completedBy == "alice") 418 419 // A later record without completedBy (a resignation) clears it, so 420 // wins stay distinguishable from resignations. 421 let resign = CKRecord(recordType: "Game", recordID: recordID) 422 resign["title"] = "T" as CKRecordValue 423 resign["completedAt"] = Date() as CKRecordValue 424 let merged = RecordSerializer.applyGameRecord(resign, to: ctx) 425 try ctx.save() 426 #expect(merged === entity) 427 #expect(merged.completedBy == nil) 428 } 429 430 @Test("applyGameRecord round-trips the notification push credential and keeps it when absent") 431 @MainActor func applyGameRecordNotification() throws { 432 let persistence = makeTestPersistence() 433 let ctx = persistence.viewContext 434 let gameID = UUID() 435 let recordID = CKRecord.ID( 436 recordName: RecordSerializer.recordName(forGameID: gameID), 437 zoneID: RecordSerializer.zoneID(for: gameID) 438 ) 439 let (asset, tmpURL) = try makePuzzleAsset() 440 defer { try? FileManager.default.removeItem(at: tmpURL) } 441 442 // The credential carries both the worker auth secret and the worker-blind 443 // content key in one blob (the `notification` field). 444 let creds = try GamePushCredentials.fresh() 445 #expect(creds.contentKey != nil) 446 let record = CKRecord(recordType: "Game", recordID: recordID) 447 record["title"] = "T" as CKRecordValue 448 record["pushCredential"] = Data(try creds.encoded().utf8) as CKRecordValue 449 record["puzzleSource"] = asset as CKRecordValue 450 var contentKeyChanges: [UUID] = [] 451 let entity = RecordSerializer.applyGameRecord( 452 record, 453 to: ctx, 454 onContentKeyChange: { contentKeyChanges.append($0) } 455 ) 456 try ctx.save() 457 #expect(GamePushCredentials.decode(entity.notification) == creds) 458 #expect(contentKeyChanges == [gameID]) 459 460 // A later record without the field never clears a local credential: 461 // creds are minted once and only ever replaced (rotation), so an 462 // absent field is always a stale record. The local value is kept, 463 // flagged pending, and signalled for a re-push that heals the server. 464 let cleared = CKRecord(recordType: "Game", recordID: recordID) 465 cleared["title"] = "T" as CKRecordValue 466 var staleRecords: [String] = [] 467 let merged = RecordSerializer.applyGameRecord( 468 cleared, 469 to: ctx, 470 onContentKeyChange: { contentKeyChanges.append($0) }, 471 onStaleCredentials: { staleRecords.append($0) } 472 ) 473 try ctx.save() 474 #expect(merged === entity) 475 #expect(GamePushCredentials.decode(merged.notification) == creds) 476 #expect(contentKeyChanges == [gameID]) 477 #expect(staleRecords == [recordID.recordName]) 478 #expect(merged.hasPendingSave) 479 } 480 481 @Test("applyGameRecord preserves id and createdAt on second apply, updates title") 482 @MainActor func applyGameRecordMergesOnServerRecordChanged() throws { 483 let persistence = makeTestPersistence() 484 let ctx = persistence.viewContext 485 let gameID = UUID() 486 let zone = RecordSerializer.zoneID(for: gameID) 487 let recordName = RecordSerializer.recordName(forGameID: gameID) 488 let recordID = CKRecord.ID(recordName: recordName, zoneID: zone) 489 490 let (asset1, tmpURL1) = try makePuzzleAsset(source: "original source") 491 defer { try? FileManager.default.removeItem(at: tmpURL1) } 492 493 // First apply — creates the entity. 494 let record1 = CKRecord(recordType: "Game", recordID: recordID) 495 record1["title"] = "Original" as CKRecordValue 496 record1["puzzleSource"] = asset1 as CKRecordValue 497 let entity = RecordSerializer.applyGameRecord(record1, to: ctx) 498 try ctx.save() 499 500 let frozenID = entity.id 501 let frozenCreatedAt = entity.createdAt 502 503 // Second apply — simulates a server record change with an updated title. 504 // puzzleSource is intentionally absent here to verify it isn't wiped. 505 let record2 = CKRecord(recordType: "Game", recordID: recordID) 506 record2["title"] = "Updated" as CKRecordValue 507 let merged = RecordSerializer.applyGameRecord(record2, to: ctx) 508 try ctx.save() 509 510 #expect(merged === entity) // same managed object 511 #expect(merged.id == frozenID) // id not overwritten 512 #expect(merged.createdAt == frozenCreatedAt) // createdAt not overwritten 513 #expect(merged.title == "Updated") // mutable field updated 514 } 515 516 /// A valid XD whose title ("Test Puzzle") differs from any `record["title"]` 517 /// the tests set, so the parse-derived title is observable. 518 private static let validXDSource = """ 519 Title: Test Puzzle 520 Author: Test 521 522 523 ABC 524 D#E 525 FGH 526 527 528 A1. Across 1 ~ ABC 529 A4. Across 4 ~ DE 530 A5. Across 5 ~ FGH 531 D1. Down 1 ~ ADF 532 D2. Down 2 ~ BG 533 D3. Down 3 ~ CEH 534 """ 535 536 @Test("applyGameRecord derives the title from the puzzle asset, overriding a stale record title") 537 @MainActor func applyGameRecordDerivesTitleFromAsset() throws { 538 let persistence = makeTestPersistence() 539 let ctx = persistence.viewContext 540 let gameID = UUID() 541 let recordID = CKRecord.ID( 542 recordName: RecordSerializer.recordName(forGameID: gameID), 543 zoneID: RecordSerializer.zoneID(for: gameID) 544 ) 545 546 // The record's title field carries a stale "Joining…" placeholder — the 547 // exact value a participant's Game-record push can clobber the shared 548 // record with — but the puzzleSource asset parses to "Test Puzzle". 549 let (asset, tmpURL) = try makePuzzleAsset(source: Self.validXDSource) 550 defer { try? FileManager.default.removeItem(at: tmpURL) } 551 let record = CKRecord(recordType: "Game", recordID: recordID) 552 record["title"] = "Joining\u{2026}" as CKRecordValue 553 record["puzzleSource"] = asset as CKRecordValue 554 555 let entity = RecordSerializer.applyGameRecord(record, to: ctx) 556 try ctx.save() 557 558 // The asset wins: the stale title self-heals to the puzzle's real title. 559 #expect(entity.title == "Test Puzzle") 560 } 561 562 @Test("applyGameRecord skips an oversized puzzleSource asset and reports it via onDiagnostic") 563 @MainActor func applyGameRecordReportsOversizedAsset() throws { 564 let persistence = makeTestPersistence() 565 let ctx = persistence.viewContext 566 let gameID = UUID() 567 let recordID = CKRecord.ID( 568 recordName: RecordSerializer.recordName(forGameID: gameID), 569 zoneID: RecordSerializer.zoneID(for: gameID) 570 ) 571 let oversized = String(repeating: "x", count: XD.maxSourceBytes + 1) 572 let (asset, tmpURL) = try makePuzzleAsset(source: oversized) 573 defer { try? FileManager.default.removeItem(at: tmpURL) } 574 let record = CKRecord(recordType: "Game", recordID: recordID) 575 record["puzzleSource"] = asset as CKRecordValue 576 577 var diagnostics: [String] = [] 578 let entity = RecordSerializer.applyGameRecord( 579 record, 580 to: ctx, 581 onDiagnostic: { diagnostics.append($0) } 582 ) 583 584 #expect((entity.puzzleSource ?? "").isEmpty) 585 #expect(diagnostics.count == 1) 586 #expect(diagnostics.first?.contains("exceeds") == true) 587 } 588 589 @Test("boundedAssetData reads a file at the limit and rejects one over it") 590 func boundedAssetDataEnforcesLimit() throws { 591 let url = FileManager.default.temporaryDirectory 592 .appendingPathComponent(UUID().uuidString) 593 defer { try? FileManager.default.removeItem(at: url) } 594 595 try Data(count: 16).write(to: url) 596 #expect(try RecordSerializer.boundedAssetData(at: url, limit: 16).count == 16) 597 #expect(throws: RecordSerializer.AssetReadError.self) { 598 _ = try RecordSerializer.boundedAssetData(at: url, limit: 15) 599 } 600 } 601 602 @Test("boundedAssetData fails closed on a missing file") 603 func boundedAssetDataFailsClosedOnMissingFile() { 604 let url = FileManager.default.temporaryDirectory 605 .appendingPathComponent(UUID().uuidString) 606 #expect(throws: (any Error).self) { 607 _ = try RecordSerializer.boundedAssetData(at: url, limit: 16) 608 } 609 } 610 611 @Test("applyGameRecord reports a failed puzzleSource asset read via onDiagnostic") 612 @MainActor func applyGameRecordReportsFailedAssetRead() throws { 613 let persistence = makeTestPersistence() 614 let ctx = persistence.viewContext 615 let gameID = UUID() 616 let recordID = CKRecord.ID( 617 recordName: RecordSerializer.recordName(forGameID: gameID), 618 zoneID: RecordSerializer.zoneID(for: gameID) 619 ) 620 // Delete the backing file before applying, simulating a CKAsset whose 621 // download staged file is gone by the time the record is applied. 622 let (asset, tmpURL) = try makePuzzleAsset(source: Self.validXDSource) 623 try FileManager.default.removeItem(at: tmpURL) 624 let record = CKRecord(recordType: "Game", recordID: recordID) 625 record["puzzleSource"] = asset as CKRecordValue 626 627 var diagnostics: [String] = [] 628 let entity = RecordSerializer.applyGameRecord( 629 record, 630 to: ctx, 631 onDiagnostic: { diagnostics.append($0) } 632 ) 633 634 #expect((entity.puzzleSource ?? "").isEmpty) 635 #expect(diagnostics.count == 1) 636 #expect(diagnostics.first?.contains("read failed") == true) 637 } 638 639 @Test("populateGameRecord writes the title for an owner but not a participant") 640 @MainActor func populateGameRecordGatesTitleOnOwnership() throws { 641 let persistence = makeTestPersistence() 642 let ctx = persistence.viewContext 643 644 func makeGame(databaseScope: Int16) -> GameEntity { 645 let entity = GameEntity(context: ctx) 646 entity.id = UUID() 647 entity.ckRecordName = "game-\(UUID().uuidString)" 648 entity.title = "Joining\u{2026}" 649 entity.ckShareRecordName = "share-marker" 650 entity.puzzleSource = "" 651 entity.databaseScope = databaseScope 652 return entity 653 } 654 655 // Owner (databaseScope == 0): title and share marker are written. 656 let ownerEntity = makeGame(databaseScope: 0) 657 ownerEntity.syncVersion = GameSyncVersion.current 658 let ownerRecord = CKRecord(recordType: "Game", recordID: CKRecord.ID(recordName: ownerEntity.ckRecordName!)) 659 RecordSerializer.populateGameRecord(ownerRecord, from: ownerEntity, includePuzzleSource: false) 660 #expect(ownerRecord["title"] as? String == "Joining\u{2026}") 661 #expect(ownerRecord["shareRecordName"] as? String == "share-marker") 662 #expect(ownerRecord["syncVersion"] as? Int64 == GameSyncVersion.current) 663 664 // Participant (databaseScope == 1): the transient placeholder title is 665 // not written, so a cred-minting re-save can't clobber the owner's title. 666 let participantEntity = makeGame(databaseScope: 1) 667 let participantRecord = CKRecord(recordType: "Game", recordID: CKRecord.ID(recordName: participantEntity.ckRecordName!)) 668 RecordSerializer.populateGameRecord(participantRecord, from: participantEntity, includePuzzleSource: false) 669 #expect(participantRecord["title"] == nil) 670 #expect(participantRecord["shareRecordName"] == nil) 671 #expect(participantRecord["syncVersion"] == nil) 672 } 673 674 @Test("Game records default missing sync versions to legacy and adopt explicit versions") 675 @MainActor func applyGameRecordSyncVersions() { 676 let persistence = makeTestPersistence() 677 let ctx = persistence.viewContext 678 679 let legacyID = UUID() 680 let legacyRecord = CKRecord( 681 recordType: "Game", 682 recordID: CKRecord.ID( 683 recordName: RecordSerializer.recordName(forGameID: legacyID), 684 zoneID: RecordSerializer.zoneID(for: legacyID) 685 ) 686 ) 687 let legacy = RecordSerializer.applyGameRecord(legacyRecord, to: ctx) 688 #expect(legacy.syncVersion == GameSyncVersion.legacy) 689 690 let futureID = UUID() 691 let futureRecord = CKRecord( 692 recordType: "Game", 693 recordID: CKRecord.ID( 694 recordName: RecordSerializer.recordName(forGameID: futureID), 695 zoneID: RecordSerializer.zoneID(for: futureID) 696 ) 697 ) 698 futureRecord["syncVersion"] = Int64(7) as CKRecordValue 699 let future = RecordSerializer.applyGameRecord(futureRecord, to: ctx) 700 #expect(future.syncVersion == 7) 701 } 702 703 @Test("applyGameRecord preserves local mutable fields when a save is pending") 704 @MainActor func applyGameRecordPreservesLocalFieldsWhenSavePending() throws { 705 let persistence = makeTestPersistence() 706 let ctx = persistence.viewContext 707 let gameID = UUID() 708 let zone = RecordSerializer.zoneID(for: gameID) 709 let recordName = RecordSerializer.recordName(forGameID: gameID) 710 let recordID = CKRecord.ID(recordName: recordName, zoneID: zone) 711 712 // Local entity reflects a just-set completion: cells are solved, 713 // `markCompleted` wrote `completedAt` and `hasPendingSave` together, 714 // and a Game-record push is queued but hasn't landed yet. 715 let localCompletedAt = Date(timeIntervalSince1970: 1_700_000_500) 716 let entity = GameEntity(context: ctx) 717 entity.id = gameID 718 entity.ckRecordName = recordName 719 entity.ckZoneName = zone.zoneName 720 entity.title = "Local Title" 721 entity.completedAt = localCompletedAt 722 entity.hasPendingSave = true 723 entity.puzzleSource = "" 724 entity.createdAt = Date(timeIntervalSince1970: 1_700_000_000) 725 entity.updatedAt = Date(timeIntervalSince1970: 1_700_000_400) 726 727 // Stale server snapshot (the push hasn't landed): no completedAt, 728 // older title. Applying it without the pending-save guard would 729 // clobber the local fields and the next outbound push would then 730 // serialise the clobbered values, permanently losing them. 731 let record = CKRecord(recordType: "Game", recordID: recordID) 732 record["title"] = "Remote Stale Title" as CKRecordValue 733 734 let merged = RecordSerializer.applyGameRecord(record, to: ctx) 735 try ctx.save() 736 737 #expect(merged === entity) 738 #expect(merged.completedAt == localCompletedAt) 739 #expect(merged.title == "Local Title") 740 // The fresher etag is still adopted so the next push uses a current 741 // change tag and doesn't oplock-fail. 742 #expect(merged.ckSystemFields != nil) 743 } 744 745 @Test("applyGameRecord does not clear an existing completion with an incomplete snapshot") 746 @MainActor func applyGameRecordKeepsExistingCompletionWhenIncomingIsNil() throws { 747 let persistence = makeTestPersistence() 748 let ctx = persistence.viewContext 749 let gameID = UUID() 750 let zone = RecordSerializer.zoneID(for: gameID) 751 let recordName = RecordSerializer.recordName(forGameID: gameID) 752 let recordID = CKRecord.ID(recordName: recordName, zoneID: zone) 753 let localCompletedAt = Date(timeIntervalSince1970: 1_700_000_500) 754 755 let entity = GameEntity(context: ctx) 756 entity.id = gameID 757 entity.ckRecordName = recordName 758 entity.ckZoneName = zone.zoneName 759 entity.title = "Completed" 760 entity.completedAt = localCompletedAt 761 entity.completedBy = "alice" 762 entity.puzzleSource = "" 763 entity.createdAt = Date(timeIntervalSince1970: 1_700_000_000) 764 entity.updatedAt = Date(timeIntervalSince1970: 1_700_000_400) 765 try ctx.save() 766 767 let record = CKRecord(recordType: "Game", recordID: recordID) 768 record["pushCredential"] = Data("fresh-notification".utf8) as CKRecordValue 769 770 let merged = RecordSerializer.applyGameRecord(record, to: ctx) 771 try ctx.save() 772 773 #expect(merged === entity) 774 #expect(merged.completedAt == localCompletedAt) 775 #expect(merged.completedBy == "alice") 776 #expect(merged.notification == "fresh-notification") 777 } 778 779 @Test("populateGameRecord preserves server completion when local participant is stale") 780 @MainActor func populateGameRecordDoesNotClearServerCompletionFromStaleParticipant() throws { 781 let gameID = UUID() 782 let zone = RecordSerializer.zoneID(for: gameID, ownerName: "_owner") 783 let recordID = CKRecord.ID( 784 recordName: RecordSerializer.recordName(forGameID: gameID), 785 zoneID: zone 786 ) 787 let serverCompletedAt = Date(timeIntervalSince1970: 1_700_000_600) 788 let record = CKRecord(recordType: "Game", recordID: recordID) 789 record["completedAt"] = serverCompletedAt as CKRecordValue 790 record["completedBy"] = "owner-author" as CKRecordValue 791 792 let persistence = makeTestPersistence() 793 let entity = GameEntity(context: persistence.viewContext) 794 entity.id = gameID 795 entity.ckRecordName = recordID.recordName 796 entity.databaseScope = 1 797 entity.notification = "new-notification" 798 799 RecordSerializer.populateGameRecord(record, from: entity, includePuzzleSource: false) 800 801 #expect(record["completedAt"] as? Date == serverCompletedAt) 802 #expect(record["completedBy"] as? String == "owner-author") 803 #expect((record["pushCredential"] as? Data).flatMap { String(data: $0, encoding: .utf8) } == "new-notification") 804 } 805 806 @Test("applyGameRecord does not lower an existing updatedAt") 807 @MainActor func applyGameRecordPreservesFresherUpdatedAt() throws { 808 let persistence = makeTestPersistence() 809 let ctx = persistence.viewContext 810 let gameID = UUID() 811 let zone = RecordSerializer.zoneID(for: gameID) 812 let recordName = RecordSerializer.recordName(forGameID: gameID) 813 let recordID = CKRecord.ID(recordName: recordName, zoneID: zone) 814 815 let entity = GameEntity(context: ctx) 816 let newerUpdatedAt = Date(timeIntervalSince1970: 1_700_000_500) 817 entity.id = gameID 818 entity.ckRecordName = recordName 819 entity.ckZoneName = zone.zoneName 820 entity.title = "Local" 821 entity.puzzleSource = "" 822 entity.createdAt = Date(timeIntervalSince1970: 1_700_000_000) 823 entity.updatedAt = newerUpdatedAt 824 825 let record = CKRecord(recordType: "Game", recordID: recordID) 826 record["title"] = "Remote" as CKRecordValue 827 828 let merged = RecordSerializer.applyGameRecord(record, to: ctx) 829 try ctx.save() 830 831 #expect(merged === entity) 832 #expect(merged.title == "Remote") 833 #expect(merged.updatedAt == newerUpdatedAt) 834 } 835 836 // MARK: - System fields round-trip 837 838 @Test("Encode and decode system fields preserves record type and zone") 839 func systemFieldsRoundTrip() { 840 let gameID = UUID() 841 let zoneID = RecordSerializer.zoneID(for: gameID) 842 let recordID = CKRecord.ID(recordName: "test-record", zoneID: zoneID) 843 let original = CKRecord(recordType: "Cell", recordID: recordID) 844 845 let encoded = RecordSerializer.encodeSystemFields(of: original) 846 #expect(encoded != nil) 847 848 let decoded = RecordSerializer.decodeRecord(from: encoded!) 849 #expect(decoded != nil) 850 #expect(decoded?.recordType == "Cell") 851 #expect(decoded?.recordID.zoneID.zoneName == "game-\(gameID.uuidString)") 852 #expect(decoded?.recordID.recordName == "test-record") 853 } 854 855 // MARK: - Decision records 856 857 @Test("Decision record name uses decision-<kind>-<key> format") 858 func decisionRecordNameFormat() { 859 let name = RecordSerializer.decisionRecordName(kind: "block", key: "_bob") 860 #expect(name == "decision-block-_bob") 861 } 862 863 @Test("parseDecisionRecordName round-trips, preserving dashes in the key") 864 func decisionNameRoundTrip() { 865 let name = RecordSerializer.decisionRecordName(kind: "block", key: "_b-o-b") 866 let parsed = RecordSerializer.parseDecisionRecordName(name) 867 #expect(parsed?.kind == "block") 868 #expect(parsed?.key == "_b-o-b") 869 } 870 871 @Test("parseDecisionRecordName rejects non-decision and malformed names") 872 func decisionNameRejectsOthers() { 873 #expect(RecordSerializer.parseDecisionRecordName("ping-1234") == nil) 874 #expect(RecordSerializer.parseDecisionRecordName("decision-block") == nil) 875 #expect(RecordSerializer.parseDecisionRecordName("decision--k") == nil) 876 } 877 878 @Test("decisionRecord keeps identity in the name, not a key field") 879 func decisionRecordFields() { 880 let record = RecordSerializer.decisionRecord( 881 kind: "block", 882 key: "_bob", 883 zone: RecordSerializer.accountZoneID 884 ) 885 #expect(record.recordType == "Decision") 886 // Identity (kind + key) lives in the record name. 887 #expect(record.recordID.recordName == "decision-block-_bob") 888 #expect(record.recordID.zoneID.zoneName == "account") 889 #expect(record["kind"] as? String == "block") 890 // `key` is not duplicated as a field; `payload` is unused for block. 891 #expect(record["key"] == nil) 892 #expect(record["payload"] == nil) 893 } 894 895 @Test("decisionRecord carries an optional payload when provided") 896 func decisionRecordPayload() { 897 let record = RecordSerializer.decisionRecord( 898 kind: "snooze", 899 key: "_bob", 900 payload: "{\"until\":1}", 901 zone: RecordSerializer.accountZoneID 902 ) 903 #expect(record.recordID.recordName == "decision-snooze-_bob") 904 #expect(record["payload"] as? String == "{\"until\":1}") 905 } 906 907 @Test("decisionRecord writes the version field only when provided") 908 func decisionRecordVersion() { 909 let unversioned = RecordSerializer.decisionRecord( 910 kind: "account", 911 key: "pushSecret", 912 payload: "s", 913 zone: RecordSerializer.accountZoneID 914 ) 915 #expect(unversioned["version"] == nil) 916 917 let versioned = RecordSerializer.decisionRecord( 918 kind: "account", 919 key: "pushSecret", 920 payload: "s", 921 zone: RecordSerializer.accountZoneID, 922 version: 3 923 ) 924 #expect(versioned["version"] as? Int64 == 3) 925 } 926 927 @Test("decisionVersion defaults to the base generation for a version-less record") 928 func decisionVersionDefault() { 929 let record = RecordSerializer.decisionRecord( 930 kind: "account", 931 key: "pushSecret", 932 payload: "s", 933 zone: RecordSerializer.accountZoneID 934 ) 935 #expect(RecordSerializer.decisionVersion(record) == RecordSerializer.decisionBaseVersion) 936 } 937 938 @Test("parseAccountPushSecretDecision returns the secret and its generation") 939 func parseAccountPushSecretDecisionReadsVersion() { 940 let record = RecordSerializer.decisionRecord( 941 kind: RecordSerializer.accountDecisionKind, 942 key: RecordSerializer.accountPushSecretDecisionKey, 943 payload: "the-secret", 944 zone: RecordSerializer.accountZoneID, 945 version: 5 946 ) 947 let parsed = RecordSerializer.parseAccountPushSecretDecision(record, databaseScope: .private) 948 #expect(parsed?.secret == "the-secret") 949 #expect(parsed?.version == 5) 950 } 951 952 @Test("parseAccountPushSecretDecision rejects a copy from a friend inbox zone") 953 func parseAccountPushSecretDecisionRejectsFriendZone() { 954 // A friend holds .readWrite on our private friend-<pairKey> inbox (also 955 // scope 0). A forged secret dropped there must never be adopted as our 956 // own HMAC secret — only the account zone counts. 957 let friendZone = CKRecordZone.ID( 958 zoneName: FriendZone.zoneName(pairKey: "pair-abc"), 959 ownerName: CKCurrentUserDefaultName 960 ) 961 let record = RecordSerializer.decisionRecord( 962 kind: RecordSerializer.accountDecisionKind, 963 key: RecordSerializer.accountPushSecretDecisionKey, 964 payload: "forged-secret", 965 zone: friendZone, 966 version: 99 967 ) 968 #expect(RecordSerializer.parseAccountPushSecretDecision(record, databaseScope: .private) == nil) 969 } 970 971 @Test("parseAccountPushSecretDecision rejects an account-named zone in the shared scope") 972 func parseAccountPushSecretDecisionRejectsSharedScope() { 973 // A peer can own a zone that *names* itself like our account zone and 974 // share it to us; it arrives via the shared DB (scope 1). The scope 975 // gate rejects it even though the zone name matches. 976 let record = RecordSerializer.decisionRecord( 977 kind: RecordSerializer.accountDecisionKind, 978 key: RecordSerializer.accountPushSecretDecisionKey, 979 payload: "forged-secret", 980 zone: RecordSerializer.accountZoneID, 981 version: 99 982 ) 983 #expect(RecordSerializer.parseAccountPushSecretDecision(record, databaseScope: .shared) == nil) 984 } 985 986 @Test("parseAccountPushSecretDecision treats a missing version as the base generation") 987 func parseAccountPushSecretDecisionDefaultsVersion() { 988 let record = RecordSerializer.decisionRecord( 989 kind: RecordSerializer.accountDecisionKind, 990 key: RecordSerializer.accountPushSecretDecisionKey, 991 payload: "legacy-secret", 992 zone: RecordSerializer.accountZoneID 993 ) 994 let parsed = RecordSerializer.parseAccountPushSecretDecision(record, databaseScope: .private) 995 #expect(parsed?.version == RecordSerializer.decisionBaseVersion) 996 } 997 998 @Test("applyDecisionRecord(.block) creates a blocked FriendEntity with derived pairKey") 999 @MainActor func applyDecisionBlockCreatesTombstone() throws { 1000 let persistence = makeTestPersistence() 1001 let ctx = persistence.viewContext 1002 let record = RecordSerializer.decisionRecord( 1003 kind: "block", 1004 key: "_bob", 1005 zone: RecordSerializer.accountZoneID 1006 ) 1007 1008 let wrote = RecordSerializer.applyDecisionRecord( 1009 record, 1010 to: ctx, 1011 localAuthorID: "_alice" 1012 ) 1013 #expect(wrote) 1014 1015 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 1016 req.predicate = NSPredicate(format: "authorID == %@", "_bob") 1017 let friend = try ctx.fetch(req).first 1018 #expect(friend?.isBlocked == true) 1019 #expect(friend?.pairKey == FriendZone.pairKey("_alice", "_bob")) 1020 } 1021 1022 @Test("applyDecisionRecord(.block) flips an existing active friend to blocked") 1023 @MainActor func applyDecisionBlockMarksExistingFriend() throws { 1024 let persistence = makeTestPersistence() 1025 let ctx = persistence.viewContext 1026 1027 let existing = FriendEntity(context: ctx) 1028 existing.authorID = "_bob" 1029 existing.pairKey = "k-existing" 1030 existing.isBlocked = false 1031 existing.createdAt = Date() 1032 try ctx.save() 1033 1034 let record = RecordSerializer.decisionRecord( 1035 kind: "block", 1036 key: "_bob", 1037 zone: RecordSerializer.accountZoneID 1038 ) 1039 RecordSerializer.applyDecisionRecord(record, to: ctx, localAuthorID: "_alice") 1040 1041 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 1042 req.predicate = NSPredicate(format: "authorID == %@", "_bob") 1043 let rows = try ctx.fetch(req) 1044 // Upsert, not insert: the existing row flips rather than duplicating, 1045 // and its original pairKey/zone are left intact. 1046 #expect(rows.count == 1) 1047 #expect(rows.first?.isBlocked == true) 1048 #expect(rows.first?.pairKey == "k-existing") 1049 } 1050 1051 @Test("applyDecisionRecord(.block) is versioned: unblock clears, stale re-block ignored") 1052 @MainActor func applyDecisionBlockUnblockVersioned() throws { 1053 let persistence = makeTestPersistence() 1054 let ctx = persistence.viewContext 1055 1056 let existing = FriendEntity(context: ctx) 1057 existing.authorID = "_bob" 1058 existing.pairKey = "k" 1059 existing.createdAt = Date() 1060 try ctx.save() 1061 1062 func blockDecision(_ payload: String, version: Int64) -> CKRecord { 1063 RecordSerializer.decisionRecord( 1064 kind: RecordSerializer.blockDecisionKind, 1065 key: "_bob", 1066 payload: payload, 1067 zone: RecordSerializer.accountZoneID, 1068 version: version 1069 ) 1070 } 1071 1072 // Block at v1. 1073 #expect(RecordSerializer.applyDecisionRecord( 1074 blockDecision("1", version: 1), to: ctx, localAuthorID: "_alice" 1075 )) 1076 #expect(existing.isBlocked == true) 1077 #expect(existing.blockVersion == 1) 1078 1079 // Unblock at v2 supersedes it. 1080 #expect(RecordSerializer.applyDecisionRecord( 1081 blockDecision("0", version: 2), to: ctx, localAuthorID: "_alice" 1082 )) 1083 #expect(existing.isBlocked == false) 1084 #expect(existing.blockVersion == 2) 1085 1086 // A stale re-block at v1 is rejected — it can't resurrect the block. 1087 #expect(!RecordSerializer.applyDecisionRecord( 1088 blockDecision("1", version: 1), to: ctx, localAuthorID: "_alice" 1089 )) 1090 #expect(existing.isBlocked == false) 1091 #expect(existing.blockVersion == 2) 1092 } 1093 1094 @Test("applyDecisionRecord(.block) rejects a block written into a friend zone") 1095 @MainActor func applyDecisionBlockRejectsFriendZone() throws { 1096 let persistence = makeTestPersistence() 1097 let ctx = persistence.viewContext 1098 1099 // A block is the user's own choice and only travels through the account 1100 // zone in their private database. A friend (Mallory) holds `.readWrite` 1101 // on the pair zone Alice owns, so she could drop `decision-block-_carol` 1102 // there — even arriving at Alice's private scope (0), it must be 1103 // rejected purely on zone, so it can't block a third party on her behalf. 1104 let record = RecordSerializer.decisionRecord( 1105 kind: RecordSerializer.blockDecisionKind, 1106 key: "_carol", 1107 zone: friendZoneID(local: "_alice", remote: "_mallory") 1108 ) 1109 let wrote = RecordSerializer.applyDecisionRecord( 1110 record, to: ctx, localAuthorID: "_alice", databaseScope: .private 1111 ) 1112 #expect(!wrote) 1113 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 1114 #expect(try ctx.count(for: req) == 0) 1115 } 1116 1117 @Test("applyDecisionRecord(.left) rejects a left decision written into a friend zone") 1118 @MainActor func applyDecisionLeftRejectsFriendZone() throws { 1119 let persistence = makeTestPersistence() 1120 let ctx = persistence.viewContext 1121 1122 // A `left` decision hard-deletes a participant game row. A friend who 1123 // can name a live `gameID` must not be able to evict Alice's row by 1124 // writing the decision into the pair zone he can reach — it is honored 1125 // only from Alice's own account zone. 1126 let gameID = UUID() 1127 let entity = GameEntity(context: ctx) 1128 entity.id = gameID 1129 entity.title = "Shared" 1130 entity.puzzleSource = "" 1131 entity.databaseScope = 1 1132 entity.createdAt = Date() 1133 entity.updatedAt = Date() 1134 try ctx.save() 1135 1136 let record = RecordSerializer.decisionRecord( 1137 kind: "left", 1138 key: gameID.uuidString, 1139 zone: friendZoneID(local: "_alice", remote: "_mallory") 1140 ) 1141 let wrote = RecordSerializer.applyDecisionRecord( 1142 record, to: ctx, localAuthorID: "_alice", databaseScope: .private 1143 ) 1144 #expect(!wrote) 1145 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1146 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1147 #expect(try ctx.count(for: req) == 1) 1148 } 1149 1150 @Test("applyDecisionRecord(.name) rejects a friend name seen at shared scope") 1151 @MainActor func applyNameDecisionRejectsSharedScope() throws { 1152 let persistence = makeTestPersistence() 1153 let ctx = persistence.viewContext 1154 1155 // A friend's name only arrives via the inbox we own (scope 0). The same 1156 // record seen at scope 1 — the friend's own inbox — is not the channel 1157 // they tell us their name through, so it is dropped. 1158 let record = nameDecisionRecord( 1159 subject: "_bob", name: "Brandon", version: 1, 1160 zone: friendZoneID(local: "_alice", remote: "_bob") 1161 ) 1162 let wrote = RecordSerializer.applyDecisionRecord( 1163 record, to: ctx, localAuthorID: "_alice", databaseScope: .shared 1164 ) 1165 #expect(!wrote) 1166 } 1167 1168 @Test("applyDecisionRecord ignores unknown kinds") 1169 @MainActor func applyDecisionIgnoresUnknownKind() throws { 1170 let persistence = makeTestPersistence() 1171 let ctx = persistence.viewContext 1172 let record = RecordSerializer.decisionRecord( 1173 kind: "future", 1174 key: "_bob", 1175 zone: RecordSerializer.accountZoneID 1176 ) 1177 let wrote = RecordSerializer.applyDecisionRecord( 1178 record, 1179 to: ctx, 1180 localAuthorID: "_alice" 1181 ) 1182 #expect(!wrote) 1183 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 1184 #expect(try ctx.count(for: req) == 0) 1185 } 1186 1187 @Test("applyDecisionRecord(.encryptionKey) mirrors a peer key from its pair zone") 1188 @MainActor func applyEncryptionKeyDecisionMirrorsPeerKey() async throws { 1189 let url = FileManager.default.temporaryDirectory 1190 .appendingPathComponent("friend-key-directory-\(UUID().uuidString).json") 1191 defer { try? FileManager.default.removeItem(at: url) } 1192 1193 try await FriendEncryptionKeyDirectory.$testingFileURL.withValue(url) { 1194 let persistence = makeTestPersistence() 1195 let ctx = persistence.viewContext 1196 let payload = try #require(FriendEncryptionKeyPayload.fresh()) 1197 let record = RecordSerializer.decisionRecord( 1198 kind: RecordSerializer.encryptionKeyDecisionKind, 1199 key: "_bob", 1200 payload: payload.encodedString(), 1201 zone: friendZoneID(local: "_alice", remote: "_bob") 1202 ) 1203 1204 let wrote = RecordSerializer.applyDecisionRecord( 1205 record, 1206 to: ctx, 1207 localAuthorID: "_alice" 1208 ) 1209 #expect(!wrote) 1210 #expect(FriendEncryptionKeyDirectory.payload(for: "_bob") == payload) 1211 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 1212 #expect(try ctx.count(for: req) == 0) 1213 } 1214 } 1215 1216 @Test("applyDecisionRecord(.encryptionKey) rejects a key seen at shared scope") 1217 @MainActor func applyEncryptionKeyDecisionRejectsSharedScope() async throws { 1218 let url = FileManager.default.temporaryDirectory 1219 .appendingPathComponent("friend-key-directory-\(UUID().uuidString).json") 1220 defer { try? FileManager.default.removeItem(at: url) } 1221 1222 try await FriendEncryptionKeyDirectory.$testingFileURL.withValue(url) { 1223 let persistence = makeTestPersistence() 1224 let ctx = persistence.viewContext 1225 let payload = try #require(FriendEncryptionKeyPayload.fresh()) 1226 // A peer owns a zone that *names* itself for this pair and shares it 1227 // to us; it arrives via the shared DB (scope 1). The zone name 1228 // matches, so only the scope gate rejects it — a forged key must not 1229 // be mirrored into the directory the NSE reads. 1230 let record = RecordSerializer.decisionRecord( 1231 kind: RecordSerializer.encryptionKeyDecisionKind, 1232 key: "_bob", 1233 payload: payload.encodedString(), 1234 zone: friendZoneID(local: "_alice", remote: "_bob") 1235 ) 1236 1237 let wrote = RecordSerializer.applyDecisionRecord( 1238 record, 1239 to: ctx, 1240 localAuthorID: "_alice", 1241 databaseScope: .shared 1242 ) 1243 #expect(!wrote) 1244 #expect(FriendEncryptionKeyDirectory.payload(for: "_bob") == nil) 1245 } 1246 } 1247 1248 // MARK: - Name decisions 1249 1250 /// The pairwise friend zone for (`local`, `remote`) — the only zone a 1251 /// name Decision for `remote` is honored from. 1252 private func friendZoneID(local: String, remote: String) -> CKRecordZone.ID { 1253 CKRecordZone.ID( 1254 zoneName: FriendZone.zoneName(pairKey: FriendZone.pairKey(local, remote)), 1255 ownerName: "_zone-owner" 1256 ) 1257 } 1258 1259 private func nameDecisionRecord( 1260 subject: String, 1261 name: String, 1262 version: Int64, 1263 zone: CKRecordZone.ID 1264 ) -> CKRecord { 1265 RecordSerializer.decisionRecord( 1266 kind: RecordSerializer.nameDecisionKind, 1267 key: subject, 1268 payload: name, 1269 zone: zone, 1270 version: version 1271 ) 1272 } 1273 1274 @Test("applyDecisionRecord(.name) updates an existing friend from its pair zone") 1275 @MainActor func applyNameDecisionUpdatesFriend() throws { 1276 let persistence = makeTestPersistence() 1277 let ctx = persistence.viewContext 1278 let pairKey = FriendZone.pairKey("_alice", "_bob") 1279 let existing = FriendEntity(context: ctx) 1280 existing.authorID = "_bob" 1281 existing.pairKey = pairKey 1282 existing.createdAt = Date() 1283 try ctx.save() 1284 1285 let record = nameDecisionRecord( 1286 subject: "_bob", 1287 name: "Brandon", 1288 version: 1, 1289 zone: friendZoneID(local: "_alice", remote: "_bob") 1290 ) 1291 let wrote = RecordSerializer.applyDecisionRecord( 1292 record, to: ctx, localAuthorID: "_alice" 1293 ) 1294 #expect(wrote) 1295 #expect(existing.displayName == "Brandon") 1296 #expect(existing.displayNameVersion == 1) 1297 } 1298 1299 @Test("applyDecisionRecord(.name) is last-writer-wins on version") 1300 @MainActor func applyNameDecisionVersionGate() throws { 1301 let persistence = makeTestPersistence() 1302 let ctx = persistence.viewContext 1303 let pairKey = FriendZone.pairKey("_alice", "_bob") 1304 let existing = FriendEntity(context: ctx) 1305 existing.authorID = "_bob" 1306 existing.pairKey = pairKey 1307 existing.createdAt = Date() 1308 existing.displayName = "Brandon" 1309 existing.displayNameVersion = 3 1310 try ctx.save() 1311 1312 let zone = friendZoneID(local: "_alice", remote: "_bob") 1313 let stale = nameDecisionRecord(subject: "_bob", name: "Old", version: 2, zone: zone) 1314 #expect(!RecordSerializer.applyDecisionRecord(stale, to: ctx, localAuthorID: "_alice")) 1315 #expect(existing.displayName == "Brandon") 1316 1317 let equal = nameDecisionRecord(subject: "_bob", name: "Bran", version: 3, zone: zone) 1318 #expect(RecordSerializer.applyDecisionRecord(equal, to: ctx, localAuthorID: "_alice")) 1319 #expect(existing.displayName == "Bran") 1320 1321 let newer = nameDecisionRecord(subject: "_bob", name: "Brandon II", version: 4, zone: zone) 1322 #expect(RecordSerializer.applyDecisionRecord(newer, to: ctx, localAuthorID: "_alice")) 1323 #expect(existing.displayName == "Brandon II") 1324 #expect(existing.displayNameVersion == 4) 1325 } 1326 1327 @Test("applyDecisionRecord(.name) resurrects a friendship from our inbox") 1328 @MainActor func applyNameDecisionResurrectsFriend() throws { 1329 let persistence = makeTestPersistence() 1330 let ctx = persistence.viewContext 1331 let zone = friendZoneID(local: "_alice", remote: "_bob") 1332 1333 // A friend's name arrives via the inbox we own (private engine, 1334 // scope 0). 1335 let record = nameDecisionRecord(subject: "_bob", name: "Brandon", version: 2, zone: zone) 1336 let wrote = RecordSerializer.applyDecisionRecord( 1337 record, to: ctx, localAuthorID: "_alice", databaseScope: .private 1338 ) 1339 #expect(wrote) 1340 1341 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 1342 req.predicate = NSPredicate(format: "authorID == %@", "_bob") 1343 let friend = try ctx.fetch(req).first 1344 #expect(friend?.displayName == "Brandon") 1345 #expect(friend?.pairKey == FriendZone.pairKey("_alice", "_bob")) 1346 #expect(friend?.isBlocked == false) 1347 } 1348 1349 @Test("applyDecisionRecord(.name) rejects a record outside the pair's zone") 1350 @MainActor func applyNameDecisionRejectsForeignZone() throws { 1351 let persistence = makeTestPersistence() 1352 let ctx = persistence.viewContext 1353 1354 // A name for _carol arriving in the (_alice, _bob) zone: the zone 1355 // hash doesn't match the (_alice, _carol) pair, so it must be dropped 1356 // — a friend can't assert names for third parties. 1357 let record = nameDecisionRecord( 1358 subject: "_carol", 1359 name: "Mallory", 1360 version: 9, 1361 zone: friendZoneID(local: "_alice", remote: "_bob") 1362 ) 1363 let wrote = RecordSerializer.applyDecisionRecord( 1364 record, to: ctx, localAuthorID: "_alice" 1365 ) 1366 #expect(!wrote) 1367 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 1368 #expect(try ctx.count(for: req) == 0) 1369 } 1370 1371 @Test("applyDecisionRecord(.name) ignores our own name and writes no row") 1372 @MainActor func applyNameDecisionIgnoresSelf() throws { 1373 let persistence = makeTestPersistence() 1374 let ctx = persistence.viewContext 1375 1376 let record = nameDecisionRecord( 1377 subject: "_alice", 1378 name: "Alice", 1379 version: 5, 1380 zone: RecordSerializer.accountZoneID 1381 ) 1382 let wrote = RecordSerializer.applyDecisionRecord( 1383 record, to: ctx, localAuthorID: "_alice" 1384 ) 1385 #expect(!wrote) 1386 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 1387 #expect(try ctx.count(for: req) == 0) 1388 } 1389 1390 @Test("applyDecisionRecord(.name) leaves a blocked friend untouched") 1391 @MainActor func applyNameDecisionSkipsBlocked() throws { 1392 let persistence = makeTestPersistence() 1393 let ctx = persistence.viewContext 1394 let pairKey = FriendZone.pairKey("_alice", "_bob") 1395 let blocked = FriendEntity(context: ctx) 1396 blocked.authorID = "_bob" 1397 blocked.pairKey = pairKey 1398 blocked.isBlocked = true 1399 blocked.createdAt = Date() 1400 try ctx.save() 1401 1402 let record = nameDecisionRecord( 1403 subject: "_bob", 1404 name: "Brandon", 1405 version: 1, 1406 zone: friendZoneID(local: "_alice", remote: "_bob") 1407 ) 1408 let wrote = RecordSerializer.applyDecisionRecord( 1409 record, to: ctx, localAuthorID: "_alice" 1410 ) 1411 #expect(!wrote) 1412 #expect(blocked.displayName?.isEmpty != false) 1413 #expect(blocked.isBlocked == true) 1414 } 1415 1416 // MARK: - Nickname decisions 1417 1418 private func nicknameDecisionRecord( 1419 subject: String, 1420 nickname: String?, 1421 version: Int64, 1422 zone: CKRecordZone.ID = RecordSerializer.accountZoneID 1423 ) -> CKRecord { 1424 RecordSerializer.decisionRecord( 1425 kind: RecordSerializer.nicknameDecisionKind, 1426 key: subject, 1427 payload: nickname, 1428 zone: zone, 1429 version: version 1430 ) 1431 } 1432 1433 @MainActor 1434 private func makeFriend( 1435 in ctx: NSManagedObjectContext, 1436 authorID: String, 1437 pairedWith localAuthorID: String 1438 ) throws -> FriendEntity { 1439 let pairKey = FriendZone.pairKey(localAuthorID, authorID) 1440 let friend = FriendEntity(context: ctx) 1441 friend.authorID = authorID 1442 friend.pairKey = pairKey 1443 friend.createdAt = Date() 1444 try ctx.save() 1445 return friend 1446 } 1447 1448 @Test("applyDecisionRecord(.nickname) sets the nickname on an existing friend") 1449 @MainActor func applyNicknameDecisionUpdatesFriend() throws { 1450 let persistence = makeTestPersistence() 1451 let ctx = persistence.viewContext 1452 let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice") 1453 1454 let record = nicknameDecisionRecord(subject: "_bob", nickname: "Bobby", version: 1) 1455 let wrote = RecordSerializer.applyDecisionRecord( 1456 record, to: ctx, localAuthorID: "_alice" 1457 ) 1458 #expect(wrote) 1459 #expect(friend.nickname == "Bobby") 1460 #expect(friend.nicknameVersion == 1) 1461 #expect(friend.resolvedDisplayName == "Bobby") 1462 } 1463 1464 @Test("applyDecisionRecord(.nickname) is last-writer-wins on version") 1465 @MainActor func applyNicknameDecisionVersionGate() throws { 1466 let persistence = makeTestPersistence() 1467 let ctx = persistence.viewContext 1468 let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice") 1469 friend.nickname = "Bobby" 1470 friend.nicknameVersion = 3 1471 try ctx.save() 1472 1473 let stale = nicknameDecisionRecord(subject: "_bob", nickname: "Old", version: 2) 1474 #expect(!RecordSerializer.applyDecisionRecord(stale, to: ctx, localAuthorID: "_alice")) 1475 #expect(friend.nickname == "Bobby") 1476 1477 let equal = nicknameDecisionRecord(subject: "_bob", nickname: "Rob", version: 3) 1478 #expect(RecordSerializer.applyDecisionRecord(equal, to: ctx, localAuthorID: "_alice")) 1479 #expect(friend.nickname == "Rob") 1480 1481 let newer = nicknameDecisionRecord(subject: "_bob", nickname: "Robert", version: 4) 1482 #expect(RecordSerializer.applyDecisionRecord(newer, to: ctx, localAuthorID: "_alice")) 1483 #expect(friend.nickname == "Robert") 1484 #expect(friend.nicknameVersion == 4) 1485 } 1486 1487 @Test("applyDecisionRecord(.nickname) clears the nickname on an empty payload") 1488 @MainActor func applyNicknameDecisionClears() throws { 1489 let persistence = makeTestPersistence() 1490 let ctx = persistence.viewContext 1491 let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice") 1492 friend.displayName = "Brandon" 1493 friend.displayNameVersion = 1 1494 friend.nickname = "Bobby" 1495 friend.nicknameVersion = 1 1496 try ctx.save() 1497 1498 let record = nicknameDecisionRecord(subject: "_bob", nickname: nil, version: 2) 1499 let wrote = RecordSerializer.applyDecisionRecord( 1500 record, to: ctx, localAuthorID: "_alice" 1501 ) 1502 #expect(wrote) 1503 #expect(friend.nickname == nil) 1504 #expect(friend.nicknameVersion == 2) 1505 // Cleared nickname falls back to the friend's own synced name. 1506 #expect(friend.resolvedDisplayName == "Brandon") 1507 } 1508 1509 @Test("applyDecisionRecord(.nickname) rejects a record outside the account zone") 1510 @MainActor func applyNicknameDecisionRejectsFriendZone() throws { 1511 let persistence = makeTestPersistence() 1512 let ctx = persistence.viewContext 1513 let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice") 1514 1515 // A "nickname" decision planted by the friend in the shared pairwise 1516 // zone must not relabel anyone in this user's list. 1517 let record = nicknameDecisionRecord( 1518 subject: "_bob", 1519 nickname: "Gotcha", 1520 version: 9, 1521 zone: friendZoneID(local: "_alice", remote: "_bob") 1522 ) 1523 let wrote = RecordSerializer.applyDecisionRecord( 1524 record, to: ctx, localAuthorID: "_alice" 1525 ) 1526 #expect(!wrote) 1527 #expect(friend.nickname?.isEmpty != false) 1528 } 1529 1530 @Test("applyDecisionRecord(.nickname) applies a record fetched with a concrete owner name") 1531 @MainActor func applyNicknameDecisionConcreteOwnerName() throws { 1532 let persistence = makeTestPersistence() 1533 let ctx = persistence.viewContext 1534 let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice") 1535 1536 // A Decision written with the `CKCurrentUserDefaultName` placeholder 1537 // comes back from CloudKit with the concrete user-record ID as its 1538 // zone owner. The apply path must still recognise it as the account 1539 // zone (zone *name* + private scope), or no nickname ever syncs. 1540 let fetchedZone = CKRecordZone.ID( 1541 zoneName: RecordSerializer.accountZoneID.zoneName, 1542 ownerName: "_alice" 1543 ) 1544 let record = nicknameDecisionRecord( 1545 subject: "_bob", nickname: "Bobby", version: 1, zone: fetchedZone 1546 ) 1547 let wrote = RecordSerializer.applyDecisionRecord( 1548 record, to: ctx, localAuthorID: "_alice", databaseScope: .private 1549 ) 1550 #expect(wrote) 1551 #expect(friend.nickname == "Bobby") 1552 #expect(friend.nicknameVersion == 1) 1553 } 1554 1555 @Test("applyDecisionRecord(.nickname) rejects an account-named zone in the shared DB") 1556 @MainActor func applyNicknameDecisionRejectsSharedAccountZone() throws { 1557 let persistence = makeTestPersistence() 1558 let ctx = persistence.viewContext 1559 let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice") 1560 1561 // A friend cannot reach our private DB; spoofing the relabel means 1562 // sharing a zone they named "account" into our *shared* DB. The 1563 // private-scope gate must reject it even though the zone name matches. 1564 let spoofZone = CKRecordZone.ID( 1565 zoneName: RecordSerializer.accountZoneID.zoneName, 1566 ownerName: "_bob" 1567 ) 1568 let record = nicknameDecisionRecord( 1569 subject: "_bob", nickname: "Gotcha", version: 9, zone: spoofZone 1570 ) 1571 let wrote = RecordSerializer.applyDecisionRecord( 1572 record, to: ctx, localAuthorID: "_alice", databaseScope: .shared 1573 ) 1574 #expect(!wrote) 1575 #expect(friend.nickname?.isEmpty != false) 1576 } 1577 1578 @Test("applyDecisionRecord(.nickname) writes no row for an unknown friend") 1579 @MainActor func applyNicknameDecisionSkipsUnknownFriend() throws { 1580 let persistence = makeTestPersistence() 1581 let ctx = persistence.viewContext 1582 1583 let record = nicknameDecisionRecord(subject: "_bob", nickname: "Bobby", version: 1) 1584 let wrote = RecordSerializer.applyDecisionRecord( 1585 record, to: ctx, localAuthorID: "_alice" 1586 ) 1587 #expect(!wrote) 1588 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 1589 #expect(try ctx.count(for: req) == 0) 1590 } 1591 1592 @Test("parseNameDecision reads subject, name, and version") 1593 func parseNameDecisionFields() { 1594 let record = nameDecisionRecord( 1595 subject: "_bob", 1596 name: "Brandon", 1597 version: 7, 1598 zone: RecordSerializer.accountZoneID 1599 ) 1600 let parsed = RecordSerializer.parseNameDecision(record) 1601 #expect(parsed?.authorID == "_bob") 1602 #expect(parsed?.name == "Brandon") 1603 #expect(parsed?.version == 7) 1604 // Non-name decisions don't parse. 1605 let block = RecordSerializer.decisionRecord( 1606 kind: "block", key: "_bob", zone: RecordSerializer.accountZoneID 1607 ) 1608 #expect(RecordSerializer.parseNameDecision(block) == nil) 1609 } 1610 1611 @Test("applyDecisionRecord(.left) hard-deletes the participant game row") 1612 @MainActor func applyDecisionLeftDeletesParticipantGame() throws { 1613 let persistence = makeTestPersistence() 1614 let ctx = persistence.viewContext 1615 let gameID = UUID() 1616 let entity = GameEntity(context: ctx) 1617 entity.id = gameID 1618 entity.title = "Shared" 1619 entity.puzzleSource = "" 1620 entity.databaseScope = 1 1621 entity.createdAt = Date() 1622 entity.updatedAt = Date() 1623 try ctx.save() 1624 1625 let record = RecordSerializer.decisionRecord( 1626 kind: "left", 1627 key: gameID.uuidString, 1628 zone: RecordSerializer.accountZoneID 1629 ) 1630 let wrote = RecordSerializer.applyDecisionRecord( 1631 record, to: ctx, localAuthorID: "_alice" 1632 ) 1633 #expect(wrote) 1634 1635 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1636 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1637 #expect(try ctx.count(for: req) == 0) 1638 } 1639 1640 @Test("applyDecisionRecord(.left) leaves an owned (scope 0) row intact") 1641 @MainActor func applyDecisionLeftSkipsOwnedGame() throws { 1642 let persistence = makeTestPersistence() 1643 let ctx = persistence.viewContext 1644 let gameID = UUID() 1645 let entity = GameEntity(context: ctx) 1646 entity.id = gameID 1647 entity.title = "Owned" 1648 entity.puzzleSource = "" 1649 entity.databaseScope = 0 1650 entity.createdAt = Date() 1651 entity.updatedAt = Date() 1652 try ctx.save() 1653 1654 let record = RecordSerializer.decisionRecord( 1655 kind: "left", 1656 key: gameID.uuidString, 1657 zone: RecordSerializer.accountZoneID 1658 ) 1659 let wrote = RecordSerializer.applyDecisionRecord( 1660 record, to: ctx, localAuthorID: "_alice" 1661 ) 1662 // A `left` fact never applies to an owned copy — the participant who 1663 // left can't be the owner of the same game id. 1664 #expect(!wrote) 1665 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1666 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1667 #expect(try ctx.count(for: req) == 1) 1668 } 1669 1670 @Test("applyDecisionRecord(.left) is a no-op when the row is already gone") 1671 @MainActor func applyDecisionLeftIdempotent() { 1672 let persistence = makeTestPersistence() 1673 let ctx = persistence.viewContext 1674 let record = RecordSerializer.decisionRecord( 1675 kind: "left", 1676 key: UUID().uuidString, 1677 zone: RecordSerializer.accountZoneID 1678 ) 1679 let wrote = RecordSerializer.applyDecisionRecord( 1680 record, to: ctx, localAuthorID: "_alice" 1681 ) 1682 #expect(!wrote) 1683 } 1684 1685 @Test("applyDecisionRecord(.left) rejects a non-UUID key") 1686 @MainActor func applyDecisionLeftRejectsBadKey() { 1687 let persistence = makeTestPersistence() 1688 let ctx = persistence.viewContext 1689 let record = RecordSerializer.decisionRecord( 1690 kind: "left", 1691 key: "not-a-uuid", 1692 zone: RecordSerializer.accountZoneID 1693 ) 1694 #expect( 1695 !RecordSerializer.applyDecisionRecord( 1696 record, to: ctx, localAuthorID: "_alice" 1697 ) 1698 ) 1699 } 1700 }