/ GUNRPG.Tests / LiteDbCombatSessionStoreTests.cs
LiteDbCombatSessionStoreTests.cs
1 using GUNRPG.Application.Sessions; 2 using GUNRPG.Core.Combat; 3 using GUNRPG.Core.Intents; 4 using GUNRPG.Core.Operators; 5 using GUNRPG.Infrastructure.Persistence; 6 using LiteDB; 7 8 namespace GUNRPG.Tests; 9 10 public class LiteDbCombatSessionStoreTests : IDisposable 11 { 12 private static readonly TimeSpan TestOperationTimeout = TimeSpan.FromSeconds(10); 13 private readonly string _tempDbPath; 14 private readonly LiteDatabase _database; 15 private readonly LiteDbCombatSessionStore _store; 16 17 public LiteDbCombatSessionStoreTests() 18 { 19 // Create a unique temp file for each test run 20 _tempDbPath = Path.Combine(Path.GetTempPath(), $"test_combat_sessions_{Guid.NewGuid()}.db"); 21 22 // Create custom mapper for consistency with production 23 var mapper = new BsonMapper(); 24 mapper.EnumAsInteger = false; 25 mapper.Entity<CombatSessionSnapshot>().Id(x => x.Id); 26 27 _database = new LiteDatabase(_tempDbPath, mapper); 28 29 // Apply migrations as would happen in production 30 LiteDbMigrations.ApplyMigrations(_database); 31 LiteDbMigrations.SetDatabaseSchemaVersion(_database, LiteDbMigrations.CurrentSchemaVersion); 32 33 _store = new LiteDbCombatSessionStore(_database); 34 } 35 36 public void Dispose() 37 { 38 _database?.Dispose(); 39 if (File.Exists(_tempDbPath)) 40 { 41 File.Delete(_tempDbPath); 42 } 43 } 44 45 [Fact] 46 public async Task SaveAsync_PersistsSnapshot() 47 { 48 var snapshot = CreateTestSnapshot(); 49 50 await _store.SaveAsync(snapshot); 51 52 var loaded = await _store.LoadAsync(snapshot.Id); 53 Assert.NotNull(loaded); 54 Assert.Equal(snapshot.Id, loaded.Id); 55 Assert.Equal(snapshot.Phase, loaded.Phase); 56 Assert.Equal(snapshot.TurnNumber, loaded.TurnNumber); 57 } 58 59 [Fact] 60 public async Task LoadAsync_ReturnsNull_WhenNotFound() 61 { 62 var nonExistentId = Guid.NewGuid(); 63 64 var loaded = await _store.LoadAsync(nonExistentId); 65 66 Assert.Null(loaded); 67 } 68 69 [Fact] 70 public async Task SaveAsync_UpdatesExistingSnapshot() 71 { 72 var snapshot = CreateTestSnapshot(); 73 await _store.SaveAsync(snapshot); 74 75 // Update the snapshot - create a new instance with updated values 76 var updatedSnapshot = new CombatSessionSnapshot 77 { 78 Id = snapshot.Id, 79 OperatorId = snapshot.OperatorId, 80 Phase = SessionPhase.Completed, 81 TurnNumber = 5, 82 Combat = snapshot.Combat, 83 Player = snapshot.Player, 84 Enemy = snapshot.Enemy, 85 Pet = snapshot.Pet, 86 EnemyLevel = snapshot.EnemyLevel, 87 Seed = snapshot.Seed, 88 PostCombatResolved = snapshot.PostCombatResolved, 89 CreatedAt = snapshot.CreatedAt 90 }; 91 await _store.SaveAsync(updatedSnapshot); 92 93 var loaded = await _store.LoadAsync(snapshot.Id); 94 Assert.NotNull(loaded); 95 Assert.Equal(5, loaded.TurnNumber); 96 Assert.Equal(SessionPhase.Completed, loaded.Phase); 97 } 98 99 [Fact] 100 public async Task DeleteAsync_RemovesSnapshot() 101 { 102 var snapshot = CreateTestSnapshot(); 103 await _store.SaveAsync(snapshot); 104 105 await _store.DeleteAsync(snapshot.Id); 106 107 var loaded = await _store.LoadAsync(snapshot.Id); 108 Assert.Null(loaded); 109 } 110 111 [Fact] 112 public async Task DeleteAsync_DoesNotThrow_WhenNotFound() 113 { 114 var nonExistentId = Guid.NewGuid(); 115 116 var exception = await Record.ExceptionAsync(async () => await _store.DeleteAsync(nonExistentId)); 117 118 Assert.Null(exception); 119 } 120 121 [Fact] 122 public async Task ListAsync_ReturnsAllSnapshots() 123 { 124 var snapshot1 = CreateTestSnapshot(); 125 var snapshot2 = CreateTestSnapshot(); 126 await _store.SaveAsync(snapshot1); 127 await _store.SaveAsync(snapshot2); 128 129 var snapshots = await _store.ListAsync(); 130 131 Assert.Equal(2, snapshots.Count); 132 Assert.Contains(snapshots, s => s.Id == snapshot1.Id); 133 Assert.Contains(snapshots, s => s.Id == snapshot2.Id); 134 } 135 136 [Fact] 137 public async Task ListAsync_ReturnsEmpty_WhenNoSnapshots() 138 { 139 var snapshots = await _store.ListAsync(); 140 141 Assert.Empty(snapshots); 142 } 143 144 [Fact] 145 public async Task SaveAsync_PersistsComplexNestedObjects() 146 { 147 var snapshot = CreateTestSnapshot(); 148 149 await _store.SaveAsync(snapshot); 150 151 var loaded = await _store.LoadAsync(snapshot.Id); 152 Assert.NotNull(loaded); 153 154 // Verify combat state 155 Assert.NotNull(loaded.Combat); 156 Assert.Equal(snapshot.Combat.Phase, loaded.Combat.Phase); 157 Assert.Equal(snapshot.Combat.CurrentTimeMs, loaded.Combat.CurrentTimeMs); 158 159 // Verify player operator 160 Assert.NotNull(loaded.Player); 161 Assert.Equal(snapshot.Player.Name, loaded.Player.Name); 162 Assert.Equal(snapshot.Player.Health, loaded.Player.Health); 163 Assert.Equal(snapshot.Player.MovementState, loaded.Player.MovementState); 164 165 // Verify pet state 166 Assert.NotNull(loaded.Pet); 167 Assert.Equal(snapshot.Pet.Health, loaded.Pet.Health); 168 Assert.Equal(snapshot.Pet.Morale, loaded.Pet.Morale); 169 } 170 171 [Fact] 172 public async Task SaveAsync_PersistsEnums() 173 { 174 var snapshot = new CombatSessionSnapshot 175 { 176 Id = Guid.NewGuid(), 177 Phase = SessionPhase.Completed, 178 TurnNumber = 1, 179 Combat = new CombatStateSnapshot 180 { 181 Phase = CombatPhase.Ended, 182 CurrentTimeMs = 1000, 183 RandomState = new RandomStateSnapshot { Seed = 42, CallCount = 0 } 184 }, 185 Player = CreateTestOperator("Player"), 186 Enemy = CreateTestOperator("Enemy"), 187 Pet = CreateTestPet(), 188 OperatorId = Guid.NewGuid(), 189 EnemyLevel = 1, 190 Seed = 123, 191 PostCombatResolved = false, 192 CreatedAt = DateTimeOffset.UtcNow 193 }; 194 195 await _store.SaveAsync(snapshot); 196 197 var loaded = await _store.LoadAsync(snapshot.Id); 198 Assert.NotNull(loaded); 199 Assert.Equal(SessionPhase.Completed, loaded.Phase); 200 Assert.Equal(CombatPhase.Ended, loaded.Combat.Phase); 201 } 202 203 [Fact] 204 public async Task MultipleInstances_CanAccessSameDatabase() 205 { 206 var snapshot = CreateTestSnapshot(); 207 await _store.SaveAsync(snapshot); 208 209 // Create a second store instance pointing to the same database 210 using var database2 = new LiteDatabase(_tempDbPath); 211 var store2 = new LiteDbCombatSessionStore(database2); 212 213 var loaded = await store2.LoadAsync(snapshot.Id); 214 Assert.NotNull(loaded); 215 Assert.Equal(snapshot.Id, loaded.Id); 216 } 217 218 [Fact] 219 public async Task ConcurrentAccess_MultipleThreads_ThreadSafe() 220 { 221 // Test actual concurrent access from multiple threads 222 var snapshot1 = CreateTestSnapshot(); 223 var snapshot2 = CreateTestSnapshot(); 224 var snapshot3 = CreateTestSnapshot(); 225 226 // Perform concurrent write operations 227 var tasks = new[] 228 { 229 Task.Run(() => _store.SaveAsync(snapshot1)), 230 Task.Run(() => _store.SaveAsync(snapshot2)), 231 Task.Run(() => _store.SaveAsync(snapshot3)) 232 }; 233 await Task.WhenAll(tasks).WaitAsync(TestOperationTimeout); 234 235 // Verify all writes succeeded and can be read concurrently 236 var readTasks = new[] 237 { 238 Task.Run(() => _store.LoadAsync(snapshot1.Id)), 239 Task.Run(() => _store.LoadAsync(snapshot2.Id)), 240 Task.Run(() => _store.LoadAsync(snapshot3.Id)) 241 }; 242 var results = await Task.WhenAll(readTasks).WaitAsync(TestOperationTimeout); 243 244 Assert.All(results, result => Assert.NotNull(result)); 245 Assert.Contains(results, r => r!.Id == snapshot1.Id); 246 Assert.Contains(results, r => r!.Id == snapshot2.Id); 247 Assert.Contains(results, r => r!.Id == snapshot3.Id); 248 } 249 250 [Fact] 251 public void Migrations_AreAppliedOnStartup() 252 { 253 // Verify schema version is set 254 var schemaVersion = LiteDbMigrations.GetDatabaseSchemaVersion(_database); 255 Assert.Equal(LiteDbMigrations.CurrentSchemaVersion, schemaVersion); 256 } 257 258 [Fact] 259 public async Task Migrations_DoNotAffectExistingData() 260 { 261 // Save data before migration check 262 var snapshot = CreateTestSnapshot(); 263 await _store.SaveAsync(snapshot); 264 265 // Re-apply migrations (should be idempotent) 266 LiteDbMigrations.ApplyMigrations(_database); 267 LiteDbMigrations.SetDatabaseSchemaVersion(_database, LiteDbMigrations.CurrentSchemaVersion); 268 269 // Verify data still loads correctly 270 var loaded = await _store.LoadAsync(snapshot.Id); 271 Assert.NotNull(loaded); 272 Assert.Equal(snapshot.Id, loaded.Id); 273 Assert.Equal(snapshot.TurnNumber, loaded.TurnNumber); 274 } 275 276 [Fact] 277 public async Task Migrations_UpgradeFromVersion0() 278 { 279 // Create a database without version set (simulating existing database) 280 var testDbPath = Path.Combine(Path.GetTempPath(), $"test_migration_{Guid.NewGuid()}.db"); 281 try 282 { 283 // Create custom mapper for consistency with production 284 var mapper = new BsonMapper(); 285 mapper.EnumAsInteger = false; 286 mapper.Entity<CombatSessionSnapshot>().Id(x => x.Id); 287 288 using var db = new LiteDatabase(testDbPath, mapper); 289 var col = db.GetCollection<CombatSessionSnapshot>("combat_sessions"); 290 291 // Add data without setting version (version 0) 292 var snapshot = CreateTestSnapshot(); 293 col.Upsert(snapshot.Id, snapshot); 294 295 // Verify no version is set 296 Assert.Equal(0, LiteDbMigrations.GetDatabaseSchemaVersion(db)); 297 298 // Now apply migrations 299 LiteDbMigrations.ApplyMigrations(db); 300 LiteDbMigrations.SetDatabaseSchemaVersion(db, LiteDbMigrations.CurrentSchemaVersion); 301 302 // Verify version is updated 303 Assert.Equal(LiteDbMigrations.CurrentSchemaVersion, LiteDbMigrations.GetDatabaseSchemaVersion(db)); 304 305 // Verify data still exists and is accessible 306 var loaded = col.FindById(snapshot.Id); 307 Assert.NotNull(loaded); 308 Assert.Equal(snapshot.Id, loaded.Id); 309 } 310 finally 311 { 312 if (File.Exists(testDbPath)) 313 File.Delete(testDbPath); 314 } 315 } 316 317 private static CombatSessionSnapshot CreateTestSnapshot() 318 { 319 var id = Guid.NewGuid(); 320 return new CombatSessionSnapshot 321 { 322 Id = id, 323 Phase = SessionPhase.Planning, 324 TurnNumber = 1, 325 Combat = new CombatStateSnapshot 326 { 327 Phase = CombatPhase.Planning, 328 CurrentTimeMs = 0, 329 RandomState = new RandomStateSnapshot { Seed = 123, CallCount = 0 } 330 }, 331 Player = CreateTestOperator("Player"), 332 Enemy = CreateTestOperator("Enemy"), 333 Pet = CreateTestPet(), 334 OperatorId = Guid.NewGuid(), 335 EnemyLevel = 1, 336 Seed = 123, 337 PostCombatResolved = false, 338 CreatedAt = DateTimeOffset.UtcNow 339 }; 340 } 341 342 private static OperatorSnapshot CreateTestOperator(string name) 343 { 344 return new OperatorSnapshot 345 { 346 Id = Guid.NewGuid(), 347 Name = name, 348 Health = 100f, 349 MaxHealth = 100f, 350 Stamina = 100f, 351 MaxStamina = 100f, 352 MovementState = MovementState.Stationary, 353 AimState = AimState.Hip, 354 WeaponState = WeaponState.Ready, 355 CurrentMovement = MovementState.Stationary, 356 CurrentCover = CoverState.None, 357 CurrentDirection = MovementDirection.Holding, 358 CurrentAmmo = 30, 359 DistanceToOpponent = 10f 360 }; 361 } 362 363 private static PetStateSnapshot CreateTestPet() 364 { 365 return new PetStateSnapshot 366 { 367 OperatorId = Guid.NewGuid(), 368 Health = 100f, 369 Morale = 75f, 370 LastUpdated = DateTimeOffset.UtcNow 371 }; 372 } 373 }