books/backend/Books.Api.Tests/Infrastructure/TestDatabase.cs

122 lines
3.7 KiB
C#
Raw Normal View History

using Npgsql;
namespace Books.Api.Tests.Infrastructure;
/// <summary>
/// Creates an isolated PostgreSQL database for each test run.
/// The database is automatically dropped when disposed.
/// </summary>
public class TestDatabase : IDisposable
{
public const string DefaultConnectionString =
"Host=localhost;Port=5432;Username=postgres;Password=postgres;Database=postgres;Include Error Detail=true";
private readonly string _masterConnectionString;
private bool _disposed;
public string DatabaseName { get; }
public string ConnectionString
{
get
{
var builder = new NpgsqlConnectionStringBuilder(_masterConnectionString)
{
Database = DatabaseName
};
return builder.ToString();
}
}
public TestDatabase(string? masterConnectionString = null)
{
_masterConnectionString = masterConnectionString ?? GetConnectionStringFromEnvironment();
DatabaseName = $"books_test_{Guid.NewGuid():N}";
CreateDatabase();
}
private static string GetConnectionStringFromEnvironment()
{
return Environment.GetEnvironmentVariable("TEST_CONNECTION_STRING")
?? DefaultConnectionString;
}
private void CreateDatabase()
{
try
{
using var connection = new NpgsqlConnection(_masterConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = $"CREATE DATABASE \"{DatabaseName}\"";
command.ExecuteNonQuery();
Console.WriteLine($"[TestDatabase] Created test database: {DatabaseName}");
}
catch (Exception e)
{
throw new InvalidOperationException(
$"Failed to create test database '{DatabaseName}' using connection string: {_masterConnectionString}",
e);
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
try
{
// Step 1: Terminate all active connections to prevent "database in use" errors
using (var connection = new NpgsqlConnection(_masterConnectionString))
{
connection.Open();
using var terminateCommand = connection.CreateCommand();
terminateCommand.CommandText = $@"
SELECT pg_terminate_backend(pg_stat_activity.pid)
FROM pg_stat_activity
WHERE pg_stat_activity.datname = '{DatabaseName}'
AND pid <> pg_backend_pid();
";
terminateCommand.ExecuteNonQuery();
}
// Step 2: Drop the database
using (var connection = new NpgsqlConnection(_masterConnectionString))
{
connection.Open();
using var dropCommand = connection.CreateCommand();
dropCommand.CommandText = $"DROP DATABASE IF EXISTS \"{DatabaseName}\"";
dropCommand.ExecuteNonQuery();
}
Console.WriteLine($"[TestDatabase] Dropped test database: {DatabaseName}");
}
catch (Exception e)
{
// Log but don't throw - we don't want cleanup failures to mask test failures
Console.WriteLine($"[TestDatabase] Warning: Failed to drop test database {DatabaseName}: {e.Message}");
}
}
_disposed = true;
}
~TestDatabase()
{
Dispose(false);
}
}