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

197 lines
7.7 KiB
C#

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Contracts;
[Collection("Postgres integration")]
public sealed class ContractInstancePersistenceIntegrationTests(PostgresIntegrationHarness harness)
{
private PostgresWebApplicationFactory Factory => harness.Factory;
private const string PlayerId = "dev-local-1";
private const string InstanceId = "prototype_contract_instance_pg_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 CompletedAt = new(2026, 6, 22, 14, 0, 0, TimeSpan.Zero);
[RequirePostgresFact]
public async Task TryCreateActive_ShouldReturnOneTrueAndRestFalse_WhenCalledConcurrently()
{
// Arrange
await ResetContractInstanceTableAsync();
const int concurrentCalls = 8;
// Act
var results = await Task.WhenAll(
Enumerable.Range(0, concurrentCalls).Select(_ => Task.Run(() =>
{
using var scope = Factory.Services.CreateScope();
var store = scope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
return store.TryCreateActive(
PlayerId,
InstanceId,
TemplateId,
SeedBucket,
IssuedAt,
out ContractInstanceState _);
})));
// Assert
Assert.Equal(1, results.Count(static r => r));
Assert.Equal(concurrentCalls - 1, results.Count(static r => !r));
Assert.True(
Factory.Services.CreateScope().ServiceProvider
.GetRequiredService<IContractInstanceStore>()
.TryGet(PlayerId, InstanceId, out var snapshot));
Assert.Equal(ContractInstanceStatus.Active, snapshot.Status);
Assert.Equal(TemplateId, snapshot.TemplateId);
}
[RequirePostgresFact]
public async Task TryCreateActive_ShouldAllowOnlyOneActive_WhenDistinctInstanceIdsRace()
{
// Arrange
await ResetContractInstanceTableAsync();
const int concurrentCalls = 8;
// Act
var results = await Task.WhenAll(
Enumerable.Range(0, concurrentCalls).Select(i => Task.Run(() =>
{
using var scope = Factory.Services.CreateScope();
var store = scope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
var instanceId = $"prototype_contract_instance_pg_race_{i:D3}";
return (
Success: store.TryCreateActive(
PlayerId,
instanceId,
TemplateId,
SeedBucket,
IssuedAt,
out _),
InstanceId: instanceId);
})));
// Assert
Assert.Equal(1, results.Count(static r => r.Success));
Assert.Equal(concurrentCalls - 1, results.Count(static r => !r.Success));
Assert.True(
Factory.Services.CreateScope().ServiceProvider
.GetRequiredService<IContractInstanceStore>()
.TryGetActiveForPlayer(PlayerId, out var active));
Assert.Equal(ContractInstanceStatus.Active, active.Status);
Assert.Contains(results.Where(static r => r.Success).Select(static r => r.InstanceId), id => id == active.ContractInstanceId);
}
[RequirePostgresFact]
public async Task TryMarkComplete_ShouldBeIdempotent_OnPostgres()
{
// Arrange
await ResetContractInstanceTableAsync();
using var scope = Factory.Services.CreateScope();
var store = scope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
Assert.True(store.TryCreateActive(
PlayerId,
InstanceId,
TemplateId,
SeedBucket,
IssuedAt,
out _));
// Act
var first = store.TryMarkComplete(PlayerId, InstanceId, CompletedAt, out var firstSnapshot);
var second = store.TryMarkComplete(
PlayerId,
InstanceId,
CompletedAt.AddHours(1),
out var secondSnapshot);
// Assert
Assert.True(first);
Assert.False(second);
Assert.Equal(CompletedAt, secondSnapshot.CompletedAt);
Assert.Equal(firstSnapshot.ContractInstanceId, secondSnapshot.ContractInstanceId);
}
[RequirePostgresFact]
public async Task CreateComplete_ShouldPersistAcrossNewFactory()
{
// Arrange
await ResetContractInstanceTableAsync();
// Act — write through first host
using (var firstScope = Factory.Services.CreateScope())
{
var store = firstScope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
Assert.True(store.TryCreateActive(
PlayerId,
InstanceId,
TemplateId,
SeedBucket,
IssuedAt,
out _));
Assert.True(store.TryMarkComplete(PlayerId, InstanceId, CompletedAt, out _));
}
ContractInstanceState readBack;
await using (var secondFactory = new PostgresWebApplicationFactory())
{
using var secondScope = secondFactory.Services.CreateScope();
var store = secondScope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
readBack = store.TryGet(PlayerId, InstanceId, out var snapshot)
? snapshot
: null!;
}
// Assert
Assert.NotNull(readBack);
Assert.Equal(ContractInstanceStatus.Completed, readBack.Status);
Assert.Equal(SeedBucket, readBack.SeedBucket);
Assert.Equal(CompletedAt, readBack.CompletedAt);
}
private async Task ResetContractInstanceTableAsync()
{
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");
if (!File.Exists(positionDdlPath) || !File.Exists(instanceDdlPath))
{
throw new FileNotFoundException("Test DDL for contract instance persistence not found.");
}
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
var instanceDdl = await File.ReadAllTextAsync(instanceDdlPath);
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();
}
}
}