263 lines
10 KiB
C#
263 lines
10 KiB
C#
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Options;
|
|
using NeonSprawl.Server.Game.Contracts;
|
|
using NeonSprawl.Server.Game.PositionState;
|
|
using NeonSprawl.Server.Game.Rewards;
|
|
using NeonSprawl.Server.Tests.Game.PositionState;
|
|
using Npgsql;
|
|
using Xunit;
|
|
|
|
namespace NeonSprawl.Server.Tests.Game.Contracts;
|
|
|
|
[Collection("Postgres integration")]
|
|
public sealed class ContractOutcomePersistenceIntegrationTests(PostgresIntegrationHarness harness)
|
|
{
|
|
private PostgresWebApplicationFactory Factory => harness.Factory;
|
|
|
|
private const string PlayerId = "dev-local-1";
|
|
private const string InstanceId = "prototype_contract_instance_pg_outcome_001";
|
|
private const string TemplateId = "prototype_contract_clear_combat_pocket";
|
|
private const string SeedBucket = "2026-06-22";
|
|
private const string OutcomeRowId = "outcome-row-pg-001";
|
|
private static readonly DateTimeOffset IssuedAt = new(2026, 6, 22, 10, 0, 0, TimeSpan.Zero);
|
|
private static readonly DateTimeOffset RecordedAt = new(2026, 6, 22, 14, 0, 0, TimeSpan.Zero);
|
|
|
|
[RequirePostgresFact]
|
|
public async Task AppendOutcome_ShouldPersistAcrossNewFactory()
|
|
{
|
|
// Arrange
|
|
await ResetContractTablesAsync();
|
|
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(PlayerId, InstanceId);
|
|
var outcomeRow = new ContractOutcomeRow(
|
|
OutcomeRowId,
|
|
InstanceId,
|
|
PlayerId,
|
|
idempotencyKey,
|
|
[new RewardItemGrantApplied("scrap_metal_bulk", 5)],
|
|
[new RewardSkillXpGrantApplied("salvage", 15)],
|
|
[],
|
|
RecordedAt);
|
|
|
|
// Act — write through first host
|
|
using var firstScope = Factory.Services.CreateScope();
|
|
var instanceStore = firstScope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
|
|
var outcomeStore = firstScope.ServiceProvider.GetRequiredService<IContractOutcomeStore>();
|
|
Assert.True(instanceStore.TryCreateActive(
|
|
PlayerId,
|
|
InstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt,
|
|
out _));
|
|
Assert.True(outcomeStore.TryAppend(outcomeRow));
|
|
|
|
ContractOutcomeRow readBack;
|
|
await using var secondFactory = new PostgresWebApplicationFactory();
|
|
using var secondScope = secondFactory.Services.CreateScope();
|
|
var readOutcomeStore = secondScope.ServiceProvider.GetRequiredService<IContractOutcomeStore>();
|
|
readBack = readOutcomeStore.TryGetByIdempotencyKey(idempotencyKey, out var row)
|
|
? row
|
|
: null!;
|
|
|
|
// Assert
|
|
Assert.NotNull(readBack);
|
|
Assert.Equal(OutcomeRowId, readBack.Id);
|
|
Assert.Equal(InstanceId, readBack.ContractInstanceId);
|
|
Assert.Equal(5, readBack.GrantedItems[0].Quantity);
|
|
Assert.Equal(15, readBack.GrantedSkillXp[0].Amount);
|
|
}
|
|
|
|
[RequirePostgresFact]
|
|
public async Task TryAppend_ShouldDenyDuplicateRowIdAndIdempotencyKey()
|
|
{
|
|
// Arrange
|
|
await ResetContractTablesAsync();
|
|
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(PlayerId, InstanceId);
|
|
var outcomeRow = new ContractOutcomeRow(
|
|
OutcomeRowId,
|
|
InstanceId,
|
|
PlayerId,
|
|
idempotencyKey,
|
|
[new RewardItemGrantApplied("scrap_metal_bulk", 5)],
|
|
[new RewardSkillXpGrantApplied("salvage", 15)],
|
|
[],
|
|
RecordedAt);
|
|
|
|
using var scope = Factory.Services.CreateScope();
|
|
var instanceStore = scope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
|
|
var outcomeStore = scope.ServiceProvider.GetRequiredService<IContractOutcomeStore>();
|
|
Assert.True(instanceStore.TryCreateActive(
|
|
PlayerId,
|
|
InstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt,
|
|
out _));
|
|
Assert.True(outcomeStore.TryAppend(outcomeRow));
|
|
// Act
|
|
var duplicateId = outcomeStore.TryAppend(outcomeRow with { IdempotencyKey = "other-key" });
|
|
var duplicateKey = outcomeStore.TryAppend(outcomeRow with { Id = "outcome-row-pg-002" });
|
|
// Assert
|
|
Assert.False(duplicateId);
|
|
Assert.False(duplicateKey);
|
|
}
|
|
|
|
[RequirePostgresFact]
|
|
public async Task TryAppend_ShouldReturnFalse_WhenContractInstanceMissing()
|
|
{
|
|
// Arrange
|
|
await ResetContractTablesAsync();
|
|
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(PlayerId, InstanceId);
|
|
var outcomeRow = new ContractOutcomeRow(
|
|
OutcomeRowId,
|
|
InstanceId,
|
|
PlayerId,
|
|
idempotencyKey,
|
|
[new RewardItemGrantApplied("scrap_metal_bulk", 5)],
|
|
[],
|
|
[],
|
|
RecordedAt);
|
|
// Act
|
|
bool appended;
|
|
using var scope = Factory.Services.CreateScope();
|
|
var outcomeStore = scope.ServiceProvider.GetRequiredService<IContractOutcomeStore>();
|
|
appended = outcomeStore.TryAppend(outcomeRow);
|
|
|
|
// Assert
|
|
Assert.False(appended);
|
|
}
|
|
|
|
[RequirePostgresFact]
|
|
public async Task TryAppend_ShouldReturnFalse_WhenPlayerIdDoesNotMatchInstanceOwner()
|
|
{
|
|
// Arrange
|
|
await ResetContractTablesAsync();
|
|
const string otherPlayerId = "other-player-pg-001";
|
|
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(otherPlayerId, InstanceId);
|
|
var outcomeRow = new ContractOutcomeRow(
|
|
OutcomeRowId,
|
|
InstanceId,
|
|
otherPlayerId,
|
|
idempotencyKey,
|
|
[new RewardItemGrantApplied("scrap_metal_bulk", 5)],
|
|
[],
|
|
[],
|
|
RecordedAt);
|
|
using var scope = Factory.Services.CreateScope();
|
|
var instanceStore = scope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
|
|
var outcomeStore = scope.ServiceProvider.GetRequiredService<IContractOutcomeStore>();
|
|
Assert.True(instanceStore.TryCreateActive(
|
|
PlayerId,
|
|
InstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt,
|
|
out _));
|
|
await SeedPostgresPlayerAsync(otherPlayerId);
|
|
// Act
|
|
var appended = outcomeStore.TryAppend(outcomeRow);
|
|
// Assert
|
|
Assert.False(appended);
|
|
Assert.Empty(outcomeStore.GetOutcomesForInstance(InstanceId));
|
|
}
|
|
|
|
[RequirePostgresFact]
|
|
public async Task TryAppend_ShouldAllowOnlyOneSuccess_WhenConcurrentDuplicateRowsRace()
|
|
{
|
|
// Arrange
|
|
await ResetContractTablesAsync();
|
|
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(PlayerId, InstanceId);
|
|
var outcomeRow = new ContractOutcomeRow(
|
|
OutcomeRowId,
|
|
InstanceId,
|
|
PlayerId,
|
|
idempotencyKey,
|
|
[new RewardItemGrantApplied("scrap_metal_bulk", 5)],
|
|
[],
|
|
[],
|
|
RecordedAt);
|
|
using var seedScope = Factory.Services.CreateScope();
|
|
var instanceStore = seedScope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
|
|
Assert.True(instanceStore.TryCreateActive(
|
|
PlayerId,
|
|
InstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt,
|
|
out _));
|
|
|
|
// Act
|
|
var results = await Task.WhenAll(
|
|
Enumerable.Range(0, 8).Select(_ => Task.Run(() =>
|
|
{
|
|
using var scope = Factory.Services.CreateScope();
|
|
var outcomeStore = scope.ServiceProvider.GetRequiredService<IContractOutcomeStore>();
|
|
return outcomeStore.TryAppend(outcomeRow);
|
|
})));
|
|
|
|
// Assert
|
|
Assert.Equal(1, results.Count(static r => r));
|
|
Assert.Equal(7, results.Count(static r => !r));
|
|
}
|
|
|
|
private static async Task SeedPostgresPlayerAsync(string playerId)
|
|
{
|
|
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
|
|
if (string.IsNullOrWhiteSpace(cs))
|
|
{
|
|
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
|
|
}
|
|
|
|
await using var conn = new NpgsqlConnection(cs);
|
|
await conn.OpenAsync();
|
|
await using var cmd = new NpgsqlCommand(
|
|
"""
|
|
INSERT INTO player_position (player_id, pos_x, pos_y, pos_z, sequence)
|
|
VALUES (@pid, 0, 0, 0, 0)
|
|
ON CONFLICT (player_id) DO NOTHING;
|
|
""",
|
|
conn);
|
|
cmd.Parameters.AddWithValue("pid", playerId);
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
private async Task ResetContractTablesAsync()
|
|
{
|
|
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");
|
|
var outcomeDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V012__contract_outcome.sql");
|
|
if (!File.Exists(positionDdlPath) || !File.Exists(instanceDdlPath) || !File.Exists(outcomeDdlPath))
|
|
{
|
|
throw new FileNotFoundException("Test DDL for contract outcome persistence not found.");
|
|
}
|
|
|
|
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
|
|
var instanceDdl = await File.ReadAllTextAsync(instanceDdlPath);
|
|
var outcomeDdl = await File.ReadAllTextAsync(outcomeDdlPath);
|
|
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();
|
|
|
|
await using var applyOutcome = new NpgsqlCommand(outcomeDdl, conn);
|
|
await applyOutcome.ExecuteNonQueryAsync();
|
|
}
|
|
}
|