From a53b84c02f0fc2d6b53a92128a8baf75eb4fedd5 Mon Sep 17 00:00:00 2001 From: Medkit Date: Tue, 27 Jan 2026 15:26:45 +0500 Subject: [PATCH] [Tests] Start Postgres container when local DB is unavailable --- Directory.Packages.props | 4 +- test/Directory.Build.props | 2 + .../AssemblySetUp.cs | 112 ++++++++++++++++++ test/Npgsql.PluginTests/AssemblySetUp.cs | 112 ++++++++++++++++++ .../NpgsqlDbFactoryFixture.cs | 86 +++++++++++++- test/Npgsql.Tests/Support/AssemblySetUp.cs | 66 ++++++++++- test/containers/postgres/init-db.sh | 49 ++++++++ 7 files changed, 426 insertions(+), 5 deletions(-) create mode 100644 test/Npgsql.DependencyInjection.Tests/AssemblySetUp.cs create mode 100644 test/Npgsql.PluginTests/AssemblySetUp.cs create mode 100644 test/containers/postgres/init-db.sh diff --git a/Directory.Packages.props b/Directory.Packages.props index 140a32607a..a168361822 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -33,7 +33,7 @@ - + @@ -41,6 +41,8 @@ + + diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 6af6edc496..dd5ffb8c70 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -11,5 +11,7 @@ + + diff --git a/test/Npgsql.DependencyInjection.Tests/AssemblySetUp.cs b/test/Npgsql.DependencyInjection.Tests/AssemblySetUp.cs new file mode 100644 index 0000000000..ca905cb912 --- /dev/null +++ b/test/Npgsql.DependencyInjection.Tests/AssemblySetUp.cs @@ -0,0 +1,112 @@ +using DotNet.Testcontainers.Configurations; +using Npgsql; +using Npgsql.Tests; +using NUnit.Framework; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Testcontainers.PostgreSql; + +[SetUpFixture] +public class AssemblySetUp +{ + static PostgreSqlContainer? _container; + + [OneTimeSetUp] + public async Task Setup() + { + var connString = TestUtil.ConnectionString; + using var conn = new NpgsqlConnection(connString); + try + { + conn.Open(); + } + catch (NpgsqlException e) when (e.IsTransient) + { + _container ??= SetupContainerAsync(); + await _container.StartAsync(); + + var containerConnString = _container.GetConnectionString(); + Environment.SetEnvironmentVariable("NPGSQL_TEST_DB", containerConnString); + await using var containerConn = new NpgsqlConnection(containerConnString); + await containerConn.OpenAsync(); + return; + } + catch (PostgresException e) + { + if (e.SqlState == PostgresErrorCodes.InvalidPassword && connString == TestUtil.DefaultConnectionString) + throw new Exception("Please create a user npgsql_tests as follows: CREATE USER npgsql_tests PASSWORD 'npgsql_tests' SUPERUSER"); + + if (e.SqlState == PostgresErrorCodes.InvalidCatalogName) + { + var builder = new NpgsqlConnectionStringBuilder(connString) + { + Pooling = false, + Multiplexing = false, + Database = "postgres" + }; + + using var adminConn = new NpgsqlConnection(builder.ConnectionString); + adminConn.Open(); + adminConn.ExecuteNonQuery("CREATE DATABASE " + conn.Database); + adminConn.Close(); + Thread.Sleep(1000); + + conn.Open(); + return; + } + + throw; + } + } + + [OneTimeTearDown] + public async Task Teardown() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + static PostgreSqlContainer SetupContainerAsync() + { + var repoRoot = GetRepoRoot(); + var initScriptPath = Path.Combine(repoRoot, "test", "containers", "postgres", "init-db.sh"); + var certsPath = Path.Combine(repoRoot, ".build"); + + if (!File.Exists(initScriptPath)) + throw new InvalidOperationException($"Init script not found: {initScriptPath}"); + if (!Directory.Exists(certsPath)) + throw new InvalidOperationException($"Certs directory not found: {certsPath}"); + + var image = Environment.GetEnvironmentVariable("NPGSQL_TEST_IMAGE") ?? "postgres:18"; + + var builder = new PostgreSqlBuilder(image) + .WithDatabase("npgsql_tests") + .WithUsername("npgsql_tests") + .WithPassword("npgsql_tests") + .WithPortBinding(5432, false) + .WithBindMount(certsPath, "/certs", AccessMode.ReadOnly) + .WithBindMount(initScriptPath, "/docker-entrypoint-initdb.d/01-init-db.sh", AccessMode.ReadOnly); + + if (!OperatingSystem.IsWindows()) + builder = builder.WithBindMount("/tmp", "/tmp"); + + return builder.Build(); + } + + static string GetRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + if (File.Exists(Path.Combine(dir.FullName, "Npgsql.slnx")) || Directory.Exists(Path.Combine(dir.FullName, ".git"))) + return dir.FullName; + dir = dir.Parent; + } + + throw new InvalidOperationException("Could not locate repo root for testcontainers assets."); + } +} diff --git a/test/Npgsql.PluginTests/AssemblySetUp.cs b/test/Npgsql.PluginTests/AssemblySetUp.cs new file mode 100644 index 0000000000..ca905cb912 --- /dev/null +++ b/test/Npgsql.PluginTests/AssemblySetUp.cs @@ -0,0 +1,112 @@ +using DotNet.Testcontainers.Configurations; +using Npgsql; +using Npgsql.Tests; +using NUnit.Framework; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Testcontainers.PostgreSql; + +[SetUpFixture] +public class AssemblySetUp +{ + static PostgreSqlContainer? _container; + + [OneTimeSetUp] + public async Task Setup() + { + var connString = TestUtil.ConnectionString; + using var conn = new NpgsqlConnection(connString); + try + { + conn.Open(); + } + catch (NpgsqlException e) when (e.IsTransient) + { + _container ??= SetupContainerAsync(); + await _container.StartAsync(); + + var containerConnString = _container.GetConnectionString(); + Environment.SetEnvironmentVariable("NPGSQL_TEST_DB", containerConnString); + await using var containerConn = new NpgsqlConnection(containerConnString); + await containerConn.OpenAsync(); + return; + } + catch (PostgresException e) + { + if (e.SqlState == PostgresErrorCodes.InvalidPassword && connString == TestUtil.DefaultConnectionString) + throw new Exception("Please create a user npgsql_tests as follows: CREATE USER npgsql_tests PASSWORD 'npgsql_tests' SUPERUSER"); + + if (e.SqlState == PostgresErrorCodes.InvalidCatalogName) + { + var builder = new NpgsqlConnectionStringBuilder(connString) + { + Pooling = false, + Multiplexing = false, + Database = "postgres" + }; + + using var adminConn = new NpgsqlConnection(builder.ConnectionString); + adminConn.Open(); + adminConn.ExecuteNonQuery("CREATE DATABASE " + conn.Database); + adminConn.Close(); + Thread.Sleep(1000); + + conn.Open(); + return; + } + + throw; + } + } + + [OneTimeTearDown] + public async Task Teardown() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + static PostgreSqlContainer SetupContainerAsync() + { + var repoRoot = GetRepoRoot(); + var initScriptPath = Path.Combine(repoRoot, "test", "containers", "postgres", "init-db.sh"); + var certsPath = Path.Combine(repoRoot, ".build"); + + if (!File.Exists(initScriptPath)) + throw new InvalidOperationException($"Init script not found: {initScriptPath}"); + if (!Directory.Exists(certsPath)) + throw new InvalidOperationException($"Certs directory not found: {certsPath}"); + + var image = Environment.GetEnvironmentVariable("NPGSQL_TEST_IMAGE") ?? "postgres:18"; + + var builder = new PostgreSqlBuilder(image) + .WithDatabase("npgsql_tests") + .WithUsername("npgsql_tests") + .WithPassword("npgsql_tests") + .WithPortBinding(5432, false) + .WithBindMount(certsPath, "/certs", AccessMode.ReadOnly) + .WithBindMount(initScriptPath, "/docker-entrypoint-initdb.d/01-init-db.sh", AccessMode.ReadOnly); + + if (!OperatingSystem.IsWindows()) + builder = builder.WithBindMount("/tmp", "/tmp"); + + return builder.Build(); + } + + static string GetRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + if (File.Exists(Path.Combine(dir.FullName, "Npgsql.slnx")) || Directory.Exists(Path.Combine(dir.FullName, ".git"))) + return dir.FullName; + dir = dir.Parent; + } + + throw new InvalidOperationException("Could not locate repo root for testcontainers assets."); + } +} diff --git a/test/Npgsql.Specification.Tests/NpgsqlDbFactoryFixture.cs b/test/Npgsql.Specification.Tests/NpgsqlDbFactoryFixture.cs index 6d8fcbad17..9109e46abf 100644 --- a/test/Npgsql.Specification.Tests/NpgsqlDbFactoryFixture.cs +++ b/test/Npgsql.Specification.Tests/NpgsqlDbFactoryFixture.cs @@ -1,16 +1,98 @@ using System; using System.Data.Common; +using System.IO; +using System.Net.Sockets; +using System.Threading.Tasks; using AdoNet.Specification.Tests; +using DotNet.Testcontainers.Configurations; +using Testcontainers.PostgreSql; +using Xunit; namespace Npgsql.Specification.Tests; -public class NpgsqlDbFactoryFixture : IDbFactoryFixture +public class NpgsqlDbFactoryFixture : IDbFactoryFixture, IAsyncLifetime { public DbProviderFactory Factory => NpgsqlFactory.Instance; const string DefaultConnectionString = "Server=localhost;Username=npgsql_tests;Password=npgsql_tests;Database=npgsql_tests;Timeout=0;Command Timeout=0"; + static readonly Lazy _initializeTask = new(() => InitializeCoreAsync()); public string ConnectionString => Environment.GetEnvironmentVariable("NPGSQL_TEST_DB") ?? DefaultConnectionString; -} \ No newline at end of file + static PostgreSqlContainer? _container; + + public NpgsqlDbFactoryFixture() => EnsureInitialized(); + + public Task InitializeAsync() => _initializeTask.Value; + + public Task DisposeAsync() => Task.CompletedTask; + + static void EnsureInitialized() => _initializeTask.Value.GetAwaiter().GetResult(); + + static async Task InitializeCoreAsync() + { + var connString = Environment.GetEnvironmentVariable("NPGSQL_TEST_DB") ?? DefaultConnectionString; + await using var conn = new NpgsqlConnection(connString); + try + { + await conn.OpenAsync(); + } + catch (NpgsqlException e) when (e.InnerException is SocketException) + { + _container ??= SetupContainer(); + await _container.StartAsync(); + + var containerConnString = _container.GetConnectionString(); + Environment.SetEnvironmentVariable("NPGSQL_TEST_DB", containerConnString); + + await using var containerConn = new NpgsqlConnection(containerConnString); + await containerConn.OpenAsync(); + await containerConn.CloseAsync(); + } + finally + { + await conn.CloseAsync(); + } + } + + static PostgreSqlContainer SetupContainer() + { + var repoRoot = GetRepoRoot(); + var initScriptPath = Path.Combine(repoRoot, "test", "containers", "postgres", "init-db.sh"); + var certsPath = Path.Combine(repoRoot, ".build"); + + if (!File.Exists(initScriptPath)) + throw new InvalidOperationException($"Init script not found: {initScriptPath}"); + if (!Directory.Exists(certsPath)) + throw new InvalidOperationException($"Certs directory not found: {certsPath}"); + + var image = Environment.GetEnvironmentVariable("NPGSQL_TEST_IMAGE") ?? "postgres:18"; + + var builder = new PostgreSqlBuilder(image) + .WithDatabase("npgsql_tests") + .WithUsername("npgsql_tests") + .WithPassword("npgsql_tests") + .WithPortBinding(5432, false) + .WithBindMount(certsPath, "/certs", AccessMode.ReadOnly) + .WithBindMount(initScriptPath, "/docker-entrypoint-initdb.d/01-init-db.sh", AccessMode.ReadOnly); + + if (!OperatingSystem.IsWindows()) + builder = builder.WithBindMount("/tmp", "/tmp"); + + return builder.Build(); + } + + static string GetRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + if (File.Exists(Path.Combine(dir.FullName, "Npgsql.slnx")) || Directory.Exists(Path.Combine(dir.FullName, ".git"))) + return dir.FullName; + dir = dir.Parent; + } + + throw new InvalidOperationException("Could not locate repo root for testcontainers assets."); + } +} diff --git a/test/Npgsql.Tests/Support/AssemblySetUp.cs b/test/Npgsql.Tests/Support/AssemblySetUp.cs index f1619ecec4..bb0be0c25b 100644 --- a/test/Npgsql.Tests/Support/AssemblySetUp.cs +++ b/test/Npgsql.Tests/Support/AssemblySetUp.cs @@ -1,14 +1,20 @@ -using Npgsql; +using DotNet.Testcontainers.Configurations; +using Npgsql; using Npgsql.Tests; using NUnit.Framework; using System; +using System.IO; +using System.Net.Sockets; using System.Threading; +using System.Threading.Tasks; +using Testcontainers.PostgreSql; [SetUpFixture] public class AssemblySetUp { + static PostgreSqlContainer? _container; [OneTimeSetUp] - public void Setup() + public async Task Setup() { var connString = TestUtil.ConnectionString; using var conn = new NpgsqlConnection(connString); @@ -16,6 +22,13 @@ public void Setup() { conn.Open(); } + catch (NpgsqlException e) when (e.InnerException is SocketException) + { + _container ??= await SetupContainerAsync(); + await _container.StartAsync(); + conn.Open(); + return; + } catch (PostgresException e) { if (e.SqlState == PostgresErrorCodes.InvalidPassword && connString == TestUtil.DefaultConnectionString) @@ -43,4 +56,53 @@ public void Setup() throw; } } + + //[OneTimeTearDown] + //public async Task Teardown() + //{ + // if (_container != null) + // { + // await _container.DisposeAsync(); + // } + //} + + static async Task SetupContainerAsync() + { + var repoRoot = GetRepoRoot(); + var initScriptPath = Path.Combine(repoRoot, "test", "containers", "postgres", "init-db.sh"); + var certsPath = Path.Combine(repoRoot, ".build"); + + if (!File.Exists(initScriptPath)) + throw new InvalidOperationException($"Init script not found: {initScriptPath}"); + if (!Directory.Exists(certsPath)) + throw new InvalidOperationException($"Certs directory not found: {certsPath}"); + + var image = Environment.GetEnvironmentVariable("NPGSQL_TEST_IMAGE") ?? "postgres:18"; + + var builder = new PostgreSqlBuilder(image) + .WithDatabase("npgsql_tests") + .WithUsername("npgsql_tests") + .WithPassword("npgsql_tests") + .WithPortBinding(5432, false) + .WithBindMount(certsPath, "/certs", AccessMode.ReadOnly) + .WithBindMount(initScriptPath, "/docker-entrypoint-initdb.d/01-init-db.sh", AccessMode.ReadOnly); + + if (!OperatingSystem.IsWindows()) + builder = builder.WithBindMount("/tmp", "/tmp"); + + return builder.Build(); + } + + static string GetRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + if (File.Exists(Path.Combine(dir.FullName, "Npgsql.slnx")) || Directory.Exists(Path.Combine(dir.FullName, ".git"))) + return dir.FullName; + dir = dir.Parent; + } + + throw new InvalidOperationException("Could not locate repo root for testcontainers assets."); + } } diff --git a/test/containers/postgres/init-db.sh b/test/containers/postgres/init-db.sh new file mode 100644 index 0000000000..3528aa87af --- /dev/null +++ b/test/containers/postgres/init-db.sh @@ -0,0 +1,49 @@ +#!/bin/sh +set -e + +cp /certs/* "$PGDATA/" +chmod 600 "$PGDATA/server.key" + +cat >> "$PGDATA/postgresql.conf" <<'EOF' +ssl = on +ssl_ca_file = 'ca.crt' +ssl_cert_file = 'server.crt' +ssl_key_file = 'server.key' +password_encryption = scram-sha-256 +wal_level = logical +max_wal_senders = 50 +logical_decoding_work_mem = 64kB +wal_sender_timeout = 3s +synchronous_standby_names = 'npgsql_test_sync_standby' +synchronous_commit = local +max_prepared_transactions = 100 +max_connections = 500 +unix_socket_directories = '/tmp,@/npgsql_unix' +EOF + +cat > "$PGDATA/pg_hba.conf" <<'EOF' +local all all trust +host all npgsql_tests_scram all scram-sha-256 +hostssl all npgsql_tests_ssl all md5 +hostnossl all npgsql_tests_ssl all reject +hostnossl all npgsql_tests_nossl all md5 +hostssl all npgsql_tests_nossl all reject +host all all all md5 +host replication all all md5 +EOF + +psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" <<'SQL' +SET password_encryption = 'md5'; +CREATE USER npgsql_tests SUPERUSER PASSWORD 'npgsql_tests'; +CREATE USER npgsql_tests_ssl SUPERUSER PASSWORD 'npgsql_tests_ssl'; +CREATE USER npgsql_tests_nossl SUPERUSER PASSWORD 'npgsql_tests_nossl'; +SET password_encryption = 'scram-sha-256'; +CREATE USER npgsql_tests_scram SUPERUSER PASSWORD 'npgsql_tests_scram'; +SELECT 'CREATE DATABASE npgsql_tests OWNER npgsql_tests' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'npgsql_tests') \gexec +SQL + +if psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT 1 FROM pg_available_extensions WHERE name='postgis'" | grep -q 1; then + psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE EXTENSION IF NOT EXISTS postgis;" +fi +