[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>
This commit is contained in:
@@ -89,6 +89,7 @@
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.11" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.11" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.4.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.9.0" />
|
||||
<PackageVersion Include="TMDbLib" Version="2.3.0" />
|
||||
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
<PackageReference Include="xunit" />
|
||||
<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,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;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that verify concurrent access patterns against a real PostgreSQL 16 container.
|
||||
/// </summary>
|
||||
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()
|
||||
.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 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(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<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>
|
||||
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()
|
||||
.WithImage("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="Task"/> representing the asynchronous operation.</returns>
|
||||
public async Task 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="Task"/> representing the asynchronous operation.</returns>
|
||||
public async Task 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();
|
||||
|
||||
var pendingMigrations = await context.Database.GetPendingMigrationsAsync();
|
||||
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>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user