feat(db): add opt-in PostgreSQL database provider
Multi-replica deployments cannot share a SQLite file, so the database has to move to a server engine before the rest of the HA work is usable. - add Jellyfin.Database.Providers.PostgreSQL with an EF Core Npgsql provider - register the provider and a pooled NpgsqlDataSource when DatabaseType is Jellyfin-PostgreSQL - accept postgresql:// URIs and POSTGRES_CONNECTION_STRING alongside CustomProviderOptions - add container-backed provider, CRUD, concurrency and migration tests
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Jellyfin.Database\Jellyfin.Database.Providers.PostgreSQL\Jellyfin.Database.Providers.PostgreSQL.csproj" />
|
||||
<ProjectReference Include="..\..\src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,129 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that verify concurrent access patterns against a real PostgreSQL 16 container.
|
||||
/// </summary>
|
||||
[Xunit.Trait("Category", "RequiresDocker")]
|
||||
public sealed class PostgreSqlConcurrencyTests : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _container;
|
||||
private NpgsqlDataSource? _dataSource;
|
||||
private PostgreSqlDatabaseProvider? _provider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgreSqlConcurrencyTests"/> class.
|
||||
/// </summary>
|
||||
public PostgreSqlConcurrencyTests()
|
||||
{
|
||||
_container = new PostgreSqlBuilder("postgres:16-alpine")
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the PostgreSQL container and applies migrations before any tests in the class run.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops and removes the PostgreSQL container after all tests in the class have run.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_dataSource is not null)
|
||||
{
|
||||
await _dataSource.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _container.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that concurrent inserts on <see cref="ActivityLog"/> from four parallel tasks succeed without deadlock.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConcurrentInserts_ActivityLogs_SucceedWithoutDeadlock()
|
||||
{
|
||||
const int parallelTasks = 4;
|
||||
const int insertsPerTask = 10;
|
||||
|
||||
var tasks = new List<Task>(parallelTasks);
|
||||
for (var i = 0; i < parallelTasks; i++)
|
||||
{
|
||||
var taskIndex = i;
|
||||
tasks.Add(Task.Run(
|
||||
() => InsertBatchAsync(taskIndex, insertsPerTask),
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
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", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(parallelTasks * insertsPerTask, count);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InsertBatchAsync(int taskIndex, int insertsPerTask)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateContext()
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
|
||||
_provider!.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
|
||||
return new JellyfinDbContext(
|
||||
optionsBuilder.Options,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
_provider,
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that validate PostgreSQL migrations against a real container.
|
||||
/// </summary>
|
||||
[Xunit.Trait("Category", "RequiresDocker")]
|
||||
public sealed class PostgreSqlMigrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _container;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgreSqlMigrationTests"/> class.
|
||||
/// </summary>
|
||||
public PostgreSqlMigrationTests()
|
||||
{
|
||||
_container = new PostgreSqlBuilder("postgres:16-alpine")
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the PostgreSQL container before any tests in the class run.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _container.StartAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops and removes the PostgreSQL container after all tests in the class have run.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _container.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the <c>InitialPostgreSql</c> migration applies cleanly to a fresh PostgreSQL 16 container.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[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(TestContext.Current.CancellationToken);
|
||||
|
||||
var pendingMigrations = await context.Database.GetPendingMigrationsAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Empty(pendingMigrations);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<JellyfinDbContext>();
|
||||
var provider = new PostgreSqlDatabaseProvider(dataSource);
|
||||
provider.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
|
||||
return new JellyfinDbContext(
|
||||
optionsBuilder.Options,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
provider,
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for CRUD operations, optimisation, and purge against a real PostgreSQL 16 container.
|
||||
/// </summary>
|
||||
[Xunit.Trait("Category", "RequiresDocker")]
|
||||
public sealed class PostgreSqlProviderTests : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _container;
|
||||
private NpgsqlDataSource? _dataSource;
|
||||
private PostgreSqlDatabaseProvider? _provider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgreSqlProviderTests"/> class.
|
||||
/// </summary>
|
||||
public PostgreSqlProviderTests()
|
||||
{
|
||||
_container = new PostgreSqlBuilder("postgres:16-alpine")
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the PostgreSQL container and applies migrations before any tests in the class run.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops and removes the PostgreSQL container after all tests in the class have run.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_dataSource is not null)
|
||||
{
|
||||
await _dataSource.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _container.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Create/Read/Update/Delete operations on <see cref="User"/>.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[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(TestContext.Current.CancellationToken);
|
||||
|
||||
var userId = user.Id;
|
||||
|
||||
// Read
|
||||
var read = await ctx.Users.FindAsync([userId], TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(read);
|
||||
Assert.Equal("testuser", read.Username);
|
||||
|
||||
// Update
|
||||
read.Username = "updateduser";
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var updated = await ctx.Users.FindAsync([userId], TestContext.Current.CancellationToken);
|
||||
Assert.Equal("updateduser", updated!.Username);
|
||||
|
||||
// Delete
|
||||
ctx.Users.Remove(updated);
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var deleted = await ctx.Users.FindAsync([userId], TestContext.Current.CancellationToken);
|
||||
Assert.Null(deleted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Create/Read/Update/Delete operations on <see cref="ActivityLog"/>.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[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(TestContext.Current.CancellationToken);
|
||||
|
||||
var logId = log.Id;
|
||||
|
||||
// Read
|
||||
var read = await ctx.ActivityLogs.FindAsync([logId], TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(read);
|
||||
Assert.Equal("Test activity", read.Name);
|
||||
|
||||
// Update
|
||||
read.Overview = "Updated overview";
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var updated = await ctx.ActivityLogs.FindAsync([logId], TestContext.Current.CancellationToken);
|
||||
Assert.Equal("Updated overview", updated!.Overview);
|
||||
|
||||
// Delete
|
||||
ctx.ActivityLogs.Remove(updated);
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var deleted = await ctx.ActivityLogs.FindAsync([logId], TestContext.Current.CancellationToken);
|
||||
Assert.Null(deleted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Create/Read/Update/Delete operations on <see cref="DisplayPreferences"/>.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[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(TestContext.Current.CancellationToken);
|
||||
|
||||
var prefsId = prefs.Id;
|
||||
|
||||
// Read
|
||||
var read = await ctx.DisplayPreferences.FindAsync([prefsId], TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(read);
|
||||
Assert.Equal("TestClient", read.Client);
|
||||
|
||||
// Update
|
||||
read.ShowSidebar = true;
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var updated = await ctx.DisplayPreferences.FindAsync([prefsId], TestContext.Current.CancellationToken);
|
||||
Assert.True(updated!.ShowSidebar);
|
||||
|
||||
// Delete
|
||||
ctx.DisplayPreferences.Remove(updated);
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var deleted = await ctx.DisplayPreferences.FindAsync([prefsId], TestContext.Current.CancellationToken);
|
||||
Assert.Null(deleted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Create/Read/Update/Delete operations on <see cref="BaseItemEntity"/>, <see cref="Chapter"/>, and <see cref="MediaStreamInfo"/>.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[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(TestContext.Current.CancellationToken);
|
||||
|
||||
// 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(TestContext.Current.CancellationToken);
|
||||
|
||||
// Read
|
||||
var readItem = await ctx.BaseItems
|
||||
.Include(i => i.Chapters)
|
||||
.Include(i => i.MediaStreams)
|
||||
.FirstOrDefaultAsync(i => i.Id.Equals(itemId), TestContext.Current.CancellationToken);
|
||||
|
||||
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(TestContext.Current.CancellationToken);
|
||||
|
||||
var updated = await ctx.BaseItems.FindAsync([itemId], TestContext.Current.CancellationToken);
|
||||
Assert.Equal("Updated Movie", updated!.Name);
|
||||
|
||||
// Delete (cascades to Chapter and MediaStreamInfo)
|
||||
ctx.BaseItems.Remove(updated);
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var deleted = await ctx.BaseItems.FindAsync([itemId], TestContext.Current.CancellationToken);
|
||||
Assert.Null(deleted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="PostgreSqlDatabaseProvider.RunScheduledOptimisation"/> executes ANALYZE without error.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="PostgreSqlDatabaseProvider.PurgeDatabase"/> empties tables and resets <c>session_replication_role</c>.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[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(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(await ctx.ActivityLogs.AnyAsync(TestContext.Current.CancellationToken));
|
||||
|
||||
// Purge
|
||||
await _provider!.PurgeDatabase(ctx, ["ActivityLogs"]);
|
||||
|
||||
// session_replication_role should be reset to 'origin' (default)
|
||||
var role = await ctx.Database
|
||||
.SqlQueryRaw<string>("SELECT current_setting('session_replication_role')")
|
||||
.FirstAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal("origin", role);
|
||||
}
|
||||
|
||||
// Verify table is empty via a fresh context
|
||||
var freshCtx = CreateContext();
|
||||
await using (freshCtx)
|
||||
{
|
||||
Assert.False(await freshCtx.ActivityLogs.AnyAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateContext()
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
|
||||
_provider!.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
|
||||
return new JellyfinDbContext(
|
||||
optionsBuilder.Options,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
_provider,
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A minimal <see cref="IDbContextFactory{TContext}"/> wrapper that returns a pre-existing context.
|
||||
/// </summary>
|
||||
private sealed class TestDbContextFactory : IDbContextFactory<JellyfinDbContext>
|
||||
{
|
||||
private readonly JellyfinDbContext _context;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TestDbContextFactory"/> class.
|
||||
/// </summary>
|
||||
/// <param name="context">The context to return from <see cref="CreateDbContext"/>.</param>
|
||||
public TestDbContextFactory(JellyfinDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the pre-existing <see cref="JellyfinDbContext"/> instance.
|
||||
/// </summary>
|
||||
/// <returns>The pre-existing <see cref="JellyfinDbContext"/> instance.</returns>
|
||||
public JellyfinDbContext CreateDbContext() => _context;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the pre-existing <see cref="JellyfinDbContext"/> instance as a completed task.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token (unused).</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> containing the pre-existing <see cref="JellyfinDbContext"/> instance.</returns>
|
||||
public Task<JellyfinDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_context);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user