neon-sprawl/server/NeonSprawl.Server.Tests/Game/Contracts/ContractOutcomePersistenceI...

184 lines
7.2 KiB
C#

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Tests.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Contracts;
[Collection("Postgres integration")]
public sealed class ContractOutcomePersistenceIntegrationTests(PostgresIntegrationHarness harness)
{
private PostgresWebApplicationFactory Factory => harness.Factory;
private const string PlayerId = "dev-local-1";
private const string InstanceId = "prototype_contract_instance_pg_outcome_001";
private const string TemplateId = "prototype_contract_clear_combat_pocket";
private const string SeedBucket = "2026-06-22";
private const string OutcomeRowId = "outcome-row-pg-001";
private static readonly DateTimeOffset IssuedAt = new(2026, 6, 22, 10, 0, 0, TimeSpan.Zero);
private static readonly DateTimeOffset RecordedAt = new(2026, 6, 22, 14, 0, 0, TimeSpan.Zero);
[RequirePostgresFact]
public async Task AppendOutcome_ShouldPersistAcrossNewFactory()
{
// Arrange
await ResetContractTablesAsync();
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(PlayerId, InstanceId);
var outcomeRow = new ContractOutcomeRow(
OutcomeRowId,
InstanceId,
PlayerId,
idempotencyKey,
[new RewardItemGrantApplied("scrap_metal_bulk", 5)],
[new RewardSkillXpGrantApplied("salvage", 15)],
[],
RecordedAt);
// Act — write through first host
using (var firstScope = Factory.Services.CreateScope())
{
var instanceStore = firstScope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
var outcomeStore = firstScope.ServiceProvider.GetRequiredService<IContractOutcomeStore>();
Assert.True(instanceStore.TryCreateActive(
PlayerId,
InstanceId,
TemplateId,
SeedBucket,
IssuedAt,
out _));
Assert.True(outcomeStore.TryAppend(outcomeRow));
}
ContractOutcomeRow readBack;
await using (var secondFactory = new PostgresWebApplicationFactory())
{
using var secondScope = secondFactory.Services.CreateScope();
var outcomeStore = secondScope.ServiceProvider.GetRequiredService<IContractOutcomeStore>();
readBack = outcomeStore.TryGetByIdempotencyKey(idempotencyKey, out var row)
? row
: null!;
}
// Assert
Assert.NotNull(readBack);
Assert.Equal(OutcomeRowId, readBack.Id);
Assert.Equal(InstanceId, readBack.ContractInstanceId);
Assert.Equal(5, readBack.GrantedItems[0].Quantity);
Assert.Equal(15, readBack.GrantedSkillXp[0].Amount);
}
[RequirePostgresFact]
public async Task TryAppend_ShouldDenyDuplicateRowIdAndIdempotencyKey()
{
// Arrange
await ResetContractTablesAsync();
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(PlayerId, InstanceId);
var outcomeRow = new ContractOutcomeRow(
OutcomeRowId,
InstanceId,
PlayerId,
idempotencyKey,
[new RewardItemGrantApplied("scrap_metal_bulk", 5)],
[new RewardSkillXpGrantApplied("salvage", 15)],
[],
RecordedAt);
using (var scope = Factory.Services.CreateScope())
{
var instanceStore = scope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
var outcomeStore = scope.ServiceProvider.GetRequiredService<IContractOutcomeStore>();
Assert.True(instanceStore.TryCreateActive(
PlayerId,
InstanceId,
TemplateId,
SeedBucket,
IssuedAt,
out _));
Assert.True(outcomeStore.TryAppend(outcomeRow));
// Act
var duplicateId = outcomeStore.TryAppend(outcomeRow with { IdempotencyKey = "other-key" });
var duplicateKey = outcomeStore.TryAppend(outcomeRow with { Id = "outcome-row-pg-002" });
// Assert
Assert.False(duplicateId);
Assert.False(duplicateKey);
}
}
[RequirePostgresFact]
public async Task TryAppend_ShouldReturnFalse_WhenContractInstanceMissing()
{
// Arrange
await ResetContractTablesAsync();
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(PlayerId, InstanceId);
var outcomeRow = new ContractOutcomeRow(
OutcomeRowId,
InstanceId,
PlayerId,
idempotencyKey,
[new RewardItemGrantApplied("scrap_metal_bulk", 5)],
[],
[],
RecordedAt);
// Act
bool appended;
using (var scope = Factory.Services.CreateScope())
{
var outcomeStore = scope.ServiceProvider.GetRequiredService<IContractOutcomeStore>();
appended = outcomeStore.TryAppend(outcomeRow);
}
// Assert
Assert.False(appended);
}
private async Task ResetContractTablesAsync()
{
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
if (string.IsNullOrWhiteSpace(cs))
{
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
}
_ = Factory.Services;
var options = Factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
var instanceDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V011__contract_instance.sql");
var outcomeDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V012__contract_outcome.sql");
if (!File.Exists(positionDdlPath) || !File.Exists(instanceDdlPath) || !File.Exists(outcomeDdlPath))
{
throw new FileNotFoundException("Test DDL for contract outcome persistence not found.");
}
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
var instanceDdl = await File.ReadAllTextAsync(instanceDdlPath);
var outcomeDdl = await File.ReadAllTextAsync(outcomeDdlPath);
await using var conn = new NpgsqlConnection(cs);
await conn.OpenAsync();
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
{
await applyPosition.ExecuteNonQueryAsync();
}
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
{
await truncate.ExecuteNonQueryAsync();
}
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
await using (var applyInstance = new NpgsqlCommand(instanceDdl, conn))
{
await applyInstance.ExecuteNonQueryAsync();
}
await using (var applyOutcome = new NpgsqlCommand(outcomeDdl, conn))
{
await applyOutcome.ExecuteNonQueryAsync();
}
}
}