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