163 lines
5.7 KiB
C#
163 lines
5.7 KiB
C#
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);
|
|
}
|
|
}
|