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,
|
||||
|
||||
@@ -34,10 +34,15 @@ public sealed class PortfolioMigrationTests
|
||||
Assert.Contains("PERFORM pg_advisory_xact_lock(20260530, 108);", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("current_setting('transaction_isolation') <> 'read committed'", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("USING ERRCODE = '0A000';", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("s.scheduled_at >= now()", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("RAISE EXCEPTION 'published portfolio game % must have at least one linked session and at least one linked master', target_portfolio_game_id USING ERRCODE = '23514';", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links AFTER INSERT OR UPDATE OF is_public ON portfolio_games DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE FUNCTION unpublish_public_portfolio_games_for_future_session() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("OLD.scheduled_at IS DISTINCT FROM NEW.scheduled_at AND NEW.scheduled_at >= now()", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("UPDATE portfolio_games pg SET is_public = false, updated_at = now() FROM portfolio_game_sessions pgs WHERE pgs.portfolio_game_id = pg.id AND pgs.session_id = NEW.id AND pg.is_public = true;", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule AFTER UPDATE OF scheduled_at ON sessions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION unpublish_public_portfolio_games_for_future_session();", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("unpublish_portfolio_game_without_required_links", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("FOR UPDATE", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("published_at = NULL", normalizedMigration, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -11,6 +11,25 @@ public sealed class PortfolioSchemaGateSourceTests
|
||||
AssertServiceDependsOnHealthyBot(compose, "web");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Aspire_ShouldStartDiscordAndWebOnlyAfterBotMigrationsAreHealthy()
|
||||
{
|
||||
var appHost = NormalizeSource(await ReadRepositoryFileAsync("src/GmRelay.AppHost/Program.cs"));
|
||||
|
||||
Assert.Contains(
|
||||
"var bot = builder.AddProject<Projects.GmRelay_Bot>(\"bot\") .WithReference(postgres) .WaitFor(postgres);",
|
||||
appHost,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"builder.AddProject<Projects.GmRelay_DiscordBot>(\"discord\") .WithReference(postgres) .WaitFor(postgres) .WaitFor(bot);",
|
||||
appHost,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"builder.AddProject<Projects.GmRelay_Web>(\"web\") .WithReference(postgres) .WaitFor(postgres) .WaitFor(bot);",
|
||||
appHost,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static void AssertServiceDependsOnHealthyBot(string compose, string serviceName)
|
||||
{
|
||||
var serviceBlock = GetServiceBlock(compose, serviceName);
|
||||
@@ -40,6 +59,11 @@ public sealed class PortfolioSchemaGateSourceTests
|
||||
return string.Join('\n', lines[start..(end < 0 ? lines.Length : end)]);
|
||||
}
|
||||
|
||||
private static string NormalizeSource(string source)
|
||||
{
|
||||
return string.Join(' ', source.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
|
||||
Reference in New Issue
Block a user