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);
}