fix(data): enforce completed portfolio sessions

This commit is contained in:
2026-06-01 15:04:20 +03:00
parent f493836b77
commit da0a306340
7 changed files with 336 additions and 75 deletions
@@ -93,8 +93,10 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
}
[Fact]
public async Task ConcurrentPublishAndLinkDelete_ShouldNotDeadlockOrCommitInvalidPublicCard()
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task ConcurrentPublishAndLinkDelete_ShouldNotDeadlockOrCommitInvalidPublicCard(bool publishCommitsFirst)
{
var database = await fixture.CreateMigratedDatabaseAsync();
await using var publishConnection = await database.OpenConnectionAsync();
@@ -121,17 +123,27 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
"DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId",
deleteTransaction,
new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
await deleteTransaction.CommitAsync().WaitAsync(CommandTimeout);
var exception = await Assert.ThrowsAsync<PostgresException>(
() => publishTransaction.CommitAsync().WaitAsync(CommandTimeout));
Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState);
await AcquirePortfolioValidationLockAsync(
publishCommitsFirst ? publishConnection : deleteConnection,
publishCommitsFirst ? publishTransaction : deleteTransaction);
var commitStates = await Task.WhenAll(
CommitAndCaptureSqlStateAsync(publishTransaction),
CommitAndCaptureSqlStateAsync(deleteTransaction)).WaitAsync(CommandTimeout);
Assert.Equal(publishCommitsFirst ? null : PostgresErrorCodes.CheckViolation, commitStates[0]);
Assert.Equal(publishCommitsFirst ? PostgresErrorCodes.CheckViolation : null, commitStates[1]);
await using var verificationConnection = await database.OpenConnectionAsync();
Assert.False(await ExecuteScalarAsync<bool>(
Assert.Equal(publishCommitsFirst, await ExecuteScalarAsync<bool>(
verificationConnection,
"SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId",
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
Assert.Equal(publishCommitsFirst ? 1L : 0L, await ExecuteScalarAsync<long>(
verificationConnection,
"SELECT COUNT(*) FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId",
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
}
[Theory]
@@ -257,8 +269,11 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
Assert.Equal(PostgresErrorCodes.FeatureNotSupported, exception.SqlState);
}
[Fact]
public async Task RepeatableReadDraftLinkDeleteRacingPublish_ShouldBeRejectedWithoutInvalidPublicCard()
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task RepeatableReadDraftLinkDeleteRacingPublish_ShouldBeRejectedWithoutInvalidPublicCard(
bool publishCommitsFirst)
{
var database = await fixture.CreateMigratedDatabaseAsync();
await using var seedConnection = await database.OpenConnectionAsync();
@@ -285,11 +300,16 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
publishTransaction,
new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId));
var deleteSqlState = await CommitAndCaptureSqlStateAsync(deleteTransaction);
var publishSqlState = await CommitAndCaptureSqlStateAsync(publishTransaction);
await AcquirePortfolioValidationLockAsync(
publishCommitsFirst ? publishConnection : deleteConnection,
publishCommitsFirst ? publishTransaction : deleteTransaction);
Assert.Equal(PostgresErrorCodes.FeatureNotSupported, deleteSqlState);
Assert.Null(publishSqlState);
var commitStates = await Task.WhenAll(
CommitAndCaptureSqlStateAsync(deleteTransaction),
CommitAndCaptureSqlStateAsync(publishTransaction)).WaitAsync(CommandTimeout);
Assert.Equal(PostgresErrorCodes.FeatureNotSupported, commitStates[0]);
Assert.Null(commitStates[1]);
await using var verificationConnection = await database.OpenConnectionAsync();
Assert.True(await ExecuteScalarAsync<bool>(
@@ -302,6 +322,105 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
}
[Fact]
public async Task PublishedCardFutureReschedule_ShouldAutomaticallyUnpublishAndPreserveFirstPublishedAt()
{
var database = await fixture.CreateMigratedDatabaseAsync();
await using var connection = await database.OpenConnectionAsync();
var seed = await SeedCardAsync(connection, isPublic: true);
await ExecuteNonQueryAsync(
connection,
"UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @sessionId",
parameters: new NpgsqlParameter("sessionId", seed.SessionIds[0]));
Assert.False(await ExecuteScalarAsync<bool>(
connection,
"SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId",
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
Assert.Equal(seed.PublishedAt, await ExecuteScalarAsync<DateTime>(
connection,
"SELECT published_at FROM portfolio_games WHERE id = @portfolioGameId",
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
}
[Fact]
public async Task PublishingDraftCardWithAnyFutureLinkedSession_ShouldFailCommit()
{
var database = await fixture.CreateMigratedDatabaseAsync();
await using var connection = await database.OpenConnectionAsync();
var seed = await SeedCardAsync(connection, isPublic: false, sessionCount: 2);
await ExecuteNonQueryAsync(
connection,
"UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @sessionId",
parameters: new NpgsqlParameter("sessionId", seed.SessionIds[1]));
await using var transaction = await connection.BeginTransactionAsync();
await ExecuteNonQueryAsync(
connection,
"""
UPDATE portfolio_games
SET is_public = true,
published_at = COALESCE(published_at, now()),
updated_at = now()
WHERE id = @portfolioGameId
""",
transaction,
new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId));
var exception = await Assert.ThrowsAsync<PostgresException>(
() => transaction.CommitAsync().WaitAsync(CommandTimeout));
Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState);
}
[Fact]
public async Task ConcurrentPublishAndFutureReschedule_ShouldNotDeadlockOrCommitInvalidPublicCard()
{
var database = await fixture.CreateMigratedDatabaseAsync();
await using var publishConnection = await database.OpenConnectionAsync();
await using var rescheduleConnection = await database.OpenConnectionAsync();
var seed = await SeedCardAsync(publishConnection, isPublic: false);
await using var publishTransaction = await publishConnection.BeginTransactionAsync();
await using var rescheduleTransaction = await rescheduleConnection.BeginTransactionAsync();
await ExecuteNonQueryAsync(
publishConnection,
"""
UPDATE portfolio_games
SET is_public = true,
published_at = COALESCE(published_at, now()),
updated_at = now()
WHERE id = @portfolioGameId
""",
publishTransaction,
new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId));
await ExecuteNonQueryAsync(
rescheduleConnection,
"UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @sessionId",
rescheduleTransaction,
new NpgsqlParameter("sessionId", seed.SessionIds[0]));
var commitStates = await Task.WhenAll(
CommitAndCaptureSqlStateAsync(publishTransaction),
CommitAndCaptureSqlStateAsync(rescheduleTransaction)).WaitAsync(CommandTimeout);
Assert.True(
commitStates[0] is null or PostgresErrorCodes.CheckViolation,
$"Unexpected publish SQLSTATE: {commitStates[0] ?? "<none>"}.");
Assert.Null(commitStates[1]);
await using var verificationConnection = await database.OpenConnectionAsync();
Assert.False(await ExecuteScalarAsync<bool>(
verificationConnection,
"SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId",
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
Assert.True(await ExecuteScalarAsync<bool>(
verificationConnection,
"SELECT scheduled_at >= now() FROM sessions WHERE id = @sessionId",
parameters: new NpgsqlParameter("sessionId", seed.SessionIds[0])));
}
[Theory]
[InlineData("portfolio_game_sessions")]
[InlineData("portfolio_game_masters")]
@@ -489,6 +608,16 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
}
}
private static Task<int> AcquirePortfolioValidationLockAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction)
{
return ExecuteNonQueryAsync(
connection,
"SELECT pg_advisory_xact_lock(20260530, 108)",
transaction);
}
private static async Task<int> ExecuteNonQueryAsync(
NpgsqlConnection connection,
string sql,