using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Tests.Game.PositionState; 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 seedRegistry = seedScope.ServiceProvider.GetRequiredService(); var seedStore = seedScope.ServiceProvider.GetRequiredService(); for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++) { _ = PlayerInventoryOperations.TryAddStack( "dev-local-1", "survey_drone_kit", quantity: 1, seedRegistry, seedStore); } } // Act PlayerInventoryMutationOutcome denyOutcome; using (var actScope = Factory.Services.CreateScope()) { var actRegistry = actScope.ServiceProvider.GetRequiredService(); var actStore = actScope.ServiceProvider.GetRequiredService(); denyOutcome = PlayerInventoryOperations.TryAddStack( "dev-local-1", "scrap_metal_bulk", quantity: 1, actRegistry, actStore); } // 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; } }