using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Tests.Game.PositionState; 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(); var outcomeStore = firstScope.ServiceProvider.GetRequiredService(); 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 readOutcomeStore = secondScope.ServiceProvider.GetRequiredService(); readBack = readOutcomeStore.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(); var outcomeStore = scope.ServiceProvider.GetRequiredService(); 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(); appended = outcomeStore.TryAppend(outcomeRow); // Assert Assert.False(appended); } [RequirePostgresFact] public async Task TryAppend_ShouldReturnFalse_WhenPlayerIdDoesNotMatchInstanceOwner() { // Arrange await ResetContractTablesAsync(); const string otherPlayerId = "other-player-pg-001"; var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(otherPlayerId, InstanceId); var outcomeRow = new ContractOutcomeRow( OutcomeRowId, InstanceId, otherPlayerId, idempotencyKey, [new RewardItemGrantApplied("scrap_metal_bulk", 5)], [], [], RecordedAt); using var scope = Factory.Services.CreateScope(); var instanceStore = scope.ServiceProvider.GetRequiredService(); var outcomeStore = scope.ServiceProvider.GetRequiredService(); Assert.True(instanceStore.TryCreateActive( PlayerId, InstanceId, TemplateId, SeedBucket, IssuedAt, out _)); await SeedPostgresPlayerAsync(otherPlayerId); // Act var appended = outcomeStore.TryAppend(outcomeRow); // Assert Assert.False(appended); Assert.Empty(outcomeStore.GetOutcomesForInstance(InstanceId)); } [RequirePostgresFact] public async Task TryAppend_ShouldAllowOnlyOneSuccess_WhenConcurrentDuplicateRowsRace() { // Arrange await ResetContractTablesAsync(); var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(PlayerId, InstanceId); var outcomeRow = new ContractOutcomeRow( OutcomeRowId, InstanceId, PlayerId, idempotencyKey, [new RewardItemGrantApplied("scrap_metal_bulk", 5)], [], [], RecordedAt); using var seedScope = Factory.Services.CreateScope(); var instanceStore = seedScope.ServiceProvider.GetRequiredService(); Assert.True(instanceStore.TryCreateActive( PlayerId, InstanceId, TemplateId, SeedBucket, IssuedAt, out _)); // Act var results = await Task.WhenAll( Enumerable.Range(0, 8).Select(_ => Task.Run(() => { using var scope = Factory.Services.CreateScope(); var outcomeStore = scope.ServiceProvider.GetRequiredService(); return outcomeStore.TryAppend(outcomeRow); }))); // Assert Assert.Equal(1, results.Count(static r => r)); Assert.Equal(7, results.Count(static r => !r)); } private static async Task SeedPostgresPlayerAsync(string playerId) { var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl"); if (string.IsNullOrWhiteSpace(cs)) { throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set."); } await using var conn = new NpgsqlConnection(cs); await conn.OpenAsync(); await using var cmd = new NpgsqlCommand( """ INSERT INTO player_position (player_id, pos_x, pos_y, pos_z, sequence) VALUES (@pid, 0, 0, 0, 0) ON CONFLICT (player_id) DO NOTHING; """, conn); cmd.Parameters.AddWithValue("pid", playerId); await cmd.ExecuteNonQueryAsync(); } 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>().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(); } }