using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.CreateSession; using GmRelay.Shared.Platform; using Npgsql; using Testcontainers.PostgreSql; namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession; [CollectionDefinition(Name)] public sealed class CreateSessionHandlerPostgresCollection : ICollectionFixture { public const string Name = "Create session handler PostgreSQL"; } public sealed class CreateSessionHandlerPostgresFixture : IAsyncLifetime { private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(5); 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 = $"create_session_{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 = 30 }.ConnectionString; await using var connection = new NpgsqlConnection(connectionString); await connection.OpenAsync().WaitAsync(ContainerTimeout); foreach (var migration in GetMigrationPaths()) { await using var command = new NpgsqlCommand(await File.ReadAllTextAsync(migration), connection) { CommandTimeout = 30 }; await command.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout); } return connectionString; } 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") .OrderBy(path => Path.GetFileName(path), StringComparer.Ordinal) .ToArray(); } directory = directory.Parent; } throw new DirectoryNotFoundException("Could not locate the bot migrations directory."); } } [Collection(CreateSessionHandlerPostgresCollection.Name)] public sealed class CreateSessionHandlerIntegrationTests(CreateSessionHandlerPostgresFixture fixture) { [Fact] public async Task HandleAsync_NewPlatformGroup_AddsOwnerAndPersistsSession() { var connectionString = await fixture.CreateMigratedDatabaseAsync(); await using var dataSource = NpgsqlDataSource.Create(connectionString); var sut = new CreateSessionHandler(dataSource); var result = await sut.HandleAsync( new CreateSessionCommand( new PlatformUser(PlatformKind.Telegram, "111111111", "Test GM", "test_gm"), new PlatformGroup(PlatformKind.Telegram, "222222222", "Test Group"), "Test Adventure", "https://vtt.example/game", [DateTimeOffset.UtcNow.AddDays(1)], null, null, GameSystem.Dnd5e, "Integration regression test", "Online", 240, true, "Online room notes"), CancellationToken.None); Assert.True(result.Success, result.ErrorMessage); Assert.NotNull(result.BatchId); Assert.NotNull(result.GroupId); await using var connection = await dataSource.OpenConnectionAsync(); await using var command = new NpgsqlCommand( """ SELECT count(*) FROM group_managers gm JOIN players p ON p.id = gm.player_id WHERE gm.group_id = @group_id AND gm.role = 'Owner' AND p.platform = 'Telegram' AND p.external_user_id = '111111111' """, connection); command.Parameters.AddWithValue("group_id", result.GroupId.Value); var ownerCount = (long)(await command.ExecuteScalarAsync() ?? 0L); Assert.Equal(1, ownerCount); await using var sessionCommand = new NpgsqlCommand( """ SELECT join_link, format, location_address FROM sessions WHERE batch_id = @batch_id """, connection); sessionCommand.Parameters.AddWithValue("batch_id", result.BatchId.Value); await using var reader = await sessionCommand.ExecuteReaderAsync(); Assert.True(await reader.ReadAsync()); Assert.Equal("https://vtt.example/game", reader.GetString(0)); Assert.Equal("Online", reader.GetString(1)); Assert.Equal("Online room notes", reader.GetString(2)); Assert.False(await reader.ReadAsync()); } [Fact] public async Task HandleAsync_OfflineSession_PersistsFormatAndLocationAddress() { var connectionString = await fixture.CreateMigratedDatabaseAsync(); await using var dataSource = NpgsqlDataSource.Create(connectionString); var sut = new CreateSessionHandler(dataSource); var result = await sut.HandleAsync( new CreateSessionCommand( new PlatformUser(PlatformKind.Telegram, "333333333", "Offline GM", "offline_gm"), new PlatformGroup(PlatformKind.Telegram, "444444444", "Offline Group"), "Offline Adventure", string.Empty, [DateTimeOffset.UtcNow.AddDays(1)], 4, null, GameSystem.Dnd5e, "Offline integration regression test", "Offline", 240, true, "Москва, ул. Кубиков, 12"), CancellationToken.None); Assert.True(result.Success, result.ErrorMessage); Assert.NotNull(result.BatchId); await using var connection = await dataSource.OpenConnectionAsync(); await using var command = new NpgsqlCommand( """ SELECT join_link, format, location_address FROM sessions WHERE batch_id = @batch_id """, connection); command.Parameters.AddWithValue("batch_id", result.BatchId.Value); await using var reader = await command.ExecuteReaderAsync(); Assert.True(await reader.ReadAsync()); Assert.Equal(string.Empty, reader.GetString(0)); Assert.Equal("Offline", reader.GetString(1)); Assert.Equal("Москва, ул. Кубиков, 12", reader.GetString(2)); Assert.False(await reader.ReadAsync()); } }