fix(data): serialize portfolio publication validation

This commit is contained in:
2026-06-01 14:12:29 +03:00
parent 536061f63c
commit 76b3ff7ddf
9 changed files with 287 additions and 32 deletions
@@ -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);
}