fix(data): enforce completed portfolio sessions
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user