234 lines
9.3 KiB
C#
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();
|
|
}
|
|
}
|