diff --git a/.woodpecker/ci.yaml b/.woodpecker/ci.yaml index 347becf386..c1c5e741f0 100644 --- a/.woodpecker/ci.yaml +++ b/.woodpecker/ci.yaml @@ -45,9 +45,8 @@ steps: # testcontainers, and a postgres service container deadlocks the step because the backend mounts # the ReadWriteOnce workspace volume into service pods and schedules them on another node. # Its data directory lives on the step's ephemeral storage, not on the workspace volume. - # Scoped to Jellyfin.Server.Tests: the three classes in Jellyfin.Database.Tests.PostgreSQL still - # start their own container, and PostgreSqlProviderTests already fails on main - on an EF 10 - # scalar query and on its own data - which a third test in the class then inherits. + # Both projects attach to it through JELLYFIN_TEST_POSTGRES and give every test a database of + # its own, so nothing here depends on a docker daemon. - name: postgres-migration-chain image: mcr.microsoft.com/dotnet/sdk:10.0 depends_on: @@ -64,7 +63,9 @@ steps: - su postgres -c "$PGBIN/initdb -D /tmp/pgdata -A trust -U postgres" - su postgres -c "$PGBIN/pg_ctl -D /tmp/pgdata -o \"-c listen_addresses=127.0.0.1 -k /tmp/pgrun\" -l /tmp/pg.log -w start" - dotnet build tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj -c Release + - dotnet build tests/Jellyfin.Database.Tests.PostgreSQL/Jellyfin.Database.Tests.PostgreSQL.csproj -c Release - dotnet test tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj -c Release --no-build --verbosity minimal --filter "Category=RequiresDocker" + - dotnet test tests/Jellyfin.Database.Tests.PostgreSQL/Jellyfin.Database.Tests.PostgreSQL.csproj -c Release --no-build --verbosity minimal --filter "Category=RequiresDocker" backend_options: kubernetes: serviceAccountName: jellyfin-ha-src diff --git a/tests/Jellyfin.Database.Tests.PostgreSQL/Jellyfin.Database.Tests.PostgreSQL.csproj b/tests/Jellyfin.Database.Tests.PostgreSQL/Jellyfin.Database.Tests.PostgreSQL.csproj index 71647b0d16..19eedc4053 100644 --- a/tests/Jellyfin.Database.Tests.PostgreSQL/Jellyfin.Database.Tests.PostgreSQL.csproj +++ b/tests/Jellyfin.Database.Tests.PostgreSQL/Jellyfin.Database.Tests.PostgreSQL.csproj @@ -18,6 +18,11 @@ + + + + + diff --git a/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlConcurrencyTests.cs b/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlConcurrencyTests.cs index e186ad118a..21a41a0ef7 100644 --- a/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlConcurrencyTests.cs +++ b/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlConcurrencyTests.cs @@ -1,71 +1,68 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; -using DotNet.Testcontainers.Builders; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.DbConfiguration; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Locking; using Jellyfin.Database.Providers.PostgreSQL; +using Jellyfin.Server.Tests.Migrations; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Npgsql; -using Testcontainers.PostgreSql; using Xunit; namespace Jellyfin.Database.Tests.PostgreSQL; /// -/// Integration tests that verify concurrent access patterns against a real PostgreSQL 16 container. +/// Integration tests that verify concurrent access patterns against a real PostgreSQL server. /// [Xunit.Trait("Category", "RequiresDocker")] public sealed class PostgreSqlConcurrencyTests : IAsyncLifetime { - private readonly PostgreSqlContainer _container; + private static int _databaseSequence; + + private PostgreSqlTestServer? _server; private NpgsqlDataSource? _dataSource; private PostgreSqlDatabaseProvider? _provider; /// - /// Initializes a new instance of the class. - /// - public PostgreSqlConcurrencyTests() - { - _container = new PostgreSqlBuilder("postgres:16-alpine") - .WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready")) - .Build(); - } - - /// - /// Starts the PostgreSQL container and applies migrations before any tests in the class run. + /// Attaches to the test server, hands this test a database of its own and applies migrations to it. /// /// A representing the asynchronous operation. public async ValueTask InitializeAsync() { - await _container.StartAsync().ConfigureAwait(false); + _server = await PostgreSqlTestServer.StartAsync().ConfigureAwait(false); - _dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build(); + var databaseName = FormattableString.Invariant($"pg_concurrency_{Interlocked.Increment(ref _databaseSequence)}"); + var connectionString = await _server.CreateDatabaseAsync(databaseName, TestContext.Current.CancellationToken).ConfigureAwait(false); + _dataSource = new NpgsqlDataSourceBuilder(connectionString).Build(); _provider = new PostgreSqlDatabaseProvider(_dataSource); - // Apply migrations once for the whole test class. var context = CreateContext(); await using (context.ConfigureAwait(false)) { - await context.Database.MigrateAsync().ConfigureAwait(false); + await context.Database.MigrateAsync(TestContext.Current.CancellationToken).ConfigureAwait(false); } } /// - /// Stops and removes the PostgreSQL container after all tests in the class have run. + /// Releases the data source and the test server. /// /// A representing the asynchronous operation. public async ValueTask DisposeAsync() { + // InitializeAsync can fail before the data source exists; its error must not be masked by an NRE here. if (_dataSource is not null) { await _dataSource.DisposeAsync().ConfigureAwait(false); } - await _container.DisposeAsync().ConfigureAwait(false); + if (_server is not null) + { + await _server.DisposeAsync().ConfigureAwait(false); + } } /// diff --git a/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlMigrationTests.cs b/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlMigrationTests.cs index 2ede930b30..75585b015f 100644 --- a/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlMigrationTests.cs +++ b/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlMigrationTests.cs @@ -1,62 +1,68 @@ +using System; +using System.Threading; using System.Threading.Tasks; -using DotNet.Testcontainers.Builders; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.DbConfiguration; using Jellyfin.Database.Implementations.Locking; using Jellyfin.Database.Providers.PostgreSQL; +using Jellyfin.Server.Tests.Migrations; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Npgsql; -using Testcontainers.PostgreSql; using Xunit; namespace Jellyfin.Database.Tests.PostgreSQL; /// -/// Integration tests that validate PostgreSQL migrations against a real container. +/// Integration tests that validate PostgreSQL migrations against a real server. /// [Xunit.Trait("Category", "RequiresDocker")] public sealed class PostgreSqlMigrationTests : IAsyncLifetime { - private readonly PostgreSqlContainer _container; + private static int _databaseSequence; + + private PostgreSqlTestServer? _server; + private NpgsqlDataSource? _dataSource; /// - /// Initializes a new instance of the class. - /// - public PostgreSqlMigrationTests() - { - _container = new PostgreSqlBuilder("postgres:16-alpine") - .WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready")) - .Build(); - } - - /// - /// Starts the PostgreSQL container before any tests in the class run. + /// Attaches to the test server and hands this test an empty database of its own. /// /// A representing the asynchronous operation. public async ValueTask InitializeAsync() { - await _container.StartAsync().ConfigureAwait(false); + _server = await PostgreSqlTestServer.StartAsync().ConfigureAwait(false); + + var databaseName = FormattableString.Invariant($"pg_migration_{Interlocked.Increment(ref _databaseSequence)}"); + var connectionString = await _server.CreateDatabaseAsync(databaseName, TestContext.Current.CancellationToken).ConfigureAwait(false); + _dataSource = new NpgsqlDataSourceBuilder(connectionString).Build(); } /// - /// Stops and removes the PostgreSQL container after all tests in the class have run. + /// Releases the data source and the test server. /// /// A representing the asynchronous operation. public async ValueTask DisposeAsync() { - await _container.DisposeAsync().ConfigureAwait(false); + // InitializeAsync can fail before the data source exists; its error must not be masked by an NRE here. + if (_dataSource is not null) + { + await _dataSource.DisposeAsync().ConfigureAwait(false); + } + + if (_server is not null) + { + await _server.DisposeAsync().ConfigureAwait(false); + } } /// - /// Verifies that the InitialPostgreSql migration applies cleanly to a fresh PostgreSQL 16 container. + /// Verifies that the InitialPostgreSql migration applies cleanly to a fresh database. /// /// A representing the asynchronous operation. [Fact] public async Task MigrateAsync_AppliesInitialMigrationCleanly() { - await using var dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build(); - var context = CreateContext(dataSource); + var context = CreateContext(_dataSource!); await using (context) { await context.Database.MigrateAsync(TestContext.Current.CancellationToken); @@ -73,11 +79,7 @@ public sealed class PostgreSqlMigrationTests : IAsyncLifetime [Fact] public void CheckForUnappliedMigrations_PostgreSql() { - // Use a dummy connection string; HasPendingModelChanges() is a purely in-memory check - // that compares the current compiled model with the migration snapshots — no real DB needed. - const string dummyConnectionString = "Host=localhost;Database=jellyfin;Username=postgres;Password=postgres"; - using var dataSource = new NpgsqlDataSourceBuilder(dummyConnectionString).Build(); - using var context = CreateContext(dataSource); + using var context = CreateContext(_dataSource!); Assert.False( context.Database.HasPendingModelChanges(), diff --git a/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlProviderTests.cs b/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlProviderTests.cs index 639fcee640..ec3036c7c3 100644 --- a/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlProviderTests.cs +++ b/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlProviderTests.cs @@ -2,71 +2,67 @@ using System; using System.Linq; using System.Threading; using System.Threading.Tasks; -using DotNet.Testcontainers.Builders; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.DbConfiguration; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Locking; using Jellyfin.Database.Providers.PostgreSQL; +using Jellyfin.Server.Tests.Migrations; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Npgsql; -using Testcontainers.PostgreSql; using Xunit; namespace Jellyfin.Database.Tests.PostgreSQL; /// -/// Integration tests for CRUD operations, optimisation, and purge against a real PostgreSQL 16 container. +/// Integration tests for CRUD operations, optimisation, and purge against a real PostgreSQL server. /// [Xunit.Trait("Category", "RequiresDocker")] public sealed class PostgreSqlProviderTests : IAsyncLifetime { - private readonly PostgreSqlContainer _container; + private static int _databaseSequence; + + private PostgreSqlTestServer? _server; private NpgsqlDataSource? _dataSource; private PostgreSqlDatabaseProvider? _provider; /// - /// Initializes a new instance of the class. - /// - public PostgreSqlProviderTests() - { - _container = new PostgreSqlBuilder("postgres:16-alpine") - .WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready")) - .Build(); - } - - /// - /// Starts the PostgreSQL container and applies migrations before any tests in the class run. + /// Attaches to the test server, hands this test a database of its own and applies migrations to it. /// /// A representing the asynchronous operation. public async ValueTask InitializeAsync() { - await _container.StartAsync().ConfigureAwait(false); + _server = await PostgreSqlTestServer.StartAsync().ConfigureAwait(false); - _dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build(); + var databaseName = FormattableString.Invariant($"pg_provider_{Interlocked.Increment(ref _databaseSequence)}"); + var connectionString = await _server.CreateDatabaseAsync(databaseName, TestContext.Current.CancellationToken).ConfigureAwait(false); + _dataSource = new NpgsqlDataSourceBuilder(connectionString).Build(); _provider = new PostgreSqlDatabaseProvider(_dataSource); - // Apply migrations once for the whole test class. var context = CreateContext(); await using (context.ConfigureAwait(false)) { - await context.Database.MigrateAsync().ConfigureAwait(false); + await context.Database.MigrateAsync(TestContext.Current.CancellationToken).ConfigureAwait(false); } } /// - /// Stops and removes the PostgreSQL container after all tests in the class have run. + /// Releases the data source and the test server. /// /// A representing the asynchronous operation. public async ValueTask DisposeAsync() { + // InitializeAsync can fail before the data source exists; its error must not be masked by an NRE here. if (_dataSource is not null) { await _dataSource.DisposeAsync().ConfigureAwait(false); } - await _container.DisposeAsync().ConfigureAwait(false); + if (_server is not null) + { + await _server.DisposeAsync().ConfigureAwait(false); + } } /// @@ -155,11 +151,15 @@ public sealed class PostgreSqlProviderTests : IAsyncLifetime var ctx = CreateContext(); await using (ctx) { - var userId = Guid.NewGuid(); + // DisplayPreferences.UserId is a foreign key onto Users, which PostgreSQL enforces and SQLite does not. + var user = new User("prefsuser", "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider", "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider"); + ctx.Users.Add(user); + await ctx.SaveChangesAsync(TestContext.Current.CancellationToken); + var itemId = Guid.NewGuid(); // Create - var prefs = new DisplayPreferences(userId, itemId, "TestClient"); + var prefs = new DisplayPreferences(user.Id, itemId, "TestClient"); ctx.DisplayPreferences.Add(prefs); await ctx.SaveChangesAsync(TestContext.Current.CancellationToken); @@ -278,7 +278,7 @@ public sealed class PostgreSqlProviderTests : IAsyncLifetime // session_replication_role should be reset to 'origin' (default) var role = await ctx.Database - .SqlQueryRaw("SELECT current_setting('session_replication_role')") + .SqlQueryRaw("SELECT current_setting('session_replication_role') AS \"Value\"") .FirstAsync(TestContext.Current.CancellationToken); Assert.Equal("origin", role); }