fix(data): harden portfolio publication concurrency

This commit is contained in:
2026-06-01 09:46:18 +03:00
parent d591e5ed5a
commit 3c1a98bcc4
11 changed files with 648 additions and 79 deletions
@@ -15,6 +15,7 @@
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
@@ -0,0 +1,90 @@
using Npgsql;
using Testcontainers.PostgreSql;
namespace GmRelay.Bot.Tests.Web;
[CollectionDefinition(Name)]
public sealed class PortfolioMigrationPostgresCollection : ICollectionFixture<PortfolioMigrationPostgresFixture>
{
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<MigratedPortfolioDatabase> 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<string> 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<NpgsqlConnection> OpenConnectionAsync()
{
var connection = new NpgsqlConnection(ConnectionString);
await connection.OpenAsync().WaitAsync(TimeSpan.FromSeconds(10));
return connection;
}
}
@@ -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<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);
}
@@ -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);
}
@@ -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<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}'.");
}
}
+101 -6
View File
@@ -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, )"
}