diff --git a/test/core/model/save_data_roundtrip_test.dart b/test/core/model/save_data_roundtrip_test.dart new file mode 100644 index 0000000..e7ed4d7 --- /dev/null +++ b/test/core/model/save_data_roundtrip_test.dart @@ -0,0 +1,568 @@ +import 'dart:collection'; +import 'dart:convert'; + +import 'package:asciineverdie/src/core/model/equipment_container.dart'; +import 'package:asciineverdie/src/core/model/equipment_item.dart'; +import 'package:asciineverdie/src/core/model/equipment_slot.dart'; +import 'package:asciineverdie/src/core/model/item_stats.dart'; +import 'package:asciineverdie/src/core/model/monetization_state.dart'; +import 'package:asciineverdie/src/core/model/monster_grade.dart'; +import 'package:asciineverdie/src/core/model/save_data.dart'; +import 'package:asciineverdie/src/core/model/game_state.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('GameSave 직렬화(serialization) 라운드트립', () { + // ========================================================================= + // 헬퍼: 모든 필드가 채워진 GameSave 생성 + // ========================================================================= + + GameSave _createFullSave() { + return GameSave( + version: kSaveVersion, + rngState: 12345, + cheatsEnabled: true, + traits: const Traits( + name: 'TestHero', + race: 'Kernel Giant', + klass: 'Bug Hunter', + level: 42, + motto: 'Never Die', + guild: 'ASCII Warriors', + raceId: 'kernel_giant', + classId: 'bug_hunter', + ), + stats: const Stats( + str: 18, + con: 16, + dex: 14, + intelligence: 12, + wis: 10, + cha: 8, + hpMax: 200, + mpMax: 100, + hpCurrent: 150, + mpCurrent: 75, + ), + inventory: const Inventory( + gold: 9999, + items: [ + InventoryEntry(name: 'Goblin Ear', count: 5), + InventoryEntry(name: 'Dragon Scale', count: 1), + ], + ), + equipment: Equipment( + items: [ + const EquipmentItem( + name: 'Flaming Sword', + slot: EquipmentSlot.weapon, + level: 10, + weight: 15, + stats: ItemStats(atk: 50, criRate: 0.1), + rarity: ItemRarity.epic, + ), + const EquipmentItem( + name: 'Tower Shield', + slot: EquipmentSlot.shield, + level: 8, + weight: 20, + stats: ItemStats(def: 30, blockRate: 0.2), + rarity: ItemRarity.rare, + ), + EquipmentItem.empty(EquipmentSlot.helm), + EquipmentItem.empty(EquipmentSlot.hauberk), + EquipmentItem.empty(EquipmentSlot.brassairts), + EquipmentItem.empty(EquipmentSlot.vambraces), + EquipmentItem.empty(EquipmentSlot.gauntlets), + EquipmentItem.empty(EquipmentSlot.gambeson), + EquipmentItem.empty(EquipmentSlot.cuisses), + EquipmentItem.empty(EquipmentSlot.greaves), + EquipmentItem.empty(EquipmentSlot.sollerets), + ], + bestIndex: 0, + ), + skillBook: const SkillBook( + skills: [ + SkillEntry(name: 'Hack', rank: 'III'), + SkillEntry(name: 'Slash', rank: 'V'), + ], + ), + progress: ProgressState( + task: const ProgressBarState(position: 50, max: 100), + quest: const ProgressBarState(position: 3, max: 10), + plot: const ProgressBarState(position: 1, max: 5), + exp: const ProgressBarState(position: 500, max: 1000), + encumbrance: const ProgressBarState(position: 30, max: 100), + currentTask: const TaskInfo( + caption: 'Executing a Goblin', + type: TaskType.kill, + monsterBaseName: 'Goblin', + monsterPart: 'ear', + monsterLevel: 5, + monsterGrade: MonsterGrade.elite, + ), + plotStageCount: 3, + questCount: 7, + plotHistory: const [ + HistoryEntry(caption: 'Prologue', isComplete: true), + HistoryEntry(caption: 'Act I', isComplete: false), + ], + questHistory: const [ + HistoryEntry(caption: 'Exterminate Goblins', isComplete: true), + ], + currentQuestMonster: const QuestMonsterInfo( + monsterData: 'Goblin|3|ear', + monsterIndex: 2, + ), + monstersKilled: 150, + deathCount: 3, + ), + queue: QueueState( + entries: Queue.from(const [ + QueueEntry( + kind: QueueKind.task, + durationMillis: 2000, + caption: 'Heading to market', + taskType: TaskType.market, + ), + ]), + ), + monetization: const MonetizationState( + adRemovalPurchased: true, + rollsRemaining: 3, + undoRemaining: 2, + pendingChests: 4, + autoReviveEndMs: 50000, + speedBoostEndMs: 80000, + luckyCharmEndMs: 60000, + ), + ); + } + + // ========================================================================= + // toJson → fromJson 라운드트립 테스트 + // ========================================================================= + + test('toJson() → fromJson() 라운드트립 — 모든 필드 보존', () { + final original = _createFullSave(); + // jsonEncode → jsonDecode로 순수 Map 변환 (freezed 객체 제거) + final json = jsonDecode(jsonEncode(original.toJson())) + as Map; + final restored = GameSave.fromJson(json); + + // traits 검증 + expect(restored.traits.name, equals('TestHero')); + expect(restored.traits.race, equals('Kernel Giant')); + expect(restored.traits.klass, equals('Bug Hunter')); + expect(restored.traits.level, equals(42)); + expect(restored.traits.motto, equals('Never Die')); + expect(restored.traits.guild, equals('ASCII Warriors')); + expect(restored.traits.raceId, equals('kernel_giant')); + expect(restored.traits.classId, equals('bug_hunter')); + + // stats 검증 + expect(restored.stats.str, equals(18)); + expect(restored.stats.con, equals(16)); + expect(restored.stats.dex, equals(14)); + expect(restored.stats.intelligence, equals(12)); + expect(restored.stats.wis, equals(10)); + expect(restored.stats.cha, equals(8)); + expect(restored.stats.hpMax, equals(200)); + expect(restored.stats.mpMax, equals(100)); + expect(restored.stats.hpCurrent, equals(150)); + expect(restored.stats.mpCurrent, equals(75)); + + // inventory 검증 + expect(restored.inventory.gold, equals(9999)); + expect(restored.inventory.items.length, equals(2)); + expect(restored.inventory.items[0].name, equals('Goblin Ear')); + expect(restored.inventory.items[0].count, equals(5)); + expect(restored.inventory.items[1].name, equals('Dragon Scale')); + expect(restored.inventory.items[1].count, equals(1)); + + // equipment 검증 + expect(restored.equipment.weapon, equals('Flaming Sword')); + expect(restored.equipment.shield, equals('Tower Shield')); + expect(restored.equipment.bestIndex, equals(0)); + expect( + restored.equipment.weaponItem.rarity, + equals(ItemRarity.epic), + ); + expect(restored.equipment.weaponItem.level, equals(10)); + expect(restored.equipment.weaponItem.stats.atk, equals(50)); + + // skillBook 검증 + expect(restored.skillBook.skills.length, equals(2)); + expect(restored.skillBook.skills[0].name, equals('Hack')); + expect(restored.skillBook.skills[0].rank, equals('III')); + + // progress 검증 + expect(restored.progress.task.position, equals(50)); + expect(restored.progress.task.max, equals(100)); + expect(restored.progress.quest.position, equals(3)); + expect(restored.progress.exp.position, equals(500)); + expect(restored.progress.currentTask.caption, equals('Executing a Goblin')); + expect(restored.progress.currentTask.type, equals(TaskType.kill)); + expect( + restored.progress.currentTask.monsterGrade, + equals(MonsterGrade.elite), + ); + expect(restored.progress.plotStageCount, equals(3)); + expect(restored.progress.questCount, equals(7)); + expect(restored.progress.plotHistory.length, equals(2)); + expect(restored.progress.plotHistory[0].isComplete, isTrue); + expect(restored.progress.questHistory.length, equals(1)); + expect(restored.progress.currentQuestMonster, isNotNull); + expect( + restored.progress.currentQuestMonster!.monsterData, + equals('Goblin|3|ear'), + ); + expect(restored.progress.monstersKilled, equals(150)); + expect(restored.progress.deathCount, equals(3)); + + // queue 검증 + expect(restored.queue.entries.length, equals(1)); + expect(restored.queue.entries.first.kind, equals(QueueKind.task)); + expect(restored.queue.entries.first.durationMillis, equals(2000)); + expect(restored.queue.entries.first.taskType, equals(TaskType.market)); + + // monetization 검증 + expect(restored.monetization, isNotNull); + expect(restored.monetization!.adRemovalPurchased, isTrue); + expect(restored.monetization!.rollsRemaining, equals(3)); + expect(restored.monetization!.undoRemaining, equals(2)); + expect(restored.monetization!.pendingChests, equals(4)); + expect(restored.monetization!.autoReviveEndMs, equals(50000)); + expect(restored.monetization!.speedBoostEndMs, equals(80000)); + expect(restored.monetization!.luckyCharmEndMs, equals(60000)); + + // 메타(meta) 검증 + expect(restored.version, equals(kSaveVersion)); + expect(restored.rngState, equals(12345)); + expect(restored.cheatsEnabled, isTrue); + }); + + // ========================================================================= + // v2→v4 마이그레이션(migration) 테스트 — 레거시 장비 문자열 → 새 형식 + // ========================================================================= + + group('v2→v4 마이그레이션', () { + test('레거시 장비 문자열이 EquipmentItem으로 변환', () { + final legacyJson = { + 'version': 2, + 'rng': 100, + 'traits': {'name': 'OldHero', 'race': 'Elf', 'klass': 'Mage'}, + 'stats': {'str': 10, 'con': 10, 'dex': 10, 'int': 10, 'wis': 10, 'cha': 10}, + 'inventory': {'gold': 500, 'items': []}, + 'equipment': { + 'weapon': 'Ancient Sword', + 'shield': 'Wooden Shield', + 'helm': 'Iron Helm', + 'hauberk': '', + 'brassairts': '', + 'vambraces': '', + 'gauntlets': '', + 'gambeson': '', + 'cuisses': '', + 'greaves': '', + 'sollerets': '', + 'bestIndex': 0, + }, + 'skills': [], + 'progress': { + 'task': {'pos': 0, 'max': 1}, + 'quest': {'pos': 0, 'max': 1}, + 'plot': {'pos': 0, 'max': 1}, + 'exp': {'pos': 0, 'max': 1}, + 'encumbrance': {'pos': 0, 'max': 1}, + }, + 'queue': [], + }; + + final restored = GameSave.fromJson(legacyJson); + + // 레거시 장비 문자열 → EquipmentItem 변환 확인 + expect(restored.equipment.weapon, equals('Ancient Sword')); + expect(restored.equipment.shield, equals('Wooden Shield')); + expect(restored.equipment.helm, equals('Iron Helm')); + // 빈 슬롯은 빈 이름으로 유지 + expect(restored.equipment.hauberk, isEmpty); + + // 레거시 아이템은 level 1, common으로 변환 + expect(restored.equipment.weaponItem.level, equals(1)); + expect(restored.equipment.weaponItem.rarity, equals(ItemRarity.common)); + expect(restored.equipment.weaponItem.slot, equals(EquipmentSlot.weapon)); + + // 버전 정보 보존 + expect(restored.version, equals(2)); + }); + + test('v2 세이브에 monetization 없으면 null', () { + // jsonDecode로 순수 Map 생성 (타입 캐스팅 호환) + final legacyJson = jsonDecode(jsonEncode({ + 'version': 2, + 'rng': 0, + 'traits': {}, + 'stats': {}, + 'inventory': {'gold': 0, 'items': []}, + 'equipment': { + 'weapon': 'Keyboard', + 'shield': '', + 'helm': '', + 'hauberk': '', + 'brassairts': '', + 'vambraces': '', + 'gauntlets': '', + 'gambeson': '', + 'cuisses': '', + 'greaves': '', + 'sollerets': '', + 'bestIndex': 0, + }, + 'skills': [], + 'progress': {}, + 'queue': [], + })) as Map; + + final restored = GameSave.fromJson(legacyJson); + + expect(restored.monetization, isNull); + }); + }); + + // ========================================================================= + // 누락된 JSON 키(missing keys)에 대한 기본값(default) 처리 + // ========================================================================= + + group('누락된 JSON 키 기본값 처리', () { + test('완전히 빈 JSON에서 기본값으로 GameSave 생성', () { + final emptyJson = {}; + + final restored = GameSave.fromJson(emptyJson); + + // 기본값 확인 + expect(restored.version, equals(kSaveVersion)); + expect(restored.rngState, equals(0)); + expect(restored.cheatsEnabled, isFalse); + expect(restored.traits.name, isEmpty); + expect(restored.traits.level, equals(1)); + expect(restored.stats.str, equals(0)); + expect(restored.stats.hpMax, equals(0)); + expect(restored.inventory.gold, equals(0)); + expect(restored.inventory.items, isEmpty); + expect(restored.skillBook.skills, isEmpty); + expect(restored.queue.entries, isEmpty); + expect(restored.monetization, isNull); + }); + + test('traits 키만 있고 내부 필드 누락 시 기본값', () { + final json = { + 'traits': {'name': 'OnlyName'}, + }; + + final restored = GameSave.fromJson(json); + + expect(restored.traits.name, equals('OnlyName')); + expect(restored.traits.race, isEmpty); + expect(restored.traits.klass, isEmpty); + expect(restored.traits.level, equals(1)); + expect(restored.traits.raceId, isEmpty); + expect(restored.traits.classId, isEmpty); + }); + + test('stats에 hpCurrent/mpCurrent 누락 시 null', () { + final json = { + 'stats': {'str': 10, 'hpMax': 50, 'mpMax': 30}, + }; + + final restored = GameSave.fromJson(json); + + expect(restored.stats.str, equals(10)); + expect(restored.stats.hpMax, equals(50)); + expect(restored.stats.hpCurrent, isNull); + expect(restored.stats.mpCurrent, isNull); + }); + + test('progress 키 누락 시 기본 ProgressBarState', () { + final json = { + 'progress': {'plotStages': 5, 'questCount': 10}, + }; + + final restored = GameSave.fromJson(json); + + expect(restored.progress.task.position, equals(0)); + expect(restored.progress.task.max, equals(1)); + expect(restored.progress.plotStageCount, equals(5)); + expect(restored.progress.questCount, equals(10)); + }); + + test('알 수 없는 TaskType/QueueKind는 기본값으로 대체(fallback)', () { + final json = { + 'progress': { + 'task': {'pos': 0, 'max': 1}, + 'quest': {'pos': 0, 'max': 1}, + 'plot': {'pos': 0, 'max': 1}, + 'exp': {'pos': 0, 'max': 1}, + 'encumbrance': {'pos': 0, 'max': 1}, + 'taskInfo': { + 'caption': 'Unknown task', + 'type': 'nonexistent_type', + }, + }, + 'queue': [ + { + 'kind': 'nonexistent_kind', + 'duration': 1000, + 'caption': 'Unknown', + 'taskType': 'nonexistent_task_type', + }, + ], + }; + + final restored = GameSave.fromJson(json); + + // 알 수 없는 enum 값은 기본값으로 대체 + expect(restored.progress.currentTask.type, equals(TaskType.neutral)); + expect(restored.queue.entries.first.kind, equals(QueueKind.task)); + expect( + restored.queue.entries.first.taskType, + equals(TaskType.neutral), + ); + }); + }); + + // ========================================================================= + // null/빈 배열(empty array) 처리 테스트 + // ========================================================================= + + group('null/빈 배열 처리', () { + test('inventory.items가 빈 배열이면 빈 리스트', () { + final json = { + 'inventory': {'gold': 100, 'items': []}, + }; + + final restored = GameSave.fromJson(json); + + expect(restored.inventory.items, isEmpty); + expect(restored.inventory.gold, equals(100)); + }); + + test('skills가 빈 배열이면 빈 스킬북', () { + final json = { + 'skills': [], + }; + + final restored = GameSave.fromJson(json); + + expect(restored.skillBook.skills, isEmpty); + }); + + test('plotHistory/questHistory null이면 빈 리스트', () { + final json = { + 'progress': { + 'task': {'pos': 0, 'max': 1}, + 'quest': {'pos': 0, 'max': 1}, + 'plot': {'pos': 0, 'max': 1}, + 'exp': {'pos': 0, 'max': 1}, + 'encumbrance': {'pos': 0, 'max': 1}, + 'plotHistory': null, + 'questHistory': null, + }, + }; + + final restored = GameSave.fromJson(json); + + expect(restored.progress.plotHistory, isEmpty); + expect(restored.progress.questHistory, isEmpty); + }); + + test('questMonster null이면 null 유지', () { + final json = { + 'progress': { + 'task': {'pos': 0, 'max': 1}, + 'quest': {'pos': 0, 'max': 1}, + 'plot': {'pos': 0, 'max': 1}, + 'exp': {'pos': 0, 'max': 1}, + 'encumbrance': {'pos': 0, 'max': 1}, + 'questMonster': null, + }, + }; + + final restored = GameSave.fromJson(json); + + expect(restored.progress.currentQuestMonster, isNull); + }); + + test('queue가 빈 배열이면 빈 큐', () { + final json = { + 'queue': [], + }; + + final restored = GameSave.fromJson(json); + + expect(restored.queue.entries, isEmpty); + }); + + test('monetization null이면 null 유지', () { + final json = { + 'monetization': null, + }; + + final restored = GameSave.fromJson(json); + + expect(restored.monetization, isNull); + }); + + test('monsterGrade null이면 null 유지 (neutral 타입)', () { + final json = { + 'progress': { + 'task': {'pos': 0, 'max': 1}, + 'quest': {'pos': 0, 'max': 1}, + 'plot': {'pos': 0, 'max': 1}, + 'exp': {'pos': 0, 'max': 1}, + 'encumbrance': {'pos': 0, 'max': 1}, + 'taskInfo': { + 'caption': 'Walking', + 'type': 'neutral', + 'monsterGrade': null, + }, + }, + }; + + final restored = GameSave.fromJson(json); + + expect(restored.progress.currentTask.monsterGrade, isNull); + }); + }); + + // ========================================================================= + // toState() 변환 테스트 + // ========================================================================= + + test('toState()로 GameState 변환 시 DeterministicRandom 상태 보존', () { + final save = GameSave( + version: kSaveVersion, + rngState: 42, + traits: const Traits( + name: 'Test', + race: 'Human', + klass: 'Warrior', + level: 1, + motto: '', + guild: '', + ), + stats: Stats.empty(), + inventory: Inventory.empty(), + equipment: Equipment.empty(), + skillBook: SkillBook.empty(), + progress: ProgressState.empty(), + queue: QueueState.empty(), + ); + + final state = save.toState(); + + expect(state.rng.state, equals(42)); + expect(state.traits.name, equals('Test')); + }); + }); +} diff --git a/test/core/storage/save_integrity_test.dart b/test/core/storage/save_integrity_test.dart new file mode 100644 index 0000000..fbea24c --- /dev/null +++ b/test/core/storage/save_integrity_test.dart @@ -0,0 +1,191 @@ +import 'dart:typed_data'; + +import 'package:asciineverdie/src/core/storage/save_integrity.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('SaveIntegrity', () { + // ========================================================================= + // sign() → verify() 라운드트립(roundtrip) 테스트 + // ========================================================================= + + group('sign → verify 라운드트립', () { + test('서명 후 검증 성공 — 원본 데이터 보존', () { + final original = Uint8List.fromList([10, 20, 30, 40, 50]); + final signed = SaveIntegrity.sign(original); + + // 서명된 데이터 = HMAC(32바이트) + 원본 + expect(signed.length, equals(SaveIntegrity.hmacLength + original.length)); + + final result = SaveIntegrity.verify(signed); + + expect(result.isLegacy, isFalse); + expect(result.gzipBytes, equals(original)); + }); + + test('큰 데이터에도 라운드트립 성공', () { + // 1KB 데이터 + final original = Uint8List.fromList( + List.generate(1024, (i) => i % 256), + ); + final signed = SaveIntegrity.sign(original); + + final result = SaveIntegrity.verify(signed); + + expect(result.isLegacy, isFalse); + expect(result.gzipBytes, equals(original)); + }); + }); + + // ========================================================================= + // 변조(tampered) 데이터 검증 실패 테스트 + // ========================================================================= + + group('변조된 데이터 verify 실패', () { + test('데이터 영역 변조 시 SaveIntegrityException 발생', () { + final original = Uint8List.fromList([10, 20, 30, 40, 50]); + final signed = SaveIntegrity.sign(original); + + // 데이터 영역(HMAC 이후) 변조 + signed[SaveIntegrity.hmacLength] = 0xFF; + + expect( + () => SaveIntegrity.verify(signed), + throwsA(isA()), + ); + }); + + test('HMAC 영역 변조 시 SaveIntegrityException 발생', () { + final original = Uint8List.fromList([10, 20, 30, 40, 50]); + final signed = SaveIntegrity.sign(original); + + // HMAC 영역 변조 + signed[0] ^= 0xFF; + + expect( + () => SaveIntegrity.verify(signed), + throwsA(isA()), + ); + }); + + test('HMAC과 데이터 모두 변조 시 예외 발생', () { + final original = Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8]); + final signed = SaveIntegrity.sign(original); + + // 양쪽 변조 + signed[0] ^= 0x01; + signed[SaveIntegrity.hmacLength + 2] ^= 0x01; + + expect( + () => SaveIntegrity.verify(signed), + throwsA(isA()), + ); + }); + }); + + // ========================================================================= + // 레거시 포맷(legacy format) 감지 테스트 + // ========================================================================= + + group('레거시 포맷 감지', () { + test('GZip 매직 바이트(0x1f 0x8b)로 시작하면 isLegacy=true', () { + // GZip 매직 바이트 + 임의 데이터 + final legacyData = Uint8List.fromList([0x1f, 0x8b, 0x08, 0x00, 0x00]); + + final result = SaveIntegrity.verify(legacyData); + + expect(result.isLegacy, isTrue); + expect(result.gzipBytes, equals(legacyData)); + }); + + test('레거시 포맷은 HMAC 검증을 건너뛰고 원본 데이터 반환', () { + // GZip 매직 바이트로 시작하는 큰 데이터 + final legacyData = Uint8List.fromList([ + 0x1f, 0x8b, ...List.generate(100, (i) => i % 256), + ]); + + final result = SaveIntegrity.verify(legacyData); + + expect(result.isLegacy, isTrue); + expect(result.gzipBytes.length, equals(legacyData.length)); + }); + }); + + // ========================================================================= + // 너무 작은 데이터 verify 실패 테스트 + // ========================================================================= + + group('너무 작은 데이터', () { + test('HMAC 길이 미만 데이터 시 SaveIntegrityException 발생', () { + // HMAC 32바이트 미만이면서 GZip 매직 바이트가 아닌 데이터 + final tooSmall = Uint8List.fromList([0x00, 0x01, 0x02]); + + expect( + () => SaveIntegrity.verify(tooSmall), + throwsA(isA()), + ); + }); + + test('정확히 HMAC 길이(32바이트) 데이터 — 빈 페이로드로 검증 시도', () { + // 32바이트 = HMAC만 있고 데이터 없음 → HMAC 불일치로 실패 + final exactHmacLength = Uint8List(SaveIntegrity.hmacLength); + + // HMAC이 맞지 않으므로 예외 발생 + expect( + () => SaveIntegrity.verify(exactHmacLength), + throwsA(isA()), + ); + }); + + test('1바이트 데이터 — 레거시도 아니고 너무 작음', () { + final oneByte = Uint8List.fromList([0x42]); + + expect( + () => SaveIntegrity.verify(oneByte), + throwsA(isA()), + ); + }); + }); + + // ========================================================================= + // 빈 데이터(empty data) 처리 테스트 + // ========================================================================= + + group('빈 데이터 처리', () { + test('빈 바이트 배열 verify 시 예외 발생', () { + final empty = Uint8List(0); + + expect( + () => SaveIntegrity.verify(empty), + throwsA(isA()), + ); + }); + + test('빈 데이터에 sign() 후 verify() 라운드트립 성공', () { + // 빈 페이로드(payload)도 서명/검증 가능 + final emptyPayload = Uint8List(0); + final signed = SaveIntegrity.sign(emptyPayload); + + expect(signed.length, equals(SaveIntegrity.hmacLength)); + + final result = SaveIntegrity.verify(signed); + + expect(result.isLegacy, isFalse); + expect(result.gzipBytes, isEmpty); + }); + }); + + // ========================================================================= + // SaveIntegrityException 테스트 + // ========================================================================= + + group('SaveIntegrityException', () { + test('toString()에 메시지 포함', () { + const exception = SaveIntegrityException('테스트 메시지'); + + expect(exception.toString(), contains('테스트 메시지')); + expect(exception.message, equals('테스트 메시지')); + }); + }); + }); +}