using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.PositionState;
/// NS-17: real PostgreSQL + HTTP; collection serializes tests that share the database.
[Collection("Postgres integration")]
public sealed class PostgresPositionStateIntegrationTests
{
private readonly PostgresWebApplicationFactory _factory;
public PostgresPositionStateIntegrationTests(PostgresWebApplicationFactory factory)
{
_factory = factory;
}
[RequirePostgresFact]
public async Task PostMove_ThenGet_ShouldReflectPersistedPositionAndSequence()
{
// Arrange
await ResetPlayerPositionTableAsync();
using var client = _factory.CreateClient();
var cmd = new MoveCommandRequest
{
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector { X = 2, Y = -1, Z = 3.5 },
};
// Act
var before = await client.GetFromJsonAsync("/game/players/dev-local-1/position");
var post = await client.PostAsJsonAsync("/game/players/dev-local-1/move", cmd);
var postBody = await post.Content.ReadFromJsonAsync();
var after = await client.GetFromJsonAsync("/game/players/dev-local-1/position");
// Assert
Assert.NotNull(before);
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
Assert.NotNull(postBody);
Assert.NotNull(after);
Assert.Equal(PositionStateResponse.CurrentSchemaVersion, postBody!.SchemaVersion);
Assert.Equal(before!.Sequence + 1, postBody.Sequence);
Assert.Equal(2, postBody.Position.X);
Assert.Equal(-1, postBody.Position.Y);
Assert.Equal(3.5, postBody.Position.Z);
Assert.Equal(postBody.Sequence, after!.Sequence);
Assert.Equal(postBody.Position.X, after.Position.X);
Assert.Equal(postBody.Position.Y, after.Position.Y);
Assert.Equal(postBody.Position.Z, after.Position.Z);
}
[RequirePostgresFact]
public async Task SecondProcess_ShouldReadLastPosition_AfterFirstFactoryDisposed()
{
// Arrange
await ResetPlayerPositionTableAsync();
var cmd = new MoveCommandRequest
{
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector { X = 10, Y = 20, Z = 30 },
};
HttpStatusCode postStatus = default;
PositionStateResponse? persisted = null;
// Act
using (var first = _factory.CreateClient())
{
var post = await first.PostAsJsonAsync("/game/players/dev-local-1/move", cmd);
postStatus = post.StatusCode;
persisted = await post.Content.ReadFromJsonAsync();
}
await using var secondFactory = new PostgresWebApplicationFactory();
using var secondClient = secondFactory.CreateClient();
var after = await secondClient.GetFromJsonAsync("/game/players/dev-local-1/position");
// Assert
Assert.Equal(HttpStatusCode.OK, postStatus);
Assert.NotNull(persisted);
Assert.NotNull(after);
Assert.Equal(10, after!.Position.X);
Assert.Equal(20, after.Position.Y);
Assert.Equal(30, after.Position.Z);
Assert.Equal(1ul, after.Sequence);
}
[RequirePostgresFact]
public async Task PostMove_ShouldReturnNotFound_WhenPlayerUnknown()
{
// Arrange
await ResetPlayerPositionTableAsync();
using var client = _factory.CreateClient();
var cmd = new MoveCommandRequest
{
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector { X = 0, Y = 0, Z = 0 },
};
// Act
var response = await client.PostAsJsonAsync("/game/players/not-a-seeded-player/move", cmd);
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
/// DDL + empty table + dev row matching the test host's (parity with startup seed).
private async Task ResetPlayerPositionTableAsync()
{
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 ddlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
if (!File.Exists(ddlPath))
{
throw new FileNotFoundException($"Test DDL not found at '{ddlPath}'.", ddlPath);
}
var ddl = await File.ReadAllTextAsync(ddlPath);
await using var conn = new NpgsqlConnection(cs);
await conn.OpenAsync();
await using (var apply = new NpgsqlCommand(ddl, conn))
{
await apply.ExecuteNonQueryAsync();
}
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position;", conn))
{
await truncate.ExecuteNonQueryAsync();
}
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
}
}