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 }