crossmate

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

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 }