crossmate

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

RecordSerializerTests.swift (6578B)


      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     // MARK: - Per-game zone
     29 
     30     @Test("zoneID(for:) uses game-<UUID> as zone name")
     31     func perGameZoneName() {
     32         let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")!
     33         let zone = RecordSerializer.zoneID(for: id)
     34         #expect(zone.zoneName == "game-12345678-1234-1234-1234-123456789ABC")
     35         #expect(zone.ownerName == CKCurrentUserDefaultName)
     36     }
     37 
     38     @Test("zoneID(for:ownerName:) accepts explicit owner")
     39     func perGameZoneExplicitOwner() {
     40         let id = UUID()
     41         let zone = RecordSerializer.zoneID(for: id, ownerName: "alice_record_id")
     42         #expect(zone.ownerName == "alice_record_id")
     43     }
     44 
     45     // MARK: - Player round-trip
     46 
     47     @Test("recordName(forPlayerInGame:authorID:) uses expected format")
     48     func playerRecordNameFormat() {
     49         let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")!
     50         let name = RecordSerializer.recordName(forPlayerInGame: id, authorID: "_abc")
     51         #expect(name == "player-12345678-1234-1234-1234-123456789ABC-_abc")
     52     }
     53 
     54     @Test("parsePlayerRecordName splits gameID and authorID")
     55     func parsePlayerRecordRoundTrip() {
     56         let gameID = UUID()
     57         let authorID = "_someAuthorID"
     58         let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
     59         let parsed = RecordSerializer.parsePlayerRecordName(recordName)
     60         #expect(parsed?.0 == gameID)
     61         #expect(parsed?.1 == authorID)
     62     }
     63 
     64     @Test("parsePlayerRecordName rejects malformed names")
     65     func parsePlayerRecordRejectsBadInput() {
     66         #expect(RecordSerializer.parsePlayerRecordName("game-foo") == nil)
     67         #expect(RecordSerializer.parsePlayerRecordName("player-not-a-uuid") == nil)
     68         #expect(RecordSerializer.parsePlayerRecordName("player-12345678-1234-1234-1234-123456789ABC") == nil)
     69     }
     70 
     71     // MARK: - applyGameRecord
     72 
     73     /// Writes `source` to a temp file and returns a `CKAsset` pointing to it.
     74     /// The caller is responsible for removing the file when done.
     75     private func makePuzzleAsset(source: String = "dummy puzzle source") throws -> (CKAsset, URL) {
     76         let url = FileManager.default.temporaryDirectory
     77             .appendingPathComponent(UUID().uuidString)
     78         try source.write(to: url, atomically: true, encoding: .utf8)
     79         return (CKAsset(fileURL: url), url)
     80     }
     81 
     82     @Test("applyGameRecord creates entity with id derived from record name")
     83     @MainActor func applyGameRecordCreatesEntity() throws {
     84         let persistence = makeTestPersistence()
     85         let ctx = persistence.viewContext
     86         let gameID = UUID()
     87         let zone = RecordSerializer.zoneID(for: gameID)
     88         let recordName = RecordSerializer.recordName(forGameID: gameID)
     89         let record = CKRecord(recordType: "Game", recordID: CKRecord.ID(recordName: recordName, zoneID: zone))
     90         record["title"] = "Test Title" as CKRecordValue
     91         let (asset, tmpURL) = try makePuzzleAsset()
     92         defer { try? FileManager.default.removeItem(at: tmpURL) }
     93         record["puzzleSource"] = asset as CKRecordValue
     94 
     95         let entity = RecordSerializer.applyGameRecord(record, to: ctx)
     96         try ctx.save()
     97 
     98         #expect(entity.id == gameID)
     99         #expect(entity.title == "Test Title")
    100         #expect(entity.ckRecordName == recordName)
    101     }
    102 
    103     @Test("applyGameRecord preserves id and createdAt on second apply, updates title")
    104     @MainActor func applyGameRecordMergesOnServerRecordChanged() throws {
    105         let persistence = makeTestPersistence()
    106         let ctx = persistence.viewContext
    107         let gameID = UUID()
    108         let zone = RecordSerializer.zoneID(for: gameID)
    109         let recordName = RecordSerializer.recordName(forGameID: gameID)
    110         let recordID = CKRecord.ID(recordName: recordName, zoneID: zone)
    111 
    112         let (asset1, tmpURL1) = try makePuzzleAsset(source: "original source")
    113         defer { try? FileManager.default.removeItem(at: tmpURL1) }
    114 
    115         // First apply — creates the entity.
    116         let record1 = CKRecord(recordType: "Game", recordID: recordID)
    117         record1["title"] = "Original" as CKRecordValue
    118         record1["puzzleSource"] = asset1 as CKRecordValue
    119         let entity = RecordSerializer.applyGameRecord(record1, to: ctx)
    120         try ctx.save()
    121 
    122         let frozenID = entity.id
    123         let frozenCreatedAt = entity.createdAt
    124 
    125         // Second apply — simulates a server record change with an updated title.
    126         // puzzleSource is intentionally absent here to verify it isn't wiped.
    127         let record2 = CKRecord(recordType: "Game", recordID: recordID)
    128         record2["title"] = "Updated" as CKRecordValue
    129         let merged = RecordSerializer.applyGameRecord(record2, to: ctx)
    130         try ctx.save()
    131 
    132         #expect(merged === entity)               // same managed object
    133         #expect(merged.id == frozenID)           // id not overwritten
    134         #expect(merged.createdAt == frozenCreatedAt) // createdAt not overwritten
    135         #expect(merged.title == "Updated")       // mutable field updated
    136     }
    137 
    138     // MARK: - System fields round-trip
    139 
    140     @Test("Encode and decode system fields preserves record type and zone")
    141     func systemFieldsRoundTrip() {
    142         let gameID = UUID()
    143         let zoneID = RecordSerializer.zoneID(for: gameID)
    144         let recordID = CKRecord.ID(recordName: "test-record", zoneID: zoneID)
    145         let original = CKRecord(recordType: "Cell", recordID: recordID)
    146 
    147         let encoded = RecordSerializer.encodeSystemFields(of: original)
    148         #expect(encoded != nil)
    149 
    150         let decoded = RecordSerializer.decodeRecord(from: encoded!)
    151         #expect(decoded != nil)
    152         #expect(decoded?.recordType == "Cell")
    153         #expect(decoded?.recordID.zoneID.zoneName == "game-\(gameID.uuidString)")
    154         #expect(decoded?.recordID.recordName == "test-record")
    155     }
    156 }