365036f9f2
- Add [Trait("Category", "RequiresDocker")] to all 3 PostgreSQL test classes
(PostgreSqlMigrationTests, PostgreSqlProviderTests, PostgreSqlConcurrencyTests)
- Add --filter "Category!=RequiresDocker" to ci-tests.yml dotnet test command
so runners without Docker don't fail on Testcontainers initialization
- Disable CodeQL workflow in fork (requires upstream org permissions + .NET 10
CodeQL support that isn't available on our self-hosted runners)
PostgreSQL tests still run in ha-build.yml against the cluster where Docker
is available via the self-hosted ARC runners.
337 lines
12 KiB
C#
337 lines
12 KiB
C#
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()
|
|
.WithImage("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="Task"/> representing the asynchronous operation.</returns>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stops and removes the PostgreSQL container after all tests in the class have run.
|
|
/// </summary>
|
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
public async Task 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();
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <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();
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <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();
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <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();
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
/// <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();
|
|
|
|
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<string>("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<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);
|
|
}
|
|
}
|