using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Rewards; namespace NeonSprawl.Server.Tests.Game.Contracts; public sealed class InMemoryContractOutcomeStoreTests { private const string PlayerId = "dev-local-1"; private const string InstanceId = "prototype_contract_instance_001"; private const string OutcomeRowId = "outcome-row-001"; private const string TemplateId = "prototype_contract_clear_combat_pocket"; private const string SeedBucket = "2026-06-22"; private static readonly DateTimeOffset IssuedAt = new(2026, 6, 22, 10, 0, 0, TimeSpan.Zero); private static readonly DateTimeOffset RecordedAt = new(2026, 6, 22, 12, 0, 0, TimeSpan.Zero); [Fact] public void TryAppend_ShouldPersistAndQueryByInstanceAndIdempotencyKey() { // Arrange var (instanceStore, store) = CreateStores(); SeedActiveInstance(instanceStore); var row = CreatePrototypeOutcome(); // Act var appended = store.TryAppend(row); var foundByKey = store.TryGetByIdempotencyKey(row.IdempotencyKey, out var byKey); var listed = store.GetOutcomesForInstance(InstanceId); // Assert Assert.True(appended); Assert.True(foundByKey); Assert.Equal(row.Id, byKey.Id); Assert.Single(listed); Assert.Equal("scrap_metal_bulk", listed[0].GrantedItems[0].ItemId); Assert.Equal(5, listed[0].GrantedItems[0].Quantity); Assert.Equal("salvage", listed[0].GrantedSkillXp[0].SkillId); Assert.Equal(15, listed[0].GrantedSkillXp[0].Amount); } [Fact] public void TryAppend_ShouldDenyDuplicateRowId() { // Arrange var (instanceStore, store) = CreateStores(); SeedActiveInstance(instanceStore); var row = CreatePrototypeOutcome(); Assert.True(store.TryAppend(row)); // Act var duplicateId = store.TryAppend(row with { IdempotencyKey = "other-key" }); // Assert Assert.False(duplicateId); } [Fact] public void TryAppend_ShouldDenyDuplicateIdempotencyKey() { // Arrange var (instanceStore, store) = CreateStores(); SeedActiveInstance(instanceStore); var row = CreatePrototypeOutcome(); Assert.True(store.TryAppend(row)); // Act var duplicateKey = store.TryAppend(row with { Id = "outcome-row-002" }); // Assert Assert.False(duplicateKey); } [Fact] public void TryAppend_ShouldFailClosed_WhenRequiredFieldsEmpty() { // Arrange var (instanceStore, store) = CreateStores(); SeedActiveInstance(instanceStore); var invalidRow = CreatePrototypeOutcome() with { PlayerId = " " }; // Act var appended = store.TryAppend(invalidRow); // Assert Assert.False(appended); Assert.Empty(store.GetOutcomesForInstance(InstanceId)); } [Fact] public void TryAppend_ShouldReturnFalse_WhenContractInstanceMissing() { // Arrange var (_, store) = CreateStores(); var row = CreatePrototypeOutcome(); // Act var appended = store.TryAppend(row); // Assert Assert.False(appended); Assert.Empty(store.GetOutcomesForInstance(InstanceId)); } [Fact] public void TryAppend_ShouldReturnFalse_WhenPlayerIdDoesNotMatchInstanceOwner() { // Arrange var (instanceStore, store) = CreateStores(); SeedActiveInstance(instanceStore); var row = CreatePrototypeOutcome() with { PlayerId = "other-player-xyz" }; // Act var appended = store.TryAppend(row); // Assert Assert.False(appended); Assert.Empty(store.GetOutcomesForInstance(InstanceId)); } [Fact] public async Task TryAppend_ShouldAllowOnlyOneSuccess_WhenConcurrentDuplicateIdempotencyKeysRace() { // Arrange var (instanceStore, store) = CreateStores(); SeedActiveInstance(instanceStore); var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(PlayerId, InstanceId); var baseRow = CreatePrototypeOutcome(); // Act var results = await Task.WhenAll( Enumerable.Range(0, 8).Select(i => Task.Run(() => { var row = baseRow with { Id = $"outcome-row-race-{i:D3}" }; Assert.Equal(idempotencyKey, row.IdempotencyKey); return store.TryAppend(row); }))); // Assert Assert.Equal(1, results.Count(static r => r)); Assert.Equal(7, results.Count(static r => !r)); Assert.Single(store.GetOutcomesForInstance(InstanceId)); } private static (InMemoryContractInstanceStore InstanceStore, InMemoryContractOutcomeStore OutcomeStore) CreateStores() { var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId }); var instanceStore = new InMemoryContractInstanceStore(options); return (instanceStore, new InMemoryContractOutcomeStore(instanceStore)); } private static void SeedActiveInstance(InMemoryContractInstanceStore instanceStore) { Assert.True(instanceStore.TryCreateActive( PlayerId, InstanceId, TemplateId, SeedBucket, IssuedAt, out _)); } private static ContractOutcomeRow CreatePrototypeOutcome() { var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(PlayerId, InstanceId); return new ContractOutcomeRow( OutcomeRowId, InstanceId, PlayerId, idempotencyKey, [new RewardItemGrantApplied("scrap_metal_bulk", 5)], [new RewardSkillXpGrantApplied("salvage", 15)], [], RecordedAt); } }