using Npgsql; using System.Data; namespace GmRelay.Bot.Tests.Web; [Collection(PortfolioMigrationPostgresCollection.Name)] public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFixture fixture) { private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10); private static long nextLegacyId = 1000; [Fact] public async Task MigrationsV001ThroughV029_ShouldApplyToPostgres17() { var database = await fixture.CreateMigratedDatabaseAsync(); Assert.Equal(29, database.AppliedMigrationCount); await using var connection = await database.OpenConnectionAsync(); Assert.Equal(4, await ExecuteScalarAsync( connection, """ SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name IN ( 'portfolio_games', 'portfolio_game_sessions', 'portfolio_game_masters', 'portfolio_game_reviews') """)); } [Theory] [InlineData("portfolio_game_sessions")] [InlineData("portfolio_game_masters")] public async Task DirectRequiredLinkDeletion_ShouldFailCommitForPublishedCard(string linkTable) { var database = await fixture.CreateMigratedDatabaseAsync(); await using var connection = await database.OpenConnectionAsync(); var seed = await SeedCardAsync(connection, isPublic: true); await using var transaction = await connection.BeginTransactionAsync(); await ExecuteNonQueryAsync( connection, $"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId", transaction, new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); var exception = await Assert.ThrowsAsync( () => transaction.CommitAsync().WaitAsync(CommandTimeout)); Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState); } [Fact] public async Task ExplicitUnpublishThenSessionDelete_ShouldCommitAndPreserveFirstPublishedAt() { var database = await fixture.CreateMigratedDatabaseAsync(); await using var connection = await database.OpenConnectionAsync(); var seed = await SeedCardAsync(connection, isPublic: true); await using var transaction = await connection.BeginTransactionAsync(); await ExecuteNonQueryAsync( connection, """ UPDATE portfolio_games SET is_public = false, updated_at = now() WHERE id = @portfolioGameId """, transaction, new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); await ExecuteNonQueryAsync( connection, "DELETE FROM sessions WHERE id = @sessionId", transaction, new NpgsqlParameter("sessionId", seed.SessionIds[0])); await transaction.CommitAsync().WaitAsync(CommandTimeout); Assert.False(await ExecuteScalarAsync( connection, "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); Assert.Equal(seed.PublishedAt, await ExecuteScalarAsync( connection, "SELECT published_at FROM portfolio_games WHERE id = @portfolioGameId", parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); Assert.Equal(0, await ExecuteScalarAsync( connection, "SELECT COUNT(*) FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); } [Theory] [InlineData(true)] [InlineData(false)] public async Task ConcurrentPublishAndLinkDelete_ShouldNotDeadlockOrCommitInvalidPublicCard(bool publishCommitsFirst) { var database = await fixture.CreateMigratedDatabaseAsync(); await using var publishConnection = await database.OpenConnectionAsync(); await using var deleteConnection = await database.OpenConnectionAsync(); var seed = await SeedCardAsync(publishConnection, isPublic: false); await using var publishTransaction = await publishConnection.BeginTransactionAsync(); await using var deleteTransaction = await deleteConnection.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(deleteConnection, "SET LOCAL lock_timeout = '2s'", deleteTransaction); Assert.Equal(1, await ExecuteNonQueryAsync( deleteConnection, "DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", deleteTransaction, new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); 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.Equal(publishCommitsFirst, await ExecuteScalarAsync( verificationConnection, "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); Assert.Equal(publishCommitsFirst ? 1L : 0L, await ExecuteScalarAsync( verificationConnection, "SELECT COUNT(*) FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); } [Theory] [InlineData("portfolio_game_sessions", "session_id")] [InlineData("portfolio_game_masters", "player_id")] public async Task ConcurrentRequiredLinkDeletes_ShouldSerializeAndRejectInvalidPublicCard( string linkTable, string linkColumn) { var database = await fixture.CreateMigratedDatabaseAsync(); await using var seedConnection = await database.OpenConnectionAsync(); var seed = await SeedCardAsync( seedConnection, isPublic: true, sessionCount: linkTable == "portfolio_game_sessions" ? 2 : 1, masterCount: linkTable == "portfolio_game_masters" ? 2 : 1); await using var firstConnection = await database.OpenConnectionAsync(); await using var secondConnection = await database.OpenConnectionAsync(); await using var firstTransaction = await firstConnection.BeginTransactionAsync(); await using var secondTransaction = await secondConnection.BeginTransactionAsync(); var linkIds = linkTable == "portfolio_game_sessions" ? seed.SessionIds : seed.MasterIds; await ExecuteNonQueryAsync( firstConnection, $"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId AND {linkColumn} = @linkId", firstTransaction, new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), new NpgsqlParameter("linkId", linkIds[0])); await ExecuteNonQueryAsync( secondConnection, $"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId AND {linkColumn} = @linkId", secondTransaction, new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), new NpgsqlParameter("linkId", linkIds[1])); var commitStates = await Task.WhenAll( CommitAndCaptureSqlStateAsync(firstTransaction), CommitAndCaptureSqlStateAsync(secondTransaction)); Assert.Single(commitStates, state => state is null); Assert.Single(commitStates, state => state == PostgresErrorCodes.CheckViolation); await using var verificationConnection = await database.OpenConnectionAsync(); Assert.True(await ExecuteScalarAsync( verificationConnection, "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); Assert.Equal(1, await ExecuteScalarAsync( verificationConnection, $"SELECT COUNT(*) FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId", parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); } [Theory] [InlineData("portfolio_game_sessions", "session_id")] [InlineData("portfolio_game_masters", "player_id")] public async Task RepeatableReadConcurrentRequiredLinkDeletes_ShouldBeRejectedWithoutInvalidPublicCard( string linkTable, string linkColumn) { var database = await fixture.CreateMigratedDatabaseAsync(); await using var seedConnection = await database.OpenConnectionAsync(); var seed = await SeedCardAsync( seedConnection, isPublic: true, sessionCount: linkTable == "portfolio_game_sessions" ? 2 : 1, masterCount: linkTable == "portfolio_game_masters" ? 2 : 1); await using var firstConnection = await database.OpenConnectionAsync(); await using var secondConnection = await database.OpenConnectionAsync(); await using var firstTransaction = await firstConnection.BeginTransactionAsync(IsolationLevel.RepeatableRead); await using var secondTransaction = await secondConnection.BeginTransactionAsync(IsolationLevel.RepeatableRead); var linkIds = linkTable == "portfolio_game_sessions" ? seed.SessionIds : seed.MasterIds; await ExecuteNonQueryAsync( firstConnection, $"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId AND {linkColumn} = @linkId", firstTransaction, new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), new NpgsqlParameter("linkId", linkIds[0])); await ExecuteNonQueryAsync( secondConnection, $"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId AND {linkColumn} = @linkId", secondTransaction, new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), new NpgsqlParameter("linkId", linkIds[1])); var commitStates = await Task.WhenAll( CommitAndCaptureSqlStateAsync(firstTransaction), CommitAndCaptureSqlStateAsync(secondTransaction)); Assert.All(commitStates, state => Assert.Equal(PostgresErrorCodes.FeatureNotSupported, state)); await using var verificationConnection = await database.OpenConnectionAsync(); Assert.True(await ExecuteScalarAsync( verificationConnection, "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); Assert.Equal(2, await ExecuteScalarAsync( verificationConnection, $"SELECT COUNT(*) FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId", parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); } [Theory] [InlineData(IsolationLevel.RepeatableRead)] [InlineData(IsolationLevel.Serializable)] public async Task NonReadCommittedPublishedCardLinkDelete_ShouldBeRejected(IsolationLevel isolationLevel) { var database = await fixture.CreateMigratedDatabaseAsync(); await using var connection = await database.OpenConnectionAsync(); var seed = await SeedCardAsync(connection, isPublic: true); await using var transaction = await connection.BeginTransactionAsync(isolationLevel); await ExecuteNonQueryAsync( connection, "DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", transaction, new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); var exception = await Assert.ThrowsAsync( () => transaction.CommitAsync().WaitAsync(CommandTimeout)); Assert.Equal(PostgresErrorCodes.FeatureNotSupported, exception.SqlState); } [Theory] [InlineData(true)] [InlineData(false)] public async Task RepeatableReadDraftLinkDeleteRacingPublish_ShouldBeRejectedWithoutInvalidPublicCard( bool publishCommitsFirst) { var database = await fixture.CreateMigratedDatabaseAsync(); await using var seedConnection = await database.OpenConnectionAsync(); var seed = await SeedCardAsync(seedConnection, isPublic: false); await using var deleteConnection = await database.OpenConnectionAsync(); await using var publishConnection = await database.OpenConnectionAsync(); await using var deleteTransaction = await deleteConnection.BeginTransactionAsync(IsolationLevel.RepeatableRead); await using var publishTransaction = await publishConnection.BeginTransactionAsync(); await ExecuteNonQueryAsync( deleteConnection, "DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", deleteTransaction, new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); 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 AcquirePortfolioValidationLockAsync( publishCommitsFirst ? publishConnection : deleteConnection, publishCommitsFirst ? publishTransaction : deleteTransaction); 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( verificationConnection, "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); Assert.Equal(1, await ExecuteScalarAsync( verificationConnection, "SELECT COUNT(*) FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", 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( connection, "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); Assert.Equal(seed.PublishedAt, await ExecuteScalarAsync( connection, "SELECT published_at FROM portfolio_games WHERE id = @portfolioGameId", parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); } [Fact] public async Task PublishedCardPastFuturePastReschedule_ShouldRemainPublicAndPreserveFirstPublishedAt() { var database = await fixture.CreateMigratedDatabaseAsync(); await using var connection = await database.OpenConnectionAsync(); var seed = await SeedCardAsync(connection, isPublic: true); await using var transaction = await connection.BeginTransactionAsync(); await ExecuteNonQueryAsync( connection, "UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @sessionId", transaction, new NpgsqlParameter("sessionId", seed.SessionIds[0])); await ExecuteNonQueryAsync( connection, "UPDATE sessions SET scheduled_at = now() - interval '2 days' WHERE id = @sessionId", transaction, new NpgsqlParameter("sessionId", seed.SessionIds[0])); await transaction.CommitAsync().WaitAsync(CommandTimeout); Assert.True(await ExecuteScalarAsync( connection, "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); Assert.Equal(seed.PublishedAt, await ExecuteScalarAsync( connection, "SELECT published_at FROM portfolio_games WHERE id = @portfolioGameId", parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); } [Fact] public async Task ConcurrentBatchFutureReschedules_ShouldLockPublicCardsInStableOrderWithoutDeadlock() { var database = await fixture.CreateMigratedDatabaseAsync(); await using var seedConnection = await database.OpenConnectionAsync(); var firstSeed = await SeedCardAsync(seedConnection, isPublic: true, sessionCount: 2); var secondSeed = await SeedCardAsync(seedConnection, isPublic: true, sessionCount: 2); await ExecuteNonQueryAsync( seedConnection, """ CREATE FUNCTION wait_for_portfolio_card_unpublish_gate() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN PERFORM pg_advisory_xact_lock(20260601, 108); RETURN NULL; END; $$; CREATE TRIGGER trg_wait_for_portfolio_card_unpublish_gate AFTER UPDATE OF is_public ON portfolio_games FOR EACH ROW WHEN (OLD.is_public = true AND NEW.is_public = false) EXECUTE FUNCTION wait_for_portfolio_card_unpublish_gate(); """); await using var firstConnection = await database.OpenConnectionAsync(); await using var secondConnection = await database.OpenConnectionAsync(); await using var gateConnection = await database.OpenConnectionAsync(); await using var observerConnection = await database.OpenConnectionAsync(); await using var firstTransaction = await firstConnection.BeginTransactionAsync(); await using var secondTransaction = await secondConnection.BeginTransactionAsync(); await using var gateTransaction = await gateConnection.BeginTransactionAsync(); var firstPid = await GetBackendPidAsync(firstConnection, firstTransaction); var secondPid = await GetBackendPidAsync(secondConnection, secondTransaction); var gatePid = await GetBackendPidAsync(gateConnection, gateTransaction); await AcquireBatchRescheduleGateAsync(gateConnection, gateTransaction); await RescheduleSessionsAsync( firstConnection, firstTransaction, firstSeed.SessionIds[0], secondSeed.SessionIds[0]); await RescheduleSessionsAsync( secondConnection, secondTransaction, secondSeed.SessionIds[1], firstSeed.SessionIds[1]); var firstCommitTask = CommitAndCaptureSqlStateAsync(firstTransaction); var secondCommitTask = CommitAndCaptureSqlStateAsync(secondTransaction); var gateBlockedPid = await WaitUntilEitherBlockedByAsync( observerConnection, firstPid, secondPid, gatePid); await WaitUntilBlockedByAnyAsync( observerConnection, gateBlockedPid == firstPid ? secondPid : firstPid, gatePid, gateBlockedPid); await gateTransaction.CommitAsync().WaitAsync(CommandTimeout); var commitStates = await Task.WhenAll(firstCommitTask, secondCommitTask).WaitAsync(CommandTimeout); Assert.All(commitStates, Assert.Null); await using var verificationConnection = await database.OpenConnectionAsync(); Assert.Equal(0, await ExecuteScalarAsync( verificationConnection, """ SELECT COUNT(*) FROM portfolio_games WHERE id IN (@firstPortfolioGameId, @secondPortfolioGameId) AND is_public = true """, parameters: [ new NpgsqlParameter("firstPortfolioGameId", firstSeed.PortfolioGameId), new NpgsqlParameter("secondPortfolioGameId", secondSeed.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( () => 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(); await using var observerConnection = await database.OpenConnectionAsync(); var seed = await SeedCardAsync(publishConnection, isPublic: false); await using var publishTransaction = await publishConnection.BeginTransactionAsync(); await using var rescheduleTransaction = await rescheduleConnection.BeginTransactionAsync(); var publishPid = await GetBackendPidAsync(publishConnection, publishTransaction); var reschedulePid = await GetBackendPidAsync(rescheduleConnection, rescheduleTransaction); 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 forceRescheduleTriggerTask = ExecuteNonQueryAsync( rescheduleConnection, "SET CONSTRAINTS trg_sessions_unpublish_public_portfolio_games_for_future_reschedule IMMEDIATE", rescheduleTransaction); await WaitUntilBlockedByAsync(observerConnection, reschedulePid, publishPid); Assert.Null(await CommitAndCaptureSqlStateAsync(publishTransaction).WaitAsync(CommandTimeout)); await forceRescheduleTriggerTask.WaitAsync(CommandTimeout); await rescheduleTransaction.CommitAsync().WaitAsync(CommandTimeout); await using var verificationConnection = await database.OpenConnectionAsync(); Assert.False(await ExecuteScalarAsync( verificationConnection, "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); Assert.True(await ExecuteScalarAsync( verificationConnection, "SELECT scheduled_at >= now() FROM sessions WHERE id = @sessionId", parameters: new NpgsqlParameter("sessionId", seed.SessionIds[0]))); } [Theory] [InlineData(true)] [InlineData(false)] public async Task ConcurrentSessionDeleteAndFutureReschedule_ShouldSerializeSessionBeforeCardWithoutDeadlock( bool deleteLocksSessionFirst) { var database = await fixture.CreateMigratedDatabaseAsync(); await using var seedConnection = await database.OpenConnectionAsync(); var seed = await SeedCardAsync(seedConnection, isPublic: true); await using var deleteConnection = await database.OpenConnectionAsync(); await using var rescheduleConnection = await database.OpenConnectionAsync(); await using var observerConnection = await database.OpenConnectionAsync(); await using var deleteTransaction = await deleteConnection.BeginTransactionAsync(); await using var rescheduleTransaction = await rescheduleConnection.BeginTransactionAsync(); var deletePid = await GetBackendPidAsync(deleteConnection, deleteTransaction); var reschedulePid = await GetBackendPidAsync(rescheduleConnection, rescheduleTransaction); if (deleteLocksSessionFirst) { await LockSessionAsync(deleteConnection, deleteTransaction, seed.SessionIds[0]); var rescheduleTask = RescheduleSessionAsync( rescheduleConnection, rescheduleTransaction, seed.SessionIds[0]); await WaitUntilBlockedByAsync(observerConnection, reschedulePid, deletePid); await UnpublishAndDeleteSessionAsync( deleteConnection, deleteTransaction, seed.PortfolioGameId, seed.SessionIds[0]); await deleteTransaction.CommitAsync().WaitAsync(CommandTimeout); Assert.Equal(0, await rescheduleTask.WaitAsync(CommandTimeout)); await rescheduleTransaction.CommitAsync().WaitAsync(CommandTimeout); } else { Assert.Equal(1, await RescheduleSessionAsync( rescheduleConnection, rescheduleTransaction, seed.SessionIds[0])); var deleteTask = LockUnpublishDeleteAndCommitSessionAsync( deleteConnection, deleteTransaction, seed.PortfolioGameId, seed.SessionIds[0]); await WaitUntilBlockedByAsync(observerConnection, deletePid, reschedulePid); await rescheduleTransaction.CommitAsync().WaitAsync(CommandTimeout); await deleteTask.WaitAsync(CommandTimeout); } await using var verificationConnection = await database.OpenConnectionAsync(); Assert.False(await ExecuteScalarAsync( verificationConnection, "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); Assert.Equal(0, await ExecuteScalarAsync( verificationConnection, "SELECT COUNT(*) FROM sessions WHERE id = @sessionId", parameters: new NpgsqlParameter("sessionId", seed.SessionIds[0]))); } [Theory] [InlineData("portfolio_game_sessions")] [InlineData("portfolio_game_masters")] public async Task MovingLastRequiredLinkAway_ShouldFailCommitForPublishedCard(string linkTable) { var database = await fixture.CreateMigratedDatabaseAsync(); await using var connection = await database.OpenConnectionAsync(); var source = await SeedCardAsync(connection, isPublic: true); var destination = await SeedCardAsync(connection, isPublic: false); await using var transaction = await connection.BeginTransactionAsync(); await ExecuteNonQueryAsync( connection, $"UPDATE {linkTable} SET portfolio_game_id = @destinationId WHERE portfolio_game_id = @sourceId", transaction, new NpgsqlParameter("destinationId", destination.PortfolioGameId), new NpgsqlParameter("sourceId", source.PortfolioGameId)); var exception = await Assert.ThrowsAsync( () => transaction.CommitAsync().WaitAsync(CommandTimeout)); Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState); } [Theory] [InlineData("sessions")] [InlineData("players")] public async Task RequiredParentCascadeDelete_ShouldFailCommitForPublishedCard(string parentTable) { var database = await fixture.CreateMigratedDatabaseAsync(); await using var connection = await database.OpenConnectionAsync(); var seed = await SeedCardAsync(connection, isPublic: true); await using var transaction = await connection.BeginTransactionAsync(); await ExecuteNonQueryAsync( connection, $"DELETE FROM {parentTable} WHERE id = @parentId", transaction, new NpgsqlParameter( "parentId", parentTable == "sessions" ? seed.SessionIds[0] : seed.MasterIds[0])); var exception = await Assert.ThrowsAsync( () => transaction.CommitAsync().WaitAsync(CommandTimeout)); Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState); } [Fact] public async Task ParentCardAndGroupCascadeDeletes_ShouldCommit() { var database = await fixture.CreateMigratedDatabaseAsync(); await using var connection = await database.OpenConnectionAsync(); var cardDeleteSeed = await SeedCardAsync(connection, isPublic: true); await ExecuteNonQueryAsync( connection, "DELETE FROM portfolio_games WHERE id = @portfolioGameId", parameters: new NpgsqlParameter("portfolioGameId", cardDeleteSeed.PortfolioGameId)); var groupDeleteSeed = await SeedCardAsync(connection, isPublic: true); await ExecuteNonQueryAsync( connection, "DELETE FROM game_groups WHERE id = @groupId", parameters: new NpgsqlParameter("groupId", groupDeleteSeed.GroupId)); Assert.Equal(0, await ExecuteScalarAsync( connection, "SELECT COUNT(*) FROM portfolio_games WHERE id IN (@cardDeleteId, @groupDeleteId)", parameters: [ new NpgsqlParameter("cardDeleteId", cardDeleteSeed.PortfolioGameId), new NpgsqlParameter("groupDeleteId", groupDeleteSeed.PortfolioGameId) ])); } private static async Task SeedCardAsync( NpgsqlConnection connection, bool isPublic, int sessionCount = 1, int masterCount = 1) { var groupId = Guid.NewGuid(); var portfolioGameId = Guid.NewGuid(); var sessionIds = Enumerable.Range(0, sessionCount).Select(_ => Guid.NewGuid()).ToArray(); var masterIds = Enumerable.Range(0, masterCount).Select(_ => Guid.NewGuid()).ToArray(); var publishedAtValue = DateTime.UtcNow.AddDays(-1); var publishedAt = new DateTime(publishedAtValue.Ticks / 10 * 10, DateTimeKind.Utc); await using var transaction = await connection.BeginTransactionAsync(); foreach (var masterId in masterIds) { var legacyId = Interlocked.Increment(ref nextLegacyId); await ExecuteNonQueryAsync( connection, """ INSERT INTO players (id, telegram_id, display_name, platform, external_user_id) VALUES (@playerId, @legacyId, 'Portfolio GM', 'Telegram', @legacyIdText); """, transaction, new NpgsqlParameter("playerId", masterId), new NpgsqlParameter("legacyId", legacyId), new NpgsqlParameter("legacyIdText", legacyId.ToString())); } var groupLegacyId = Interlocked.Increment(ref nextLegacyId); await ExecuteNonQueryAsync( connection, """ INSERT INTO game_groups (id, telegram_chat_id, name, gm_telegram_id, platform, external_group_id) VALUES (@groupId, @legacyId, 'Portfolio Club', @legacyId, 'Telegram', @legacyIdText); INSERT INTO portfolio_games ( id, group_id, public_slug, title, description, cover_storage_key, is_public, published_at) VALUES ( @portfolioGameId, @groupId, @publicSlug, 'Completed Adventure', 'A completed adventure.', 'covers/example.webp', @isPublic, CASE WHEN @isPublic THEN @publishedAt ELSE NULL END); """, transaction, new NpgsqlParameter("legacyId", groupLegacyId), new NpgsqlParameter("legacyIdText", groupLegacyId.ToString()), new NpgsqlParameter("groupId", groupId), new NpgsqlParameter("portfolioGameId", portfolioGameId), new NpgsqlParameter("publicSlug", $"portfolio-{portfolioGameId:N}"), new NpgsqlParameter("isPublic", isPublic), new NpgsqlParameter("publishedAt", publishedAt)); foreach (var sessionId in sessionIds) { await ExecuteNonQueryAsync( connection, """ INSERT INTO sessions (id, group_id, title, join_link, scheduled_at) VALUES (@sessionId, @groupId, 'Completed Session', 'https://example.test/session', now() - interval '1 day'); INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id) VALUES (@portfolioGameId, @sessionId); """, transaction, new NpgsqlParameter("sessionId", sessionId), new NpgsqlParameter("groupId", groupId), new NpgsqlParameter("portfolioGameId", portfolioGameId)); } foreach (var masterId in masterIds) { await ExecuteNonQueryAsync( connection, """ INSERT INTO portfolio_game_masters (portfolio_game_id, player_id) VALUES (@portfolioGameId, @playerId); """, transaction, new NpgsqlParameter("portfolioGameId", portfolioGameId), new NpgsqlParameter("playerId", masterId)); } await transaction.CommitAsync().WaitAsync(CommandTimeout); return new PortfolioSeed(portfolioGameId, groupId, sessionIds, masterIds, publishedAt); } private static async Task CommitAndCaptureSqlStateAsync(NpgsqlTransaction transaction) { try { await transaction.CommitAsync().WaitAsync(CommandTimeout); return null; } catch (PostgresException exception) { return exception.SqlState; } } private static Task AcquirePortfolioValidationLockAsync( NpgsqlConnection connection, NpgsqlTransaction transaction) { return ExecuteNonQueryAsync( connection, "SELECT pg_advisory_xact_lock(20260530, 108)", transaction); } private static Task AcquireBatchRescheduleGateAsync( NpgsqlConnection connection, NpgsqlTransaction transaction) { return ExecuteNonQueryAsync( connection, "SELECT pg_advisory_xact_lock(20260601, 108)", transaction); } private static Task GetBackendPidAsync( NpgsqlConnection connection, NpgsqlTransaction transaction) { return ExecuteScalarAsync(connection, "SELECT pg_backend_pid()", transaction); } private static Task LockSessionAsync( NpgsqlConnection connection, NpgsqlTransaction transaction, Guid sessionId) { return ExecuteNonQueryAsync( connection, "SELECT 1 FROM sessions s WHERE s.id = @sessionId FOR UPDATE OF s", transaction, new NpgsqlParameter("sessionId", sessionId)); } private static Task RescheduleSessionAsync( NpgsqlConnection connection, NpgsqlTransaction transaction, Guid sessionId) { return ExecuteNonQueryAsync( connection, "UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @sessionId", transaction, new NpgsqlParameter("sessionId", sessionId)); } private static Task RescheduleSessionsAsync( NpgsqlConnection connection, NpgsqlTransaction transaction, Guid firstSessionId, Guid secondSessionId) { return ExecuteNonQueryAsync( connection, """ UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @firstSessionId; UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @secondSessionId; """, transaction, new NpgsqlParameter("firstSessionId", firstSessionId), new NpgsqlParameter("secondSessionId", secondSessionId)); } private static async Task LockUnpublishDeleteAndCommitSessionAsync( NpgsqlConnection connection, NpgsqlTransaction transaction, Guid portfolioGameId, Guid sessionId) { await LockSessionAsync(connection, transaction, sessionId); await UnpublishAndDeleteSessionAsync(connection, transaction, portfolioGameId, sessionId); await transaction.CommitAsync().WaitAsync(CommandTimeout); } private static async Task UnpublishAndDeleteSessionAsync( NpgsqlConnection connection, NpgsqlTransaction transaction, Guid portfolioGameId, Guid sessionId) { await ExecuteNonQueryAsync( connection, """ UPDATE portfolio_games SET is_public = false, updated_at = now() WHERE id = @portfolioGameId """, transaction, new NpgsqlParameter("portfolioGameId", portfolioGameId)); await ExecuteNonQueryAsync( connection, "DELETE FROM sessions WHERE id = @sessionId", transaction, new NpgsqlParameter("sessionId", sessionId)); } private static async Task WaitUntilBlockedByAsync( NpgsqlConnection observerConnection, int blockedPid, int blockingPid) { using var timeout = new CancellationTokenSource(CommandTimeout); while (!timeout.IsCancellationRequested) { if (await ExecuteScalarAsync( observerConnection, "SELECT @blockingPid = ANY (pg_blocking_pids(@blockedPid))", parameters: [ new NpgsqlParameter("blockedPid", blockedPid), new NpgsqlParameter("blockingPid", blockingPid) ])) { return; } await Task.Yield(); } throw new TimeoutException( $"PostgreSQL backend {blockedPid} was not blocked by backend {blockingPid} within {CommandTimeout}."); } private static async Task WaitUntilEitherBlockedByAsync( NpgsqlConnection observerConnection, int firstBlockedPid, int secondBlockedPid, int blockingPid) { using var timeout = new CancellationTokenSource(CommandTimeout); while (!timeout.IsCancellationRequested) { var blockedPid = await ExecuteScalarAsync( observerConnection, """ SELECT CASE WHEN @blockingPid = ANY (pg_blocking_pids(@firstBlockedPid)) THEN @firstBlockedPid WHEN @blockingPid = ANY (pg_blocking_pids(@secondBlockedPid)) THEN @secondBlockedPid ELSE 0 END """, parameters: [ new NpgsqlParameter("firstBlockedPid", firstBlockedPid), new NpgsqlParameter("secondBlockedPid", secondBlockedPid), new NpgsqlParameter("blockingPid", blockingPid) ]); if (blockedPid != 0) { return blockedPid; } await Task.Yield(); } throw new TimeoutException( $"Neither PostgreSQL backend {firstBlockedPid} nor {secondBlockedPid} was blocked by backend {blockingPid} within {CommandTimeout}."); } private static async Task WaitUntilBlockedByAnyAsync( NpgsqlConnection observerConnection, int blockedPid, int firstBlockingPid, int secondBlockingPid) { using var timeout = new CancellationTokenSource(CommandTimeout); while (!timeout.IsCancellationRequested) { if (await ExecuteScalarAsync( observerConnection, """ SELECT @firstBlockingPid = ANY (pg_blocking_pids(@blockedPid)) OR @secondBlockingPid = ANY (pg_blocking_pids(@blockedPid)) """, parameters: [ new NpgsqlParameter("blockedPid", blockedPid), new NpgsqlParameter("firstBlockingPid", firstBlockingPid), new NpgsqlParameter("secondBlockingPid", secondBlockingPid) ])) { return; } await Task.Yield(); } throw new TimeoutException( $"PostgreSQL backend {blockedPid} was not blocked by backend {firstBlockingPid} or {secondBlockingPid} within {CommandTimeout}."); } private static async Task ExecuteNonQueryAsync( NpgsqlConnection connection, string sql, NpgsqlTransaction? transaction = null, params NpgsqlParameter[] parameters) { await using var command = new NpgsqlCommand(sql, connection, transaction); command.Parameters.AddRange(parameters); return await command.ExecuteNonQueryAsync().WaitAsync(CommandTimeout); } private static async Task ExecuteScalarAsync( NpgsqlConnection connection, string sql, NpgsqlTransaction? transaction = null, params NpgsqlParameter[] parameters) { await using var command = new NpgsqlCommand(sql, connection, transaction); command.Parameters.AddRange(parameters); return (T)(await command.ExecuteScalarAsync().WaitAsync(CommandTimeout))!; } private sealed record PortfolioSeed( Guid PortfolioGameId, Guid GroupId, Guid[] SessionIds, Guid[] MasterIds, DateTime PublishedAt); }