neon-sprawl/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryPersistenceI...

162 lines
6.2 KiB
C#

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<IItemDefinitionRegistry>();
var store = firstScope.ServiceProvider.GetRequiredService<IPlayerInventoryStore>();
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<IPlayerInventoryStore>();
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<IItemDefinitionRegistry>();
var store = seedScope.ServiceProvider.GetRequiredService<IPlayerInventoryStore>();
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<IItemDefinitionRegistry>();
var store = scope.ServiceProvider.GetRequiredService<IPlayerInventoryStore>();
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<IPlayerInventoryStore>();
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<IOptions<GamePositionOptions>>().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;
}
}