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; var 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 store = secondScope.ServiceProvider.GetRequiredService(); readBack = store.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); } 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(); } } }