/ GUNRPG.Tests / LiteDbOperatorEventStoreTests.cs
LiteDbOperatorEventStoreTests.cs
  1  using GUNRPG.Application.Operators;
  2  using GUNRPG.Core.Operators;
  3  using GUNRPG.Infrastructure.Persistence;
  4  using LiteDB;
  5  using Xunit;
  6  
  7  namespace GUNRPG.Tests;
  8  
  9  public class LiteDbOperatorEventStoreTests : IDisposable
 10  {
 11      private readonly LiteDatabase _database;
 12      private readonly LiteDbOperatorEventStore _store;
 13  
 14      public LiteDbOperatorEventStoreTests()
 15      {
 16          // Use in-memory database for testing with custom mapper
 17          var mapper = new BsonMapper();
 18          mapper.Entity<OperatorEventDocument>().Id(x => x.Id);
 19          _database = new LiteDatabase(":memory:", mapper);
 20          _store = new LiteDbOperatorEventStore(_database);
 21      }
 22  
 23      public void Dispose()
 24      {
 25          _database?.Dispose();
 26      }
 27  
 28      [Fact]
 29      public async Task AppendEventAsync_ShouldStoreEvent()
 30      {
 31          // Arrange
 32          var operatorId = OperatorId.NewId();
 33          var evt = new OperatorCreatedEvent(operatorId, "TestOperator");
 34  
 35          // Act
 36          await _store.AppendEventAsync(evt);
 37  
 38          // Assert
 39          var events = await _store.LoadEventsAsync(operatorId);
 40          Assert.Single(events);
 41          Assert.Equal(operatorId, events[0].OperatorId);
 42      }
 43  
 44      [Fact]
 45      public async Task AppendEventAsync_ShouldRejectDuplicateSequence()
 46      {
 47          // Arrange
 48          var operatorId = OperatorId.NewId();
 49          var evt1 = new OperatorCreatedEvent(operatorId, "TestOperator");
 50          var evt2 = new OperatorCreatedEvent(operatorId, "Duplicate"); // Same sequence
 51  
 52          // Act
 53          await _store.AppendEventAsync(evt1);
 54  
 55          // Assert
 56          var ex = await Assert.ThrowsAsync<InvalidOperationException>(
 57              () => _store.AppendEventAsync(evt2));
 58          Assert.Contains("already exists", ex.Message);
 59      }
 60  
 61      [Fact]
 62      public async Task AppendEventAsync_ShouldRejectMissingPreviousEvent()
 63      {
 64          // Arrange
 65          var operatorId = OperatorId.NewId();
 66          var evt = new XpGainedEvent(operatorId, 1, 100, "Victory", "fake_hash");
 67  
 68          // Act & Assert
 69          var ex = await Assert.ThrowsAsync<InvalidOperationException>(
 70              () => _store.AppendEventAsync(evt));
 71          Assert.Contains("Previous event not found", ex.Message);
 72      }
 73  
 74      [Fact]
 75      public async Task AppendEventAsync_ShouldRejectBrokenHashChain()
 76      {
 77          // Arrange
 78          var operatorId = OperatorId.NewId();
 79          var evt1 = new OperatorCreatedEvent(operatorId, "TestOperator");
 80          var evt2 = new XpGainedEvent(operatorId, 1, 100, "Victory", "wrong_hash");
 81  
 82          // Act
 83          await _store.AppendEventAsync(evt1);
 84  
 85          // Assert
 86          var ex = await Assert.ThrowsAsync<InvalidOperationException>(
 87              () => _store.AppendEventAsync(evt2));
 88          Assert.Contains("Hash chain broken", ex.Message);
 89      }
 90  
 91      [Fact]
 92      public async Task LoadEventsAsync_ShouldReturnEmptyForNonexistentOperator()
 93      {
 94          // Arrange
 95          var operatorId = OperatorId.NewId();
 96  
 97          // Act
 98          var events = await _store.LoadEventsAsync(operatorId);
 99  
100          // Assert
101          Assert.Empty(events);
102      }
103  
104      [Fact]
105      public async Task LoadEventsAsync_ShouldReturnEventsInOrder()
106      {
107          // Arrange
108          var operatorId = OperatorId.NewId();
109          var evt1 = new OperatorCreatedEvent(operatorId, "TestOperator");
110          var evt2 = new XpGainedEvent(operatorId, 1, 100, "Victory", evt1.Hash);
111          var evt3 = new XpGainedEvent(operatorId, 2, 50, "Survived", evt2.Hash);
112  
113          // Act
114          await _store.AppendEventAsync(evt1);
115          await _store.AppendEventAsync(evt2);
116          await _store.AppendEventAsync(evt3);
117  
118          var events = await _store.LoadEventsAsync(operatorId);
119  
120          // Assert
121          Assert.Equal(3, events.Count);
122          Assert.Equal(0, events[0].SequenceNumber);
123          Assert.Equal(1, events[1].SequenceNumber);
124          Assert.Equal(2, events[2].SequenceNumber);
125      }
126  
127      [Fact]
128      public async Task LoadEventsAsync_ShouldVerifyHashChain()
129      {
130          // Arrange
131          var operatorId = OperatorId.NewId();
132          var evt = new OperatorCreatedEvent(operatorId, "TestOperator");
133          await _store.AppendEventAsync(evt);
134  
135          // Manually corrupt the hash in the database
136          var collection = _database.GetCollection<OperatorEventDocument>("operator_events");
137          var doc = collection.FindOne(d => d.OperatorId == operatorId.Value);
138          doc.Hash = "corrupted_hash";
139          collection.Update(doc);
140  
141          // Act - Load should rollback and return empty list since first event is corrupted
142          var events = await _store.LoadEventsAsync(operatorId);
143  
144          // Assert - Should return empty list after rolling back
145          Assert.Empty(events);
146  
147          // Verify corrupted event was deleted from store
148          var docsAfterRollback = collection.FindAll().ToList();
149          Assert.Empty(docsAfterRollback);
150      }
151  
152      [Fact]
153      public async Task OperatorExistsAsync_ShouldReturnTrueForExistingOperator()
154      {
155          // Arrange
156          var operatorId = OperatorId.NewId();
157          var evt = new OperatorCreatedEvent(operatorId, "TestOperator");
158          await _store.AppendEventAsync(evt);
159  
160          // Act
161          var exists = await _store.OperatorExistsAsync(operatorId);
162  
163          // Assert
164          Assert.True(exists);
165      }
166  
167      [Fact]
168      public async Task OperatorExistsAsync_ShouldReturnFalseForNonexistentOperator()
169      {
170          // Arrange
171          var operatorId = OperatorId.NewId();
172  
173          // Act
174          var exists = await _store.OperatorExistsAsync(operatorId);
175  
176          // Assert
177          Assert.False(exists);
178      }
179  
180      [Fact]
181      public async Task GetCurrentSequenceAsync_ShouldReturnMinusOneForNonexistentOperator()
182      {
183          // Arrange
184          var operatorId = OperatorId.NewId();
185  
186          // Act
187          var sequence = await _store.GetCurrentSequenceAsync(operatorId);
188  
189          // Assert
190          Assert.Equal(-1, sequence);
191      }
192  
193      [Fact]
194      public async Task GetCurrentSequenceAsync_ShouldReturnLatestSequence()
195      {
196          // Arrange
197          var operatorId = OperatorId.NewId();
198          var evt1 = new OperatorCreatedEvent(operatorId, "TestOperator");
199          var evt2 = new XpGainedEvent(operatorId, 1, 100, "Victory", evt1.Hash);
200          var evt3 = new XpGainedEvent(operatorId, 2, 50, "Survived", evt2.Hash);
201          await _store.AppendEventAsync(evt1);
202          await _store.AppendEventAsync(evt2);
203          await _store.AppendEventAsync(evt3);
204  
205          // Act
206          var sequence = await _store.GetCurrentSequenceAsync(operatorId);
207  
208          // Assert
209          Assert.Equal(2, sequence);
210      }
211  
212      [Fact]
213      public async Task ListOperatorIdsAsync_ShouldReturnAllOperators()
214      {
215          // Arrange
216          var op1 = OperatorId.NewId();
217          var op2 = OperatorId.NewId();
218          await _store.AppendEventAsync(new OperatorCreatedEvent(op1, "Operator1"));
219          await _store.AppendEventAsync(new OperatorCreatedEvent(op2, "Operator2"));
220  
221          // Act
222          var operatorIds = await _store.ListOperatorIdsAsync();
223  
224          // Assert
225          Assert.Equal(2, operatorIds.Count);
226          Assert.Contains(op1, operatorIds);
227          Assert.Contains(op2, operatorIds);
228      }
229  
230      [Fact]
231      public async Task EventStore_ShouldSupportMultipleOperators()
232      {
233          // Arrange
234          var op1 = OperatorId.NewId();
235          var op2 = OperatorId.NewId();
236          
237          var evt1_1 = new OperatorCreatedEvent(op1, "Operator1");
238          var evt1_2 = new XpGainedEvent(op1, 1, 100, "Victory", evt1_1.Hash);
239          
240          var evt2_1 = new OperatorCreatedEvent(op2, "Operator2");
241          var evt2_2 = new XpGainedEvent(op2, 1, 50, "Survived", evt2_1.Hash);
242  
243          // Act
244          await _store.AppendEventAsync(evt1_1);
245          await _store.AppendEventAsync(evt1_2);
246          await _store.AppendEventAsync(evt2_1);
247          await _store.AppendEventAsync(evt2_2);
248  
249          var events1 = await _store.LoadEventsAsync(op1);
250          var events2 = await _store.LoadEventsAsync(op2);
251  
252          // Assert
253          Assert.Equal(2, events1.Count);
254          Assert.Equal(2, events2.Count);
255          Assert.Equal(100, ((XpGainedEvent)events1[1]).GetPayload().XpAmount);
256          Assert.Equal(50, ((XpGainedEvent)events2[1]).GetPayload().XpAmount);
257      }
258  
259      [Fact]
260      public async Task LoadEventsAsync_ShouldRollbackCorruptedEvents()
261      {
262          // Arrange
263          var operatorId = OperatorId.NewId();
264          var evt1 = new OperatorCreatedEvent(operatorId, "TestOperator");
265          var evt2 = new XpGainedEvent(operatorId, 1, 100, "Victory", evt1.Hash);
266          var evt3 = new XpGainedEvent(operatorId, 2, 50, "Survived", evt2.Hash);
267  
268          await _store.AppendEventAsync(evt1);
269          await _store.AppendEventAsync(evt2);
270          await _store.AppendEventAsync(evt3);
271  
272          // Manually corrupt an event in the database by inserting one with wrong hash
273          var collection = _database.GetCollection<OperatorEventDocument>("operator_events");
274          var evt4_corrupted = new OperatorEventDocument
275          {
276              OperatorId = operatorId.Value,
277              SequenceNumber = 3,
278              EventType = "XpGained",
279              Payload = System.Text.Json.JsonSerializer.Serialize(new { XpAmount = 25, Reason = "Bonus" }),
280              PreviousHash = "wrong_hash",
281              Hash = "also_wrong",
282              Timestamp = DateTimeOffset.UtcNow
283          };
284          collection.Insert(evt4_corrupted);
285  
286          // Act - Load should rollback corrupted event
287          var events = await _store.LoadEventsAsync(operatorId);
288  
289          // Assert - Should only return valid events up to evt3
290          Assert.Equal(3, events.Count);
291          Assert.Equal(evt1.Hash, events[0].Hash);
292          Assert.Equal(evt2.Hash, events[1].Hash);
293          Assert.Equal(evt3.Hash, events[2].Hash);
294  
295          // Verify corrupted event was deleted from store
296          var eventsAfterRollback = await _store.LoadEventsAsync(operatorId);
297          Assert.Equal(3, eventsAfterRollback.Count);
298      }
299  
300      [Fact]
301      public async Task CombatVictory_ShouldBeStoredAndLoaded()
302      {
303          // Arrange
304          var operatorId = OperatorId.NewId();
305          var evt1 = new OperatorCreatedEvent(operatorId, "TestOperator");
306          var evt2 = new CombatVictoryEvent(operatorId, 1, evt1.Hash);
307  
308          // Act
309          await _store.AppendEventAsync(evt1);
310          await _store.AppendEventAsync(evt2);
311          var events = await _store.LoadEventsAsync(operatorId);
312  
313          // Assert
314          Assert.Equal(2, events.Count);
315          Assert.IsType<CombatVictoryEvent>(events[1]);
316      }
317  
318      [Fact]
319      public async Task ExfilFailed_ShouldBeStoredAndLoaded()
320      {
321          // Arrange
322          var operatorId = OperatorId.NewId();
323          var evt1 = new OperatorCreatedEvent(operatorId, "TestOperator");
324          var evt2 = new ExfilFailedEvent(operatorId, 1, "Retreat", evt1.Hash);
325  
326          // Act
327          await _store.AppendEventAsync(evt1);
328          await _store.AppendEventAsync(evt2);
329          var events = await _store.LoadEventsAsync(operatorId);
330  
331          // Assert
332          Assert.Equal(2, events.Count);
333          Assert.IsType<ExfilFailedEvent>(events[1]);
334          Assert.Equal("Retreat", ((ExfilFailedEvent)events[1]).GetReason());
335      }
336  
337      [Fact]
338      public async Task OperatorDied_ShouldBeStoredAndLoaded()
339      {
340          // Arrange
341          var operatorId = OperatorId.NewId();
342          var evt1 = new OperatorCreatedEvent(operatorId, "TestOperator");
343          var evt2 = new OperatorDiedEvent(operatorId, 1, "Combat casualty", evt1.Hash);
344  
345          // Act
346          await _store.AppendEventAsync(evt1);
347          await _store.AppendEventAsync(evt2);
348          var events = await _store.LoadEventsAsync(operatorId);
349  
350          // Assert
351          Assert.Equal(2, events.Count);
352          Assert.IsType<OperatorDiedEvent>(events[1]);
353          Assert.Equal("Combat casualty", ((OperatorDiedEvent)events[1]).GetCauseOfDeath());
354      }
355  
356      [Fact]
357      public async Task AppendEventsAsync_ShouldAppendMultipleEventsAtomically()
358      {
359          // Arrange
360          var operatorId = OperatorId.NewId();
361          var evt1 = new OperatorCreatedEvent(operatorId, "TestOperator");
362          var evt2 = new XpGainedEvent(operatorId, 1, 100, "Victory", evt1.Hash);
363          var evt3 = new CombatVictoryEvent(operatorId, 2, evt2.Hash);
364  
365          var eventsToAppend = new List<OperatorEvent> { evt1, evt2, evt3 };
366  
367          // Act
368          await _store.AppendEventsAsync(eventsToAppend);
369  
370          // Assert
371          var events = await _store.LoadEventsAsync(operatorId);
372          Assert.Equal(3, events.Count);
373          Assert.IsType<OperatorCreatedEvent>(events[0]);
374          Assert.IsType<XpGainedEvent>(events[1]);
375          Assert.IsType<CombatVictoryEvent>(events[2]);
376      }
377  
378      [Fact]
379      public async Task AppendEventsAsync_ShouldRejectEventsFromDifferentOperators()
380      {
381          // Arrange
382          var op1 = OperatorId.NewId();
383          var op2 = OperatorId.NewId();
384          var evt1 = new OperatorCreatedEvent(op1, "Operator1");
385          var evt2 = new OperatorCreatedEvent(op2, "Operator2");
386  
387          var eventsToAppend = new List<OperatorEvent> { evt1, evt2 };
388  
389          // Act & Assert
390          var ex = await Assert.ThrowsAsync<InvalidOperationException>(
391              () => _store.AppendEventsAsync(eventsToAppend));
392          Assert.Contains("same operator", ex.Message);
393      }
394  
395      [Fact]
396      public async Task AppendEventsAsync_ShouldRejectNonSequentialEvents()
397      {
398          // Arrange
399          var operatorId = OperatorId.NewId();
400          var evt1 = new OperatorCreatedEvent(operatorId, "TestOperator");
401          var evt2 = new XpGainedEvent(operatorId, 2, 100, "Victory", evt1.Hash); // Gap in sequence
402  
403          var eventsToAppend = new List<OperatorEvent> { evt1, evt2 };
404  
405          // Act & Assert
406          var ex = await Assert.ThrowsAsync<InvalidOperationException>(
407              () => _store.AppendEventsAsync(eventsToAppend));
408          Assert.Contains("sequential order", ex.Message);
409      }
410  
411      [Fact]
412      public async Task AppendEventsAsync_ShouldBeAtomicOnFailure()
413      {
414          // Arrange
415          var operatorId = OperatorId.NewId();
416          var evt1 = new OperatorCreatedEvent(operatorId, "TestOperator");
417          await _store.AppendEventAsync(evt1); // First event already exists
418  
419          var evt2 = new XpGainedEvent(operatorId, 1, 100, "Victory", evt1.Hash);
420          var evt3 = new CombatVictoryEvent(operatorId, 2, evt2.Hash);
421          
422          // Try to append a batch that includes a duplicate sequence
423          var eventsToAppend = new List<OperatorEvent> { evt1, evt2, evt3 }; // evt1 is duplicate
424  
425          // Act & Assert
426          await Assert.ThrowsAsync<InvalidOperationException>(
427              () => _store.AppendEventsAsync(eventsToAppend));
428  
429          // Verify no events were appended (atomicity)
430          var events = await _store.LoadEventsAsync(operatorId);
431          Assert.Single(events); // Only original evt1 should exist
432      }
433  
434      // ── Account association tests ──────────────────────────────────────────────
435  
436      [Fact]
437      public async Task AssociateOperatorWithAccountAsync_ShouldSetAccountIdOnGenesisEvent()
438      {
439          // Arrange
440          var operatorId = OperatorId.NewId();
441          var accountId = Guid.NewGuid();
442          var evt = new OperatorCreatedEvent(operatorId, "TestOperator");
443          await _store.AppendEventAsync(evt);
444  
445          // Act
446          await _store.AssociateOperatorWithAccountAsync(operatorId, accountId);
447  
448          // Assert
449          var storedAccountId = await _store.GetOperatorAccountIdAsync(operatorId);
450          Assert.Equal(accountId, storedAccountId);
451      }
452  
453      [Fact]
454      public async Task AssociateOperatorWithAccountAsync_ShouldThrow_WhenOperatorNotFound()
455      {
456          // Arrange
457          var nonExistentOperatorId = OperatorId.NewId();
458          var accountId = Guid.NewGuid();
459  
460          // Act & Assert
461          var ex = await Assert.ThrowsAsync<InvalidOperationException>(
462              () => _store.AssociateOperatorWithAccountAsync(nonExistentOperatorId, accountId));
463          Assert.Contains("genesis event not found", ex.Message);
464      }
465  
466      [Fact]
467      public async Task GetOperatorAccountIdAsync_ShouldReturnNull_WhenNotAssociated()
468      {
469          // Arrange
470          var operatorId = OperatorId.NewId();
471          var evt = new OperatorCreatedEvent(operatorId, "TestOperator");
472          await _store.AppendEventAsync(evt);
473  
474          // Act
475          var accountId = await _store.GetOperatorAccountIdAsync(operatorId);
476  
477          // Assert
478          Assert.Null(accountId);
479      }
480  
481      [Fact]
482      public async Task GetOperatorAccountIdAsync_ShouldReturnNull_WhenOperatorNotFound()
483      {
484          // Arrange
485          var nonExistentOperatorId = OperatorId.NewId();
486  
487          // Act
488          var accountId = await _store.GetOperatorAccountIdAsync(nonExistentOperatorId);
489  
490          // Assert
491          Assert.Null(accountId);
492      }
493  
494      [Fact]
495      public async Task ListOperatorIdsByAccountAsync_ShouldReturnOnlyOperatorsForThatAccount()
496      {
497          // Arrange
498          var accountA = Guid.NewGuid();
499          var accountB = Guid.NewGuid();
500  
501          var opA1 = OperatorId.NewId();
502          var opA2 = OperatorId.NewId();
503          var opB1 = OperatorId.NewId();
504  
505          await _store.AppendEventAsync(new OperatorCreatedEvent(opA1, "OperatorA1"));
506          await _store.AppendEventAsync(new OperatorCreatedEvent(opA2, "OperatorA2"));
507          await _store.AppendEventAsync(new OperatorCreatedEvent(opB1, "OperatorB1"));
508  
509          await _store.AssociateOperatorWithAccountAsync(opA1, accountA);
510          await _store.AssociateOperatorWithAccountAsync(opA2, accountA);
511          await _store.AssociateOperatorWithAccountAsync(opB1, accountB);
512  
513          // Act
514          var accountAOperators = await _store.ListOperatorIdsByAccountAsync(accountA);
515          var accountBOperators = await _store.ListOperatorIdsByAccountAsync(accountB);
516  
517          // Assert
518          Assert.Equal(2, accountAOperators.Count);
519          Assert.Contains(opA1, accountAOperators);
520          Assert.Contains(opA2, accountAOperators);
521  
522          Assert.Single(accountBOperators);
523          Assert.Contains(opB1, accountBOperators);
524      }
525  
526      [Fact]
527      public async Task ListOperatorIdsByAccountAsync_ShouldReturnEmpty_ForUnknownAccount()
528      {
529          // Arrange
530          var operatorId = OperatorId.NewId();
531          await _store.AppendEventAsync(new OperatorCreatedEvent(operatorId, "TestOperator"));
532          await _store.AssociateOperatorWithAccountAsync(operatorId, Guid.NewGuid());
533  
534          // Act
535          var result = await _store.ListOperatorIdsByAccountAsync(Guid.NewGuid());
536  
537          // Assert
538          Assert.Empty(result);
539      }
540  
541      [Fact]
542      public async Task ListOperatorIdsByAccountAsync_ShouldExcludeUnassociatedOperators()
543      {
544          // Arrange
545          var accountId = Guid.NewGuid();
546          var associatedOp = OperatorId.NewId();
547          var unassociatedOp = OperatorId.NewId();
548  
549          await _store.AppendEventAsync(new OperatorCreatedEvent(associatedOp, "Associated"));
550          await _store.AppendEventAsync(new OperatorCreatedEvent(unassociatedOp, "Unassociated"));
551          await _store.AssociateOperatorWithAccountAsync(associatedOp, accountId);
552          // unassociatedOp intentionally has no AccountId
553  
554          // Act
555          var result = await _store.ListOperatorIdsByAccountAsync(accountId);
556  
557          // Assert
558          Assert.Single(result);
559          Assert.Contains(associatedOp, result);
560          Assert.DoesNotContain(unassociatedOp, result);
561      }
562  }