using System;
using System.Threading;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using Npgsql;
using Testcontainers.PostgreSql;
namespace Jellyfin.Server.Tests.Migrations;
///
/// Hands out a PostgreSQL server for the tests that need one. A server named by
/// JELLYFIN_TEST_POSTGRES is used as is, so CI can attach a service container instead of running
/// a docker daemon of its own; without it a container is started through testcontainers.
///
public sealed class PostgreSqlTestServer : IAsyncDisposable
{
///
/// The connection string of an already running server. Must be able to create databases.
///
public const string ConnectionStringVariable = "JELLYFIN_TEST_POSTGRES";
private readonly PostgreSqlContainer? _container;
private PostgreSqlTestServer(PostgreSqlContainer? container, string connectionString)
{
_container = container;
ConnectionString = connectionString;
}
///
/// Gets the connection string of the server holding the test databases.
///
public string ConnectionString { get; }
///
/// Starts or attaches to a PostgreSQL server and waits until it accepts connections.
///
/// The running server.
public static async Task StartAsync()
{
var provided = Environment.GetEnvironmentVariable(ConnectionStringVariable);
if (!string.IsNullOrWhiteSpace(provided))
{
var attached = new PostgreSqlTestServer(null, provided);
await attached.WaitUntilReadyAsync().ConfigureAwait(false);
return attached;
}
var container = new PostgreSqlBuilder("postgres:16-alpine")
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
.Build();
await container.StartAsync().ConfigureAwait(false);
var started = new PostgreSqlTestServer(container, container.GetConnectionString());
await started.WaitUntilReadyAsync().ConfigureAwait(false);
return started;
}
///
/// Creates an empty database and returns a connection string pointing at it.
///
/// The database name.
/// The cancellation token.
/// The connection string of the new database.
public async Task CreateDatabaseAsync(string name, CancellationToken cancellationToken)
{
await using var adminDataSource = new NpgsqlDataSourceBuilder(ConnectionString).Build();
// A server handed in through the environment outlives the run, so a second run finds the
// databases the first one left behind.
await using var drop = adminDataSource.CreateCommand($"DROP DATABASE IF EXISTS {name}");
await drop.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
await using var command = adminDataSource.CreateCommand($"CREATE DATABASE {name}");
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return new NpgsqlConnectionStringBuilder(ConnectionString) { Database = name }.ConnectionString;
}
///
public async ValueTask DisposeAsync()
{
if (_container is not null)
{
await _container.DisposeAsync().ConfigureAwait(false);
}
}
///
/// Waits until a real connection is accepted. pg_isready also answers for the short-lived server
/// the entrypoint runs while it initializes the data directory.
///
private async Task WaitUntilReadyAsync()
{
await using var dataSource = new NpgsqlDataSourceBuilder(ConnectionString).Build();
for (var attempt = 1; ; attempt++)
{
try
{
await using var command = dataSource.CreateCommand("SELECT 1");
await command.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
return;
}
catch (NpgsqlException) when (attempt < 60)
{
await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
}
}
}
}