using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Tests.Game.PositionState; using Npgsql; using Xunit; namespace NeonSprawl.Server.Tests.Game.Items; [Collection("Postgres integration")] public sealed class PlayerInventoryPersistenceIntegrationTests(PostgresIntegrationHarness harness) { private PostgresWebApplicationFactory Factory => harness.Factory; [RequirePostgresFact] public async Task TryAddStackAcrossNewFactory_ShouldPersistSnapshot() { // Arrange await ResetInventoryTableAsync(); PlayerInventoryMutationOutcome addOutcome; // Act — write through first host using (var firstScope = Factory.Services.CreateScope()) { var registry = firstScope.ServiceProvider.GetRequiredService(); var store = firstScope.ServiceProvider.GetRequiredService(); addOutcome = PlayerInventoryOperations.TryAddStack( "dev-local-1", "scrap_metal_bulk", quantity: 25, registry, store); } // Act — read back through a fresh host await using var secondFactory = new PostgresWebApplicationFactory(); using var secondScope = secondFactory.Services.CreateScope(); var readStore = secondScope.ServiceProvider.GetRequiredService(); var readBack = readStore.TryGetSnapshot("dev-local-1", out var snapshot); // Assert Assert.Equal(PlayerInventoryMutationKind.Applied, addOutcome.Kind); Assert.True(readBack); Assert.NotNull(snapshot); Assert.Equal("scrap_metal_bulk", snapshot!.BagSlots[0].ItemId); Assert.Equal(25, snapshot.BagSlots[0].Quantity); } [RequirePostgresFact] public async Task TryAddStack_WhenDenied_ShouldNotPersistPartialState() { // Arrange await ResetInventoryTableAsync(); using (var seedScope = Factory.Services.CreateScope()) { var registry = seedScope.ServiceProvider.GetRequiredService(); var store = seedScope.ServiceProvider.GetRequiredService(); for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++) { _ = PlayerInventoryOperations.TryAddStack( "dev-local-1", "survey_drone_kit", quantity: 1, registry, store); } } // Act PlayerInventoryMutationOutcome denyOutcome; using (var scope = Factory.Services.CreateScope()) { var registry = scope.ServiceProvider.GetRequiredService(); var store = scope.ServiceProvider.GetRequiredService(); denyOutcome = PlayerInventoryOperations.TryAddStack( "dev-local-1", "scrap_metal_bulk", quantity: 1, registry, store); store.TryGetSnapshot("dev-local-1", out var afterDeny); Assert.Equal(PlayerInventorySnapshot.BagSlotCount, OccupiedBagSlotCount(afterDeny!)); } // Act — verify on fresh host await using var secondFactory = new PostgresWebApplicationFactory(); using var secondScope = secondFactory.Services.CreateScope(); var readStore = secondScope.ServiceProvider.GetRequiredService(); readStore.TryGetSnapshot("dev-local-1", out var persisted); // Assert Assert.Equal(PlayerInventoryMutationKind.Denied, denyOutcome.Kind); Assert.Equal(PlayerInventoryReasonCodes.InventoryFull, denyOutcome.ReasonCode); Assert.Equal(PlayerInventorySnapshot.BagSlotCount, OccupiedBagSlotCount(persisted!)); } private async Task ResetInventoryTableAsync() { 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 inventoryDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V005__player_inventory.sql"); if (!File.Exists(positionDdlPath)) { throw new FileNotFoundException($"Test DDL not found at '{positionDdlPath}'.", positionDdlPath); } if (!File.Exists(inventoryDdlPath)) { throw new FileNotFoundException($"Test DDL not found at '{inventoryDdlPath}'.", inventoryDdlPath); } var positionDdl = await File.ReadAllTextAsync(positionDdlPath); var inventoryDdl = await File.ReadAllTextAsync(inventoryDdlPath); 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 applyInventory = new NpgsqlCommand(inventoryDdl, conn)) { await applyInventory.ExecuteNonQueryAsync(); } await using (var truncateInventory = new NpgsqlCommand("TRUNCATE player_inventory;", conn)) { await truncateInventory.ExecuteNonQueryAsync(); } } private static int OccupiedBagSlotCount(PlayerInventorySnapshot snapshot) { var count = 0; foreach (var slot in snapshot.BagSlots) { if (!slot.IsEmpty) { count++; } } return count; } }