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