/ 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  }