using Npgsql; 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))); } [Fact] public async Task ConcurrentPublishAndLinkDelete_ShouldNotDeadlockOrCommitInvalidPublicCard() { 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 deleteTransaction.CommitAsync().WaitAsync(CommandTimeout); var exception = await Assert.ThrowsAsync( () => publishTransaction.CommitAsync().WaitAsync(CommandTimeout)); Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState); 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))); } [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")] [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 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); }