From 3c1a98bcc4c01d86278125901ced5520efa81c3a Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 1 Jun 2026 09:46:18 +0300 Subject: [PATCH] fix(data): harden portfolio publication concurrency --- .../2026-05-30-completed-game-portfolio.md | 90 ++++--- ...6-05-30-completed-game-portfolio-design.md | 7 +- ..._completed_game_portfolios_and_reviews.sql | 73 +++-- .../Sessions/DiscordDeleteSessionHandler.cs | 17 ++ .../ListSessions/DeleteSessionHandler.cs | 16 +- .../GmRelay.Bot.Tests.csproj | 1 + .../Web/PortfolioMigrationPostgresFixture.cs | 90 +++++++ .../Web/PortfolioMigrationPostgresTests.cs | 254 ++++++++++++++++++ .../Web/PortfolioMigrationTests.cs | 12 +- .../PortfolioSessionDeletionSourceTests.cs | 60 +++++ tests/GmRelay.Bot.Tests/packages.lock.json | 107 +++++++- 11 files changed, 648 insertions(+), 79 deletions(-) create mode 100644 tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs create mode 100644 tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs create mode 100644 tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md index ca40d95..f945d12 100644 --- a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -91,11 +91,12 @@ public async Task MigrationV029_ShouldCreatePortfolioTablesAndPublicationGuards( Assert.Contains("CREATE INDEX ix_portfolio_games_group ON portfolio_games (group_id, completed_at DESC);", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE INDEX ix_portfolio_game_masters_player ON portfolio_game_masters (player_id, portfolio_game_id);", 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 unpublish_portfolio_game_without_required_links() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("PERFORM 1 FROM portfolio_games WHERE id = OLD.portfolio_game_id FOR UPDATE;", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("UPDATE portfolio_games SET is_public = false, updated_at = now()", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("CREATE TRIGGER trg_portfolio_game_sessions_unpublish_after_delete AFTER DELETE ON portfolio_game_sessions", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("CREATE TRIGGER trg_portfolio_game_masters_unpublish_after_delete AFTER DELETE ON portfolio_game_masters", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE FUNCTION validate_public_portfolio_game_required_links() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("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", 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", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal); + Assert.DoesNotContain("FOR UPDATE", normalizedMigration, StringComparison.Ordinal); } ``` @@ -178,47 +179,64 @@ CREATE TABLE portfolio_game_masters ( CREATE INDEX ix_portfolio_game_masters_player ON portfolio_game_masters (player_id, portfolio_game_id); -CREATE FUNCTION unpublish_portfolio_game_without_required_links() +CREATE FUNCTION validate_public_portfolio_game_required_links() RETURNS TRIGGER LANGUAGE plpgsql AS $$ +DECLARE + target_portfolio_game_id UUID; BEGIN - PERFORM 1 - FROM portfolio_games - WHERE id = OLD.portfolio_game_id - FOR UPDATE; + IF TG_TABLE_NAME = 'portfolio_games' THEN + target_portfolio_game_id := NEW.id; + ELSE + target_portfolio_game_id := OLD.portfolio_game_id; + END IF; - UPDATE portfolio_games - SET is_public = false, - updated_at = now() - WHERE id = OLD.portfolio_game_id - AND is_public = true - AND ( - NOT EXISTS ( - SELECT 1 - FROM portfolio_game_sessions - WHERE portfolio_game_id = OLD.portfolio_game_id + IF EXISTS ( + SELECT 1 + FROM portfolio_games pg + WHERE pg.id = target_portfolio_game_id + AND pg.is_public = true + AND ( + NOT EXISTS ( + SELECT 1 + FROM portfolio_game_sessions pgs + WHERE pgs.portfolio_game_id = target_portfolio_game_id + ) + OR NOT EXISTS ( + SELECT 1 + FROM portfolio_game_masters pgm + WHERE pgm.portfolio_game_id = target_portfolio_game_id + ) ) - OR NOT EXISTS ( - SELECT 1 - FROM portfolio_game_masters - WHERE portfolio_game_id = OLD.portfolio_game_id - ) - ); + ) THEN + 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'; + END IF; - RETURN OLD; + RETURN NULL; END; $$; -CREATE TRIGGER trg_portfolio_game_sessions_unpublish_after_delete -AFTER DELETE ON portfolio_game_sessions +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 unpublish_portfolio_game_without_required_links(); +EXECUTE FUNCTION validate_public_portfolio_game_required_links(); -CREATE TRIGGER trg_portfolio_game_masters_unpublish_after_delete -AFTER DELETE ON portfolio_game_masters +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 unpublish_portfolio_game_without_required_links(); +EXECUTE FUNCTION validate_public_portfolio_game_required_links(); + +CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links +AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters +DEFERRABLE INITIALLY DEFERRED +FOR EACH ROW +EXECUTE FUNCTION validate_public_portfolio_game_required_links(); CREATE TABLE portfolio_game_reviews ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -245,7 +263,7 @@ CREATE INDEX ix_portfolio_game_reviews_pending WHERE moderation_status = 'Pending'; ``` -The delete triggers retain the link-table `ON DELETE CASCADE` behavior. They lock the parent card before checking both required link sets, unpublish the card and refresh `updated_at` when either set becomes empty, preserve the first-publication `published_at`, and become harmless no-ops while the card itself or its owning club is being deleted. +The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. At transaction commit they reject a surviving published card when either required link set is empty. Child delete triggers do not lock or update the parent card, avoiding reverse lock order. Normal session-deletion handlers explicitly unpublish linked cards before deleting sessions. Card and club cascade deletion remain harmless because no published parent survives validation. - [ ] **Step 4: Run the migration tests to verify GREEN** @@ -741,10 +759,10 @@ ModeratePortfolioReviewAsync Rules: - Draft creation optionally links one session only if it belongs to the same group, is in the past, and is not linked elsewhere. -- Update runs in one transaction, locks the portfolio row, updates scalar fields, replaces child links, rejects cross-club or future sessions, and accepts only managers from the same club. +- Update runs in one transaction, locks the portfolio row, updates scalar fields, unpublishes the card before replacing required child links, replaces child links, rejects cross-club or future sessions, and accepts only managers from the same club. - Cover replacement returns the prior storage key after the database update. - Delete returns the cover key after deleting the row. -- Publishing locks the row and verifies slug, description, cover key, one or more linked past sessions, and one or more masters before setting `is_public = true` and `published_at = COALESCE(published_at, now())`. +- Publishing locks the row and verifies slug, description, cover key, one or more linked past sessions, and one or more masters before setting `is_public = true` and `published_at = COALESCE(published_at, now())`. The deferred database guard is a backstop for direct SQL and concurrent changes. - Unpublishing only sets `is_public = false`. - Moderation accepts `Approved`, `Rejected`, or `Hidden`, stores moderator ID and timestamp, and scopes the review to the managed adventure. diff --git a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md index 84cf31f..f3a4f95 100644 --- a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md +++ b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md @@ -77,9 +77,9 @@ CHECK (NOT is_public OR ( - Index on `(group_id, completed_at DESC)`. - Partial public index on `(completed_at DESC)` where `is_public = true`. -Application validation additionally requires at least one linked completed session and at least one linked GM before publishing because those requirements span child tables. +Application validation additionally requires at least one linked completed session and at least one linked GM before publishing because those requirements span child tables. Publishing locks the parent card, validates both required link sets, then sets `is_public = true` and `published_at = COALESCE(published_at, now())` so `published_at` remains the first-publication timestamp. Link replacement locks the parent card and unpublishes it before replacing required links. -Database triggers on `portfolio_game_sessions` and `portfolio_game_masters` protect the same invariant after link removal. After a link is deleted, the trigger locks the parent card, checks both required link sets, and sets `is_public = false` with a fresh `updated_at` when either set is empty. It deliberately preserves `published_at` as the timestamp of the first publication. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted, the trigger update is a harmless no-op for the disappearing parent row. +Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public or a required session/master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets. The deferred guard is a database backstop and deliberately does not lock or update a parent card from a child delete trigger, avoiding reverse lock order. Normal session-deletion handlers explicitly unpublish linked cards in the same transaction before deleting the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted, deferred validation sees no surviving published card and remains harmless. ### `portfolio_game_sessions` @@ -341,7 +341,8 @@ Follow TDD for production changes. ### Schema And Contracts -- Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, and the link-removal trigger protection. +- Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, and deferred constraint-trigger backstop. +- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, explicit unpublish before session deletion, concurrent publish/delete ordering without deadlock, and parent/card cascade deletion. - Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent. - Existing showcase tests continue to assert the future-session catalog boundary. diff --git a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql index c6aeed4..50843d1 100644 --- a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql +++ b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql @@ -52,47 +52,64 @@ CREATE TABLE portfolio_game_masters ( CREATE INDEX ix_portfolio_game_masters_player ON portfolio_game_masters (player_id, portfolio_game_id); -CREATE FUNCTION unpublish_portfolio_game_without_required_links() +CREATE FUNCTION validate_public_portfolio_game_required_links() RETURNS TRIGGER LANGUAGE plpgsql AS $$ +DECLARE + target_portfolio_game_id UUID; BEGIN - PERFORM 1 - FROM portfolio_games - WHERE id = OLD.portfolio_game_id - FOR UPDATE; + IF TG_TABLE_NAME = 'portfolio_games' THEN + target_portfolio_game_id := NEW.id; + ELSE + target_portfolio_game_id := OLD.portfolio_game_id; + END IF; - UPDATE portfolio_games - SET is_public = false, - updated_at = now() - WHERE id = OLD.portfolio_game_id - AND is_public = true - AND ( - NOT EXISTS ( - SELECT 1 - FROM portfolio_game_sessions - WHERE portfolio_game_id = OLD.portfolio_game_id + IF EXISTS ( + SELECT 1 + FROM portfolio_games pg + WHERE pg.id = target_portfolio_game_id + AND pg.is_public = true + AND ( + NOT EXISTS ( + SELECT 1 + FROM portfolio_game_sessions pgs + WHERE pgs.portfolio_game_id = target_portfolio_game_id + ) + OR NOT EXISTS ( + SELECT 1 + FROM portfolio_game_masters pgm + WHERE pgm.portfolio_game_id = target_portfolio_game_id + ) ) - OR NOT EXISTS ( - SELECT 1 - FROM portfolio_game_masters - WHERE portfolio_game_id = OLD.portfolio_game_id - ) - ); + ) THEN + 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'; + END IF; - RETURN OLD; + RETURN NULL; END; $$; -CREATE TRIGGER trg_portfolio_game_sessions_unpublish_after_delete -AFTER DELETE ON portfolio_game_sessions +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 unpublish_portfolio_game_without_required_links(); +EXECUTE FUNCTION validate_public_portfolio_game_required_links(); -CREATE TRIGGER trg_portfolio_game_masters_unpublish_after_delete -AFTER DELETE ON portfolio_game_masters +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 unpublish_portfolio_game_without_required_links(); +EXECUTE FUNCTION validate_public_portfolio_game_required_links(); + +CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links +AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters +DEFERRABLE INITIALLY DEFERRED +FOR EACH ROW +EXECUTE FUNCTION validate_public_portfolio_game_required_links(); CREATE TABLE portfolio_game_reviews ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs index 5a83a4c..d1f6462 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs @@ -43,6 +43,23 @@ public sealed class DiscordDeleteSessionHandler( } await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + await connection.ExecuteAsync( + """ + 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 + """, + new { SessionId = sessionId, GuildId = guildId }, + transaction); + var deletedRows = await connection.ExecuteAsync( """ DELETE FROM sessions s diff --git a/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs b/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs index 94e0bdb..dc88d4d 100644 --- a/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs +++ b/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs @@ -62,7 +62,21 @@ public sealed class DeleteSessionHandler( return new DeleteSessionResult(false, "Только owner или co-GM может удалять сессию.", null, null, null, false, 0); } - // 2. Delete session + // 2. Unpublish a linked portfolio card before its required session link cascades away. + await connection.ExecuteAsync( + """ + UPDATE portfolio_games pg + SET is_public = false, + updated_at = now() + FROM portfolio_game_sessions pgs + WHERE pgs.portfolio_game_id = pg.id + AND pgs.session_id = @SessionId + AND pg.is_public = true + """, + new { command.SessionId }, + transaction); + + // 3. Delete session await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction); var remainingInTopic = session.ThreadId.HasValue diff --git a/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj b/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj index f88c60e..215330c 100644 --- a/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj +++ b/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj @@ -15,6 +15,7 @@ + diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs new file mode 100644 index 0000000..e80468c --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs @@ -0,0 +1,90 @@ +using Npgsql; +using Testcontainers.PostgreSql; + +namespace GmRelay.Bot.Tests.Web; + +[CollectionDefinition(Name)] +public sealed class PortfolioMigrationPostgresCollection : ICollectionFixture +{ + public const string Name = "Portfolio migration PostgreSQL"; +} + +public sealed class PortfolioMigrationPostgresFixture : IAsyncLifetime +{ + private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(2); + private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build(); + + public Task InitializeAsync() + { + return container.StartAsync().WaitAsync(ContainerTimeout); + } + + public Task DisposeAsync() + { + return container.DisposeAsync().AsTask().WaitAsync(ContainerTimeout); + } + + public async Task CreateMigratedDatabaseAsync() + { + var databaseName = $"portfolio_{Guid.NewGuid():N}"; + + await using (var adminConnection = new NpgsqlConnection(container.GetConnectionString())) + { + await adminConnection.OpenAsync().WaitAsync(ContainerTimeout); + await using var createDatabase = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", adminConnection); + await createDatabase.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout); + } + + var connectionString = new NpgsqlConnectionStringBuilder(container.GetConnectionString()) + { + Database = databaseName, + Timeout = 10, + CommandTimeout = 10 + }.ConnectionString; + + var migrations = GetMigrationPaths(); + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync().WaitAsync(ContainerTimeout); + + foreach (var migration in migrations) + { + await using var command = new NpgsqlCommand(await File.ReadAllTextAsync(migration), connection) + { + CommandTimeout = 30 + }; + await command.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout); + } + + return new MigratedPortfolioDatabase(connectionString, migrations.Count); + } + + private static IReadOnlyList GetMigrationPaths() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var migrationsDirectory = Path.Combine(directory.FullName, "src", "GmRelay.Bot", "Migrations"); + if (Directory.Exists(migrationsDirectory)) + { + return Directory.GetFiles(migrationsDirectory, "V*.sql") + .Where(path => string.CompareOrdinal(Path.GetFileName(path), "V030__") < 0) + .OrderBy(path => Path.GetFileName(path), StringComparer.Ordinal) + .ToArray(); + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not locate the bot migrations directory."); + } +} + +public sealed record MigratedPortfolioDatabase(string ConnectionString, int AppliedMigrationCount) +{ + public async Task OpenConnectionAsync() + { + var connection = new NpgsqlConnection(ConnectionString); + await connection.OpenAsync().WaitAsync(TimeSpan.FromSeconds(10)); + return connection; + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs new file mode 100644 index 0000000..9306172 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs @@ -0,0 +1,254 @@ +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); +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs index dd74505..a5d1bce 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs @@ -30,11 +30,13 @@ public sealed class PortfolioMigrationTests Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_moderator ON portfolio_game_reviews (moderated_by_player_id) WHERE moderated_by_player_id IS NOT NULL;", normalizedMigration, StringComparison.Ordinal); 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 unpublish_portfolio_game_without_required_links() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("PERFORM 1 FROM portfolio_games WHERE id = OLD.portfolio_game_id FOR UPDATE;", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("UPDATE portfolio_games SET is_public = false, updated_at = now() WHERE id = OLD.portfolio_game_id AND is_public = true AND (NOT EXISTS (SELECT 1 FROM portfolio_game_sessions WHERE portfolio_game_id = OLD.portfolio_game_id) OR NOT EXISTS (SELECT 1 FROM portfolio_game_masters WHERE portfolio_game_id = OLD.portfolio_game_id));", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("CREATE TRIGGER trg_portfolio_game_sessions_unpublish_after_delete AFTER DELETE ON portfolio_game_sessions FOR EACH ROW EXECUTE FUNCTION unpublish_portfolio_game_without_required_links();", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("CREATE TRIGGER trg_portfolio_game_masters_unpublish_after_delete AFTER DELETE ON portfolio_game_masters FOR EACH ROW EXECUTE FUNCTION unpublish_portfolio_game_without_required_links();", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE FUNCTION validate_public_portfolio_game_required_links() RETURNS TRIGGER LANGUAGE plpgsql", 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); + Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal); + Assert.DoesNotContain("unpublish_portfolio_game_without_required_links", normalizedMigration, StringComparison.Ordinal); + Assert.DoesNotContain("FOR UPDATE", normalizedMigration, StringComparison.Ordinal); Assert.DoesNotContain("published_at = NULL", normalizedMigration, StringComparison.OrdinalIgnoreCase); Assert.Contains("publication_consent_at TIMESTAMPTZ NOT NULL,", normalizedMigration, StringComparison.Ordinal); } diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs new file mode 100644 index 0000000..f32c441 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs @@ -0,0 +1,60 @@ +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioSessionDeletionSourceTests +{ + [Fact] + public async Task SharedDeleteSessionHandler_ShouldUnpublishLinkedPortfolioCardBeforeDeletingSession() + { + var source = NormalizeSql(await ReadRepositoryFileAsync( + "src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs")); + + const string unpublish = + "UPDATE portfolio_games pg SET is_public = false, updated_at = now() FROM portfolio_game_sessions pgs WHERE pgs.portfolio_game_id = pg.id AND pgs.session_id = @SessionId AND pg.is_public = true"; + + Assert.Contains(unpublish, source, StringComparison.Ordinal); + Assert.True( + source.IndexOf(unpublish, StringComparison.Ordinal) < + source.IndexOf("DELETE FROM sessions WHERE id = @Id", StringComparison.Ordinal), + "Linked public portfolio cards must be unpublished before deleting the session."); + } + + [Fact] + public async Task DiscordDeleteSessionHandler_ShouldUnpublishOnlyCardsFromTheInteractionGuildBeforeDeletingSession() + { + var source = NormalizeSql(await ReadRepositoryFileAsync( + "src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs")); + + const string unpublish = + "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.True( + source.IndexOf(unpublish, StringComparison.Ordinal) < + source.IndexOf("DELETE FROM sessions s", StringComparison.Ordinal), + "Discord cards must be unpublished before deleting the session."); + } + + private static string NormalizeSql(string sql) + { + return string.Join(' ', sql.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) + .Replace("( ", "(", StringComparison.Ordinal) + .Replace(" )", ")", StringComparison.Ordinal); + } + + private static async Task 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}'."); + } +} diff --git a/tests/GmRelay.Bot.Tests/packages.lock.json b/tests/GmRelay.Bot.Tests/packages.lock.json index d350362..385f11b 100644 --- a/tests/GmRelay.Bot.Tests/packages.lock.json +++ b/tests/GmRelay.Bot.Tests/packages.lock.json @@ -34,6 +34,15 @@ "resolved": "5.6.7", "contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ==" }, + "Testcontainers.PostgreSql": { + "type": "Direct", + "requested": "[4.12.0, )", + "resolved": "4.12.0", + "contentHash": "LZcQu4vfcYuzzy2ENOb7giFb6WNztEEJbufsm7kGiQxjallVuzkWxrBL8LwnjlXGW939pgEZFstL5cO0R2XrBQ==", + "dependencies": { + "Testcontainers": "4.12.0" + } + }, "xunit": { "type": "Direct", "requested": "[2.9.3, )", @@ -70,6 +79,11 @@ "Npgsql": "8.0.3" } }, + "BouncyCastle.Cryptography": { + "type": "Transitive", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + }, "Dapper": { "type": "Transitive", "resolved": "2.1.72", @@ -94,6 +108,63 @@ "dbup-core": "6.1.1" } }, + "Docker.DotNet.Enhanced": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "tm2V/DpnaURbBhMQ7Z3orNR3u+H863KQuYfA/sgGjI5py07dEeV0I02f6pGrx2869KG9uNM/E96puf9i0gId2w==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0", + "Docker.DotNet.Enhanced.LegacyHttp": "4.2.0", + "Docker.DotNet.Enhanced.NPipe": "4.2.0", + "Docker.DotNet.Enhanced.NativeHttp": "4.2.0", + "Docker.DotNet.Enhanced.Unix": "4.2.0" + } + }, + "Docker.DotNet.Enhanced.Handler.Abstractions": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "cQNxpdadEPdNdfjFCl9vgoCQIK3aVHRn1Qlu36aZUFpp4xHfPrk4hRPNVLR/CpobIFJ+dAt8AceTKMlCfPSccw==" + }, + "Docker.DotNet.Enhanced.LegacyHttp": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "sfbMX1HBPUec3PEMoqlP5ak6skXclcTBmu4gG3aUJatP34J2DgvYMP13bvz/rfrjVkAhPqnIiDKiHAkBCokajg==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0" + } + }, + "Docker.DotNet.Enhanced.NativeHttp": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "/ll+2ePYm1qrsMdgMO5BzCQnbfTGmPJAc9SqXEManbliVBZvEpBKHXLugx/OeEca2oC/b4RV+UNPtue5u4jAuA==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0" + } + }, + "Docker.DotNet.Enhanced.NPipe": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "8wyYOD6VkvqRkITwsvkt3UbW/1WDl6NFypNAsIIDaMiglNRzFrQcK0nK9VUEZa6Oja8Bso3UYySDoL8qatatAA==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0" + } + }, + "Docker.DotNet.Enhanced.Unix": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "x0wNcbww1+p9nUfw8i+JvsSArBDGkoZ9GI2PZ1wPo85B2OiFrdzp89omounNhO2GKyaIRWAqAm5jYZyNg9EnxA==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0" + } + }, + "Docker.DotNet.Enhanced.X509": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "nMw+FHGwGZieDi7kBgpIVl+E8MzjzXeXHvMQpidLADT06fts2Gw6G+K+p0hMGv7liZULxyYiZnQ1UbE2B9NNQg==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0" + } + }, "Microsoft.AspNetCore.TestHost": { "type": "Transitive", "resolved": "10.0.5", @@ -341,11 +412,35 @@ "Polly.Core": "8.4.2" } }, + "SharpZipLib": { + "type": "Transitive", + "resolved": "1.4.2", + "contentHash": "yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A==" + }, + "SSH.NET": { + "type": "Transitive", + "resolved": "2025.1.0", + "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==", + "dependencies": { + "BouncyCastle.Cryptography": "2.6.2" + } + }, "Telegram.Bot": { "type": "Transitive", "resolved": "22.9.6.1", "contentHash": "I0eaMaETcWIhMn4uu4RGd9e6PLJOjaOG3QAcKPsTcS80H3TF6gqj3UF9NKu4ZY90ul6Y6NiWToHkg/PsvxkotA==" }, + "Testcontainers": { + "type": "Transitive", + "resolved": "4.12.0", + "contentHash": "PTZRdG1ZVkFMsFbc3cK/VUaOB5L3l4wYL+OkWAK33/cvgd/5FcmZlQ6NhMAl3PWBqYkpdWmeYmQW9U2OIXqtFA==", + "dependencies": { + "Docker.DotNet.Enhanced": "4.2.0", + "Docker.DotNet.Enhanced.X509": "4.2.0", + "SSH.NET": "2025.1.0", + "SharpZipLib": "1.4.2" + } + }, "xunit.abstractions": { "type": "Transitive", "resolved": "2.0.3", @@ -392,8 +487,8 @@ "Aspire.Npgsql": "[13.2.2, )", "Dapper": "[2.1.72, )", "Dapper.AOT": "[1.0.48, )", - "GmRelay.ServiceDefaults": "[3.0.9, )", - "GmRelay.Shared": "[3.0.9, )", + "GmRelay.ServiceDefaults": "[3.5.1, )", + "GmRelay.Shared": "[3.5.1, )", "Npgsql": "[10.0.2, )", "Telegram.Bot": "[22.9.5.3, )", "dbup-postgresql": "[7.0.1, )" @@ -405,8 +500,8 @@ "Aspire.Npgsql": "[13.2.2, )", "Dapper": "[2.1.72, )", "Dapper.AOT": "[1.0.48, )", - "GmRelay.ServiceDefaults": "[3.0.9, )", - "GmRelay.Shared": "[3.0.9, )", + "GmRelay.ServiceDefaults": "[3.5.1, )", + "GmRelay.Shared": "[3.5.1, )", "NetCord.Hosting": "[1.0.0-alpha.489, )", "NetCord.Hosting.Services": "[1.0.0-alpha.489, )", "NetCord.Services": "[1.0.0-alpha.489, )", @@ -437,8 +532,8 @@ "dependencies": { "Aspire.Npgsql": "[13.2.2, )", "Dapper": "[2.1.72, )", - "GmRelay.ServiceDefaults": "[3.0.9, )", - "GmRelay.Shared": "[3.0.9, )", + "GmRelay.ServiceDefaults": "[3.5.1, )", + "GmRelay.Shared": "[3.5.1, )", "Npgsql": "[10.0.2, )", "Telegram.Bot": "[22.9.6.1, )" }