From 35a1ec8a5deb98046a1d0385e13c34bad750bc01 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Wed, 4 Mar 2026 20:49:07 -0500
Subject: [PATCH] [WIP] Add PostgreSQL integration test project for validation
(#12)
* Initial plan
* Add PostgreSQL integration test project with migration, CRUD, and concurrency tests
Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>
---
Directory.Packages.props | 1 +
Jellyfin.sln | 7 +
.../Jellyfin.Database.Tests.PostgreSQL.csproj | 25 ++
.../PostgreSqlConcurrencyTests.cs | 125 +++++++
.../PostgreSqlMigrationTests.cs | 98 +++++
.../PostgreSqlProviderTests.cs | 335 ++++++++++++++++++
6 files changed, 591 insertions(+)
create mode 100644 tests/Jellyfin.Database.Tests.PostgreSQL/Jellyfin.Database.Tests.PostgreSQL.csproj
create mode 100644 tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlConcurrencyTests.cs
create mode 100644 tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlMigrationTests.cs
create mode 100644 tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlProviderTests.cs
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 8d6591c7d..4bbbf4542 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -89,6 +89,7 @@
+
diff --git a/Jellyfin.sln b/Jellyfin.sln
index fd308c9b3..b2f8cf1ca 100644
--- a/Jellyfin.sln
+++ b/Jellyfin.sln
@@ -67,6 +67,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Server.Tests", "te
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Server.Integration.Tests", "tests\Jellyfin.Server.Integration.Tests\Jellyfin.Server.Integration.Tests.csproj", "{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Database.Tests.PostgreSQL", "tests\Jellyfin.Database.Tests.PostgreSQL\Jellyfin.Database.Tests.PostgreSQL.csproj", "{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Providers.Tests", "tests\Jellyfin.Providers.Tests\Jellyfin.Providers.Tests.csproj", "{A964008C-2136-4716-B6CB-B3426C22320A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}"
@@ -218,6 +220,10 @@ Global
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Release|Any CPU.Build.0 = Release|Any CPU
{A964008C-2136-4716-B6CB-B3426C22320A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A964008C-2136-4716-B6CB-B3426C22320A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A964008C-2136-4716-B6CB-B3426C22320A}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -289,6 +295,7 @@ Global
{42816EA8-4511-4CBF-A9C7-7791D5DDDAE6} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{3ADBCD8C-C0F2-4956-8FDC-35D686B74CF9} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
+ {B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{A964008C-2136-4716-B6CB-B3426C22320A} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{750B8757-BE3D-4F8C-941A-FBAD94904ADA} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
{332A5C7A-F907-47CA-910E-BE6F7371B9E0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
diff --git a/tests/Jellyfin.Database.Tests.PostgreSQL/Jellyfin.Database.Tests.PostgreSQL.csproj b/tests/Jellyfin.Database.Tests.PostgreSQL/Jellyfin.Database.Tests.PostgreSQL.csproj
new file mode 100644
index 000000000..b91a96deb
--- /dev/null
+++ b/tests/Jellyfin.Database.Tests.PostgreSQL/Jellyfin.Database.Tests.PostgreSQL.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net10.0
+ false
+ true
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlConcurrencyTests.cs b/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlConcurrencyTests.cs
new file mode 100644
index 000000000..1f797bb9b
--- /dev/null
+++ b/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlConcurrencyTests.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Collections.Generic;
+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 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.
+///
+public sealed class PostgreSqlConcurrencyTests : IAsyncLifetime
+{
+ private readonly PostgreSqlContainer _container;
+ private NpgsqlDataSource? _dataSource;
+ private PostgreSqlDatabaseProvider? _provider;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public PostgreSqlConcurrencyTests()
+ {
+ _container = new PostgreSqlBuilder()
+ .WithImage("postgres:16-alpine")
+ .WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
+ .Build();
+ }
+
+ ///
+ /// Starts the PostgreSQL container and applies migrations before any tests in the class run.
+ ///
+ /// A representing the asynchronous operation.
+ public async Task InitializeAsync()
+ {
+ await _container.StartAsync().ConfigureAwait(false);
+
+ _dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).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);
+ }
+ }
+
+ ///
+ /// Stops and removes the PostgreSQL container after all tests in the class have run.
+ ///
+ /// A representing the asynchronous operation.
+ public async Task DisposeAsync()
+ {
+ if (_dataSource is not null)
+ {
+ await _dataSource.DisposeAsync().ConfigureAwait(false);
+ }
+
+ await _container.DisposeAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Verifies that concurrent inserts on from four parallel tasks succeed without deadlock.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task ConcurrentInserts_ActivityLogs_SucceedWithoutDeadlock()
+ {
+ const int parallelTasks = 4;
+ const int insertsPerTask = 10;
+
+ var tasks = new List(parallelTasks);
+ for (var i = 0; i < parallelTasks; i++)
+ {
+ var taskIndex = i;
+ tasks.Add(Task.Run(async () =>
+ {
+ var ctx = CreateContext();
+ await using (ctx.ConfigureAwait(false))
+ {
+ for (var j = 0; j < insertsPerTask; j++)
+ {
+ ctx.ActivityLogs.Add(new ActivityLog(
+ $"Task {taskIndex} Insert {j}",
+ "ConcurrencyTest",
+ Guid.Empty));
+ }
+
+ await ctx.SaveChangesAsync().ConfigureAwait(false);
+ }
+ }));
+ }
+
+ await Task.WhenAll(tasks);
+
+ // Verify all rows were inserted
+ var verifyCtx = CreateContext();
+ await using (verifyCtx)
+ {
+ var count = await verifyCtx.ActivityLogs
+ .CountAsync(l => l.Type == "ConcurrencyTest");
+ Assert.Equal(parallelTasks * insertsPerTask, count);
+ }
+ }
+
+ private JellyfinDbContext CreateContext()
+ {
+ var optionsBuilder = new DbContextOptionsBuilder();
+ _provider!.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
+ return new JellyfinDbContext(
+ optionsBuilder.Options,
+ NullLogger.Instance,
+ _provider,
+ new NoLockBehavior(NullLogger.Instance));
+ }
+}
diff --git a/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlMigrationTests.cs b/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlMigrationTests.cs
new file mode 100644
index 000000000..e194fdd8d
--- /dev/null
+++ b/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlMigrationTests.cs
@@ -0,0 +1,98 @@
+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 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.
+///
+public sealed class PostgreSqlMigrationTests : IAsyncLifetime
+{
+ private readonly PostgreSqlContainer _container;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public PostgreSqlMigrationTests()
+ {
+ _container = new PostgreSqlBuilder()
+ .WithImage("postgres:16-alpine")
+ .WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
+ .Build();
+ }
+
+ ///
+ /// Starts the PostgreSQL container before any tests in the class run.
+ ///
+ /// A representing the asynchronous operation.
+ public async Task InitializeAsync()
+ {
+ await _container.StartAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Stops and removes the PostgreSQL container after all tests in the class have run.
+ ///
+ /// A representing the asynchronous operation.
+ public async Task DisposeAsync()
+ {
+ await _container.DisposeAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Verifies that the InitialPostgreSql migration applies cleanly to a fresh PostgreSQL 16 container.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task MigrateAsync_AppliesInitialMigrationCleanly()
+ {
+ await using var dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build();
+ var context = CreateContext(dataSource);
+ await using (context)
+ {
+ await context.Database.MigrateAsync();
+
+ var pendingMigrations = await context.Database.GetPendingMigrationsAsync();
+ Assert.Empty(pendingMigrations);
+ }
+ }
+
+ ///
+ /// Verifies that no pending model changes exist for the PostgreSQL provider,
+ /// acting as a CI gate that fails when model changes are added without a corresponding migration.
+ ///
+ [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);
+
+ Assert.False(
+ context.Database.HasPendingModelChanges(),
+ "There are unapplied changes to the EFCore model for PostgreSQL. Please create a Migration.");
+ }
+
+ private static JellyfinDbContext CreateContext(NpgsqlDataSource dataSource)
+ {
+ var optionsBuilder = new DbContextOptionsBuilder();
+ var provider = new PostgreSqlDatabaseProvider(dataSource);
+ provider.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
+ return new JellyfinDbContext(
+ optionsBuilder.Options,
+ NullLogger.Instance,
+ provider,
+ new NoLockBehavior(NullLogger.Instance));
+ }
+}
diff --git a/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlProviderTests.cs b/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlProviderTests.cs
new file mode 100644
index 000000000..b1852735d
--- /dev/null
+++ b/tests/Jellyfin.Database.Tests.PostgreSQL/PostgreSqlProviderTests.cs
@@ -0,0 +1,335 @@
+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 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.
+///
+public sealed class PostgreSqlProviderTests : IAsyncLifetime
+{
+ private readonly PostgreSqlContainer _container;
+ private NpgsqlDataSource? _dataSource;
+ private PostgreSqlDatabaseProvider? _provider;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public PostgreSqlProviderTests()
+ {
+ _container = new PostgreSqlBuilder()
+ .WithImage("postgres:16-alpine")
+ .WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
+ .Build();
+ }
+
+ ///
+ /// Starts the PostgreSQL container and applies migrations before any tests in the class run.
+ ///
+ /// A representing the asynchronous operation.
+ public async Task InitializeAsync()
+ {
+ await _container.StartAsync().ConfigureAwait(false);
+
+ _dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).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);
+ }
+ }
+
+ ///
+ /// Stops and removes the PostgreSQL container after all tests in the class have run.
+ ///
+ /// A representing the asynchronous operation.
+ public async Task DisposeAsync()
+ {
+ if (_dataSource is not null)
+ {
+ await _dataSource.DisposeAsync().ConfigureAwait(false);
+ }
+
+ await _container.DisposeAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Verifies Create/Read/Update/Delete operations on .
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task Crud_User()
+ {
+ var ctx = CreateContext();
+ await using (ctx)
+ {
+ // Create
+ var user = new User("testuser", "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider", "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider");
+ ctx.Users.Add(user);
+ await ctx.SaveChangesAsync();
+
+ var userId = user.Id;
+
+ // Read
+ var read = await ctx.Users.FindAsync(userId);
+ Assert.NotNull(read);
+ Assert.Equal("testuser", read.Username);
+
+ // Update
+ read.Username = "updateduser";
+ await ctx.SaveChangesAsync();
+
+ var updated = await ctx.Users.FindAsync(userId);
+ Assert.Equal("updateduser", updated!.Username);
+
+ // Delete
+ ctx.Users.Remove(updated);
+ await ctx.SaveChangesAsync();
+
+ var deleted = await ctx.Users.FindAsync(userId);
+ Assert.Null(deleted);
+ }
+ }
+
+ ///
+ /// Verifies Create/Read/Update/Delete operations on .
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task Crud_ActivityLog()
+ {
+ var ctx = CreateContext();
+ await using (ctx)
+ {
+ // Create
+ var log = new ActivityLog("Test activity", "TestType", Guid.Empty);
+ ctx.ActivityLogs.Add(log);
+ await ctx.SaveChangesAsync();
+
+ var logId = log.Id;
+
+ // Read
+ var read = await ctx.ActivityLogs.FindAsync(logId);
+ Assert.NotNull(read);
+ Assert.Equal("Test activity", read.Name);
+
+ // Update
+ read.Overview = "Updated overview";
+ await ctx.SaveChangesAsync();
+
+ var updated = await ctx.ActivityLogs.FindAsync(logId);
+ Assert.Equal("Updated overview", updated!.Overview);
+
+ // Delete
+ ctx.ActivityLogs.Remove(updated);
+ await ctx.SaveChangesAsync();
+
+ var deleted = await ctx.ActivityLogs.FindAsync(logId);
+ Assert.Null(deleted);
+ }
+ }
+
+ ///
+ /// Verifies Create/Read/Update/Delete operations on .
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task Crud_DisplayPreferences()
+ {
+ var ctx = CreateContext();
+ await using (ctx)
+ {
+ var userId = Guid.NewGuid();
+ var itemId = Guid.NewGuid();
+
+ // Create
+ var prefs = new DisplayPreferences(userId, itemId, "TestClient");
+ ctx.DisplayPreferences.Add(prefs);
+ await ctx.SaveChangesAsync();
+
+ var prefsId = prefs.Id;
+
+ // Read
+ var read = await ctx.DisplayPreferences.FindAsync(prefsId);
+ Assert.NotNull(read);
+ Assert.Equal("TestClient", read.Client);
+
+ // Update
+ read.ShowSidebar = true;
+ await ctx.SaveChangesAsync();
+
+ var updated = await ctx.DisplayPreferences.FindAsync(prefsId);
+ Assert.True(updated!.ShowSidebar);
+
+ // Delete
+ ctx.DisplayPreferences.Remove(updated);
+ await ctx.SaveChangesAsync();
+
+ var deleted = await ctx.DisplayPreferences.FindAsync(prefsId);
+ Assert.Null(deleted);
+ }
+ }
+
+ ///
+ /// Verifies Create/Read/Update/Delete operations on , , and .
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task Crud_BaseItem_Chapter_MediaStream()
+ {
+ var ctx = CreateContext();
+ await using (ctx)
+ {
+ var itemId = Guid.NewGuid();
+
+ // Create BaseItem
+ var item = new BaseItemEntity { Id = itemId, Type = "Movie", Name = "Test Movie" };
+ ctx.BaseItems.Add(item);
+ await ctx.SaveChangesAsync();
+
+ // Create Chapter linked to BaseItem
+ var chapter = new Chapter { ItemId = itemId, Item = item, ChapterIndex = 0, StartPositionTicks = 0, Name = "Intro" };
+ ctx.Chapters.Add(chapter);
+
+ // Create MediaStreamInfo linked to BaseItem
+ var stream = new MediaStreamInfo { ItemId = itemId, Item = item, StreamIndex = 0, StreamType = MediaStreamTypeEntity.Video };
+ ctx.MediaStreamInfos.Add(stream);
+
+ await ctx.SaveChangesAsync();
+
+ // Read
+ var readItem = await ctx.BaseItems
+ .Include(i => i.Chapters)
+ .Include(i => i.MediaStreams)
+ .FirstOrDefaultAsync(i => i.Id.Equals(itemId));
+
+ Assert.NotNull(readItem);
+ Assert.Equal("Test Movie", readItem.Name);
+ Assert.Single(readItem.Chapters!);
+ Assert.Single(readItem.MediaStreams!);
+
+ // Update
+ readItem.Name = "Updated Movie";
+ await ctx.SaveChangesAsync();
+
+ var updated = await ctx.BaseItems.FindAsync(itemId);
+ Assert.Equal("Updated Movie", updated!.Name);
+
+ // Delete (cascades to Chapter and MediaStreamInfo)
+ ctx.BaseItems.Remove(updated);
+ await ctx.SaveChangesAsync();
+
+ var deleted = await ctx.BaseItems.FindAsync(itemId);
+ Assert.Null(deleted);
+ }
+ }
+
+ ///
+ /// Verifies that executes ANALYZE without error.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task RunScheduledOptimisation_ExecutesWithoutError()
+ {
+ var ctx = CreateContext();
+ await using (ctx)
+ {
+ var factory = new TestDbContextFactory(ctx);
+ _provider!.DbContextFactory = factory;
+
+ await _provider.RunScheduledOptimisation(CancellationToken.None);
+ }
+ }
+
+ ///
+ /// Verifies that empties tables and resets session_replication_role.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task PurgeDatabase_EmptiesTablesAndResetsFkRole()
+ {
+ var ctx = CreateContext();
+ await using (ctx)
+ {
+ // Seed a row
+ ctx.ActivityLogs.Add(new ActivityLog("Purge test", "TestType", Guid.Empty));
+ await ctx.SaveChangesAsync();
+
+ Assert.True(await ctx.ActivityLogs.AnyAsync());
+
+ // Purge
+ await _provider!.PurgeDatabase(ctx, ["ActivityLogs"]);
+
+ // session_replication_role should be reset to 'origin' (default)
+ var role = await ctx.Database
+ .SqlQueryRaw("SELECT current_setting('session_replication_role')")
+ .FirstAsync();
+ Assert.Equal("origin", role);
+ }
+
+ // Verify table is empty via a fresh context
+ var freshCtx = CreateContext();
+ await using (freshCtx)
+ {
+ Assert.False(await freshCtx.ActivityLogs.AnyAsync());
+ }
+ }
+
+ private JellyfinDbContext CreateContext()
+ {
+ var optionsBuilder = new DbContextOptionsBuilder();
+ _provider!.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
+ return new JellyfinDbContext(
+ optionsBuilder.Options,
+ NullLogger.Instance,
+ _provider,
+ new NoLockBehavior(NullLogger.Instance));
+ }
+
+ ///
+ /// A minimal wrapper that returns a pre-existing context.
+ ///
+ private sealed class TestDbContextFactory : IDbContextFactory
+ {
+ private readonly JellyfinDbContext _context;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The context to return from .
+ public TestDbContextFactory(JellyfinDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ /// Returns the pre-existing instance.
+ ///
+ /// The pre-existing instance.
+ public JellyfinDbContext CreateDbContext() => _context;
+
+ ///
+ /// Returns the pre-existing instance as a completed task.
+ ///
+ /// A cancellation token (unused).
+ /// A containing the pre-existing instance.
+ public Task CreateDbContextAsync(CancellationToken cancellationToken = default)
+ => Task.FromResult(_context);
+ }
+}