Files
GmRelayBot/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs
T

255 lines
11 KiB
C#

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<long>(
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<PostgresException>(
() => 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<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)));
Assert.Equal(0, await ExecuteScalarAsync<long>(
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<PostgresException>(
() => publishTransaction.CommitAsync().WaitAsync(CommandTimeout));
Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState);
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)));
}
[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<long>(
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<PortfolioSeed> 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<int> 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<T> ExecuteScalarAsync<T>(
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);
}