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.SessionId)); 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))); } [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) { var playerId = Guid.NewGuid(); var groupId = Guid.NewGuid(); var sessionId = Guid.NewGuid(); var portfolioGameId = Guid.NewGuid(); var legacyId = Interlocked.Increment(ref nextLegacyId); var publishedAtValue = DateTime.UtcNow.AddDays(-1); var publishedAt = new DateTime(publishedAtValue.Ticks / 10 * 10, DateTimeKind.Utc); await using var transaction = await connection.BeginTransactionAsync(); await ExecuteNonQueryAsync( connection, """ INSERT INTO players (id, telegram_id, display_name, platform, external_user_id) VALUES (@playerId, @legacyId, 'Portfolio GM', 'Telegram', @legacyIdText); 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 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_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); INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id) VALUES (@portfolioGameId, @sessionId); INSERT INTO portfolio_game_masters (portfolio_game_id, player_id) VALUES (@portfolioGameId, @playerId); """, transaction, new NpgsqlParameter("playerId", playerId), new NpgsqlParameter("legacyId", legacyId), new NpgsqlParameter("legacyIdText", legacyId.ToString()), new NpgsqlParameter("groupId", groupId), new NpgsqlParameter("sessionId", sessionId), new NpgsqlParameter("portfolioGameId", portfolioGameId), new NpgsqlParameter("publicSlug", $"portfolio-{portfolioGameId:N}"), new NpgsqlParameter("isPublic", isPublic), new NpgsqlParameter("publishedAt", publishedAt)); await transaction.CommitAsync().WaitAsync(CommandTimeout); return new PortfolioSeed(portfolioGameId, groupId, sessionId, publishedAt); } 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 SessionId, DateTime PublishedAt); }