neon-sprawl/server/NeonSprawl.Server.Tests/Game/Contracts/ContractInstancePersistence...

234 lines
9.3 KiB
C#

using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests.Game.PositionState;
namespace NeonSprawl.Server.Tests.Game.Contracts;
[Collection("Postgres integration")]
public sealed class ContractInstancePersistenceIntegrationTests(PostgresIntegrationHarness harness)
{
private PostgresWebApplicationFactory Factory => harness.Factory;
private const string PlayerId = "dev-local-1";
private const string InstanceId = "prototype_contract_instance_pg_001";
private const string TemplateId = "prototype_contract_clear_combat_pocket";
private const string SeedBucket = "2026-06-22";
private static readonly DateTimeOffset IssuedAt = new(2026, 6, 22, 10, 0, 0, TimeSpan.Zero);
private static readonly DateTimeOffset CompletedAt = new(2026, 6, 22, 14, 0, 0, TimeSpan.Zero);
[RequirePostgresFact]
public async Task TryCreateActive_ShouldReturnOneTrueAndRestFalse_WhenCalledConcurrently()
{
// Arrange
await ResetContractInstanceTableAsync();
const int concurrentCalls = 8;
// Act
var results = await Task.WhenAll(
Enumerable.Range(0, concurrentCalls).Select(_ => Task.Run(() =>
{
using var scope = Factory.Services.CreateScope();
var store = scope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
return store.TryCreateActive(
PlayerId,
InstanceId,
TemplateId,
SeedBucket,
IssuedAt,
out ContractInstanceState _);
})));
// 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<IContractInstanceStore>()
.TryGet(PlayerId, InstanceId, out var snapshot));
Assert.Equal(ContractInstanceStatus.Active, snapshot.Status);
Assert.Equal(TemplateId, snapshot.TemplateId);
}
[RequirePostgresFact]
public async Task TryCreateActive_ShouldAllowOnlyOneActive_WhenDistinctInstanceIdsRace()
{
// Arrange
await ResetContractInstanceTableAsync();
const int concurrentCalls = 8;
// Act
var results = await Task.WhenAll(
Enumerable.Range(0, concurrentCalls).Select(i => Task.Run(() =>
{
using var scope = Factory.Services.CreateScope();
var store = scope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
var instanceId = $"prototype_contract_instance_pg_race_{i:D3}";
return (
Success: store.TryCreateActive(
PlayerId,
instanceId,
TemplateId,
SeedBucket,
IssuedAt,
out _),
InstanceId: instanceId);
})));
// Assert
Assert.Equal(1, results.Count(static r => r.Success));
Assert.Equal(concurrentCalls - 1, results.Count(static r => !r.Success));
Assert.True(
Factory.Services.CreateScope().ServiceProvider
.GetRequiredService<IContractInstanceStore>()
.TryGetActiveForPlayer(PlayerId, out var active));
Assert.Equal(ContractInstanceStatus.Active, active.Status);
Assert.Contains(results.Where(static r => r.Success).Select(static r => r.InstanceId), id => id == active.ContractInstanceId);
}
[RequirePostgresFact]
public async Task TryMarkComplete_ShouldBeIdempotent_OnPostgres()
{
// Arrange
await ResetContractInstanceTableAsync();
using var scope = Factory.Services.CreateScope();
var store = scope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
Assert.True(store.TryCreateActive(
PlayerId,
InstanceId,
TemplateId,
SeedBucket,
IssuedAt,
out _));
// Act
var first = store.TryMarkComplete(PlayerId, InstanceId, CompletedAt, out var firstSnapshot);
var second = store.TryMarkComplete(
PlayerId,
InstanceId,
CompletedAt.AddHours(1),
out var secondSnapshot);
// Assert
Assert.True(first);
Assert.False(second);
Assert.Equal(CompletedAt, secondSnapshot.CompletedAt);
Assert.Equal(firstSnapshot.ContractInstanceId, secondSnapshot.ContractInstanceId);
}
[RequirePostgresFact]
public async Task ListForPlayer_ShouldPrependActiveAndCapCompletedRows_OnPostgres()
{
// Arrange
await ResetContractInstanceTableAsync();
using var scope = Factory.Services.CreateScope();
var store = scope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
for (var i = 0; i < 12; i++)
{
var instanceId = $"prototype_contract_instance_pg_done_{i:D2}";
Assert.True(store.TryCreateActive(
PlayerId,
instanceId,
TemplateId,
SeedBucket,
IssuedAt.AddHours(i),
out _));
Assert.True(store.TryMarkComplete(
PlayerId,
instanceId,
CompletedAt.AddHours(i),
out _));
}
const string activeId = "prototype_contract_instance_pg_active";
Assert.True(store.TryCreateActive(
PlayerId,
activeId,
TemplateId,
SeedBucket,
IssuedAt.AddDays(3),
out _));
// Act
var rows = store.ListForPlayer(PlayerId, maxCompletedRows: 10);
// Assert
Assert.Equal(11, rows.Count);
Assert.Equal(activeId, rows[0].ContractInstanceId);
Assert.Equal(ContractInstanceStatus.Active, rows[0].Status);
Assert.All(rows.Skip(1), row => Assert.Equal(ContractInstanceStatus.Completed, row.Status));
Assert.DoesNotContain(
rows.Skip(1),
row => string.Equals(row.ContractInstanceId, "prototype_contract_instance_pg_done_00", StringComparison.Ordinal));
Assert.DoesNotContain(
rows.Skip(1),
row => string.Equals(row.ContractInstanceId, "prototype_contract_instance_pg_done_01", StringComparison.Ordinal));
}
[RequirePostgresFact]
public async Task CreateComplete_ShouldPersistAcrossNewFactory()
{
// Arrange
await ResetContractInstanceTableAsync();
// Act — write through first host
using (var firstScope = Factory.Services.CreateScope())
{
var store = firstScope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
Assert.True(store.TryCreateActive(
PlayerId,
InstanceId,
TemplateId,
SeedBucket,
IssuedAt,
out _));
Assert.True(store.TryMarkComplete(PlayerId, InstanceId, CompletedAt, out _));
}
ContractInstanceState readBack;
await using var secondFactory = new PostgresWebApplicationFactory();
using var secondScope = secondFactory.Services.CreateScope();
var readStore = secondScope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
readBack = readStore.TryGet(PlayerId, InstanceId, out var snapshot)
? snapshot
: null!;
// Assert
Assert.NotNull(readBack);
Assert.Equal(ContractInstanceStatus.Completed, readBack.Status);
Assert.Equal(SeedBucket, readBack.SeedBucket);
Assert.Equal(CompletedAt, readBack.CompletedAt);
}
private async Task ResetContractInstanceTableAsync()
{
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 instanceDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V011__contract_instance.sql");
if (!File.Exists(positionDdlPath) || !File.Exists(instanceDdlPath))
{
throw new FileNotFoundException("Test DDL for contract instance persistence not found.");
}
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
var instanceDdl = await File.ReadAllTextAsync(instanceDdlPath);
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 applyInstance = new NpgsqlCommand(instanceDdl, conn);
await applyInstance.ExecuteNonQueryAsync();
}
}