using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Tests.Game.PositionState; using Npgsql; using Xunit; namespace NeonSprawl.Server.Tests.Game.Quests; [Collection("Postgres integration")] public sealed class PlayerQuestProgressPersistenceIntegrationTests(PostgresIntegrationHarness harness) { private PostgresWebApplicationFactory Factory => harness.Factory; private const string PlayerId = "dev-local-1"; private static readonly DateTimeOffset CompletedAt = new(2026, 6, 3, 14, 30, 0, TimeSpan.Zero); [RequirePostgresFact] public async Task TryActivate_ShouldReturnOneTrueAndRestFalse_WhenCalledConcurrently() { // Arrange await ResetQuestProgressTableAsync(); const int concurrentCalls = 8; const string questId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId; // 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.TryActivate(PlayerId, questId, out QuestStepState _); }))); // 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() .TryGetProgress(PlayerId, questId, out var snapshot)); Assert.Equal(QuestProgressStatus.Active, snapshot.Status); Assert.Equal(0, snapshot.CurrentStepIndex); } [RequirePostgresFact] public async Task ActivateCounterComplete_ShouldPersistAcrossNewFactory() { // Arrange await ResetQuestProgressTableAsync(); // Act — write through first host using (var firstScope = Factory.Services.CreateScope()) { var store = firstScope.ServiceProvider.GetRequiredService(); Assert.True(store.TryActivate(PlayerId, PrototypeE7M1QuestCatalogRules.GatherIntroQuestId, out _)); Assert.True(store.TryUpdateObjectiveCounter( PlayerId, PrototypeE7M1QuestCatalogRules.GatherIntroQuestId, PrototypeE7M1QuestCatalogRules.GatherIntroFirstObjectiveId, 3, out _)); Assert.True(store.TryMarkComplete( PlayerId, PrototypeE7M1QuestCatalogRules.GatherIntroQuestId, CompletedAt, out _)); } // Act — read back through a fresh host QuestStepState readBack; await using var secondFactory = new PostgresWebApplicationFactory(); using var secondScope = secondFactory.Services.CreateScope(); var readStore = secondScope.ServiceProvider.GetRequiredService(); readBack = readStore.TryGetProgress( PlayerId, PrototypeE7M1QuestCatalogRules.GatherIntroQuestId, out var snapshot) ? snapshot : null!; // Assert Assert.NotNull(readBack); Assert.Equal(QuestProgressStatus.Completed, readBack.Status); Assert.Equal(0, readBack.CurrentStepIndex); Assert.Equal(3, readBack.ObjectiveCounters[PrototypeE7M1QuestCatalogRules.GatherIntroFirstObjectiveId]); Assert.Equal(CompletedAt, readBack.CompletedAt); } [RequirePostgresFact] public async Task TryClearProgress_ShouldRemoveRowAndPersistAcrossNewFactory() { // Arrange await ResetQuestProgressTableAsync(); const string questId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId; // Act using (var firstScope = Factory.Services.CreateScope()) { var store = firstScope.ServiceProvider.GetRequiredService(); Assert.True(store.TryActivate(PlayerId, questId, out _)); Assert.True(store.TryMarkComplete(PlayerId, questId, CompletedAt, out _)); Assert.True(store.TryClearProgress(PlayerId, questId)); Assert.False(store.TryGetProgress(PlayerId, questId, out _)); } var readBackExists = false; await using var secondFactory = new PostgresWebApplicationFactory(); using var secondScope = secondFactory.Services.CreateScope(); var readStore = secondScope.ServiceProvider.GetRequiredService(); readBackExists = readStore.TryGetProgress(PlayerId, questId, out _); // Assert Assert.False(readBackExists); } private async Task ResetQuestProgressTableAsync() { 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 questDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V008__player_quest_progress.sql"); if (!File.Exists(positionDdlPath) || !File.Exists(questDdlPath)) { throw new FileNotFoundException("Test DDL for quest progress persistence not found."); } var positionDdl = await File.ReadAllTextAsync(positionDdlPath); var questDdl = await File.ReadAllTextAsync(questDdlPath); 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 applyQuest = new NpgsqlCommand(questDdl, conn); await applyQuest.ExecuteNonQueryAsync(); } }