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(); 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() .TryGet(PlayerId, InstanceId, out var snapshot)); Assert.Equal(ContractInstanceStatus.Active, snapshot.Status); Assert.Equal(TemplateId, snapshot.TemplateId); } [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(); 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(); 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>().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(); } } }