fix(data): serialize portfolio publication validation
This commit is contained in:
@@ -74,7 +74,7 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
|
||||
connection,
|
||||
"DELETE FROM sessions WHERE id = @sessionId",
|
||||
transaction,
|
||||
new NpgsqlParameter("sessionId", seed.SessionId));
|
||||
new NpgsqlParameter("sessionId", seed.SessionIds[0]));
|
||||
|
||||
await transaction.CommitAsync().WaitAsync(CommandTimeout);
|
||||
|
||||
@@ -133,6 +133,105 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
|
||||
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<bool>(
|
||||
verificationConnection,
|
||||
"SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId",
|
||||
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
|
||||
Assert.Equal(1, await ExecuteScalarAsync<long>(
|
||||
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<PostgresException>(
|
||||
() => 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<PostgresException>(
|
||||
() => transaction.CommitAsync().WaitAsync(CommandTimeout));
|
||||
|
||||
Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParentCardAndGroupCascadeDeletes_ShouldCommit()
|
||||
{
|
||||
@@ -161,29 +260,42 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
|
||||
]));
|
||||
}
|
||||
|
||||
private static async Task<PortfolioSeed> SeedCardAsync(NpgsqlConnection connection, bool isPublic)
|
||||
private static async Task<PortfolioSeed> SeedCardAsync(
|
||||
NpgsqlConnection connection,
|
||||
bool isPublic,
|
||||
int sessionCount = 1,
|
||||
int masterCount = 1)
|
||||
{
|
||||
var playerId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var sessionId = Guid.NewGuid();
|
||||
var portfolioGameId = Guid.NewGuid();
|
||||
var legacyId = Interlocked.Increment(ref nextLegacyId);
|
||||
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 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,
|
||||
@@ -202,26 +314,61 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
|
||||
'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("legacyId", groupLegacyId),
|
||||
new NpgsqlParameter("legacyIdText", groupLegacyId.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));
|
||||
|
||||
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, sessionId, publishedAt);
|
||||
return new PortfolioSeed(portfolioGameId, groupId, sessionIds, masterIds, publishedAt);
|
||||
}
|
||||
|
||||
private static async Task<string?> CommitAndCaptureSqlStateAsync(NpgsqlTransaction transaction)
|
||||
{
|
||||
try
|
||||
{
|
||||
await transaction.CommitAsync().WaitAsync(CommandTimeout);
|
||||
return null;
|
||||
}
|
||||
catch (PostgresException exception)
|
||||
{
|
||||
return exception.SqlState;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteNonQueryAsync(
|
||||
@@ -249,6 +396,7 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
|
||||
private sealed record PortfolioSeed(
|
||||
Guid PortfolioGameId,
|
||||
Guid GroupId,
|
||||
Guid SessionId,
|
||||
Guid[] SessionIds,
|
||||
Guid[] MasterIds,
|
||||
DateTime PublishedAt);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ public sealed class PortfolioMigrationTests
|
||||
Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_public ON portfolio_game_reviews (portfolio_game_id, created_at DESC) WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL;", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_pending ON portfolio_game_reviews (portfolio_game_id, created_at DESC) WHERE moderation_status = 'Pending';", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE FUNCTION validate_public_portfolio_game_required_links() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal);
|
||||
Assert.Contains("PERFORM pg_advisory_xact_lock(20260530, 108);", 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);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class PortfolioSchemaGateSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Compose_ShouldStartDiscordAndWebOnlyAfterBotMigrationsAreHealthy()
|
||||
{
|
||||
var compose = await ReadRepositoryFileAsync("compose.yaml");
|
||||
|
||||
AssertServiceDependsOnHealthyBot(compose, "discord");
|
||||
AssertServiceDependsOnHealthyBot(compose, "web");
|
||||
}
|
||||
|
||||
private static void AssertServiceDependsOnHealthyBot(string compose, string serviceName)
|
||||
{
|
||||
var serviceBlock = GetServiceBlock(compose, serviceName);
|
||||
|
||||
Assert.Contains(
|
||||
"""
|
||||
bot:
|
||||
condition: service_healthy
|
||||
""",
|
||||
serviceBlock,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string GetServiceBlock(string compose, string serviceName)
|
||||
{
|
||||
var lines = compose.Split('\n');
|
||||
var start = Array.FindIndex(lines, line => line.TrimEnd('\r') == $" {serviceName}:");
|
||||
Assert.True(start >= 0, $"compose.yaml should contain service '{serviceName}'.");
|
||||
|
||||
var end = Array.FindIndex(
|
||||
lines,
|
||||
start + 1,
|
||||
line => line.StartsWith(" ", StringComparison.Ordinal)
|
||||
&& !line.StartsWith(" ", StringComparison.Ordinal)
|
||||
&& line.TrimEnd('\r').EndsWith(':'));
|
||||
|
||||
return string.Join('\n', lines[start..(end < 0 ? lines.Length : end)]);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return await File.ReadAllTextAsync(candidate);
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public sealed class PortfolioSessionDeletionSourceTests
|
||||
"UPDATE portfolio_games pg SET is_public = false, updated_at = now() FROM portfolio_game_sessions pgs JOIN sessions s ON s.id = pgs.session_id JOIN game_groups g ON g.id = s.group_id WHERE pgs.portfolio_game_id = pg.id AND s.id = @SessionId AND g.platform = 'Discord' AND g.external_group_id = @GuildId AND pg.is_public = true";
|
||||
|
||||
Assert.Contains(unpublish, source, StringComparison.Ordinal);
|
||||
Assert.Contains("AND p.platform = 'Discord'", source, StringComparison.Ordinal);
|
||||
Assert.True(
|
||||
source.IndexOf(unpublish, StringComparison.Ordinal) <
|
||||
source.IndexOf("DELETE FROM sessions s", StringComparison.Ordinal),
|
||||
|
||||
Reference in New Issue
Block a user