test(db): run the startup migration chain against PostgreSQL in CI #7
@@ -38,3 +38,42 @@ steps:
|
||||
memory: 8Gi
|
||||
cpu: 4
|
||||
ephemeral-storage: 20Gi
|
||||
|
||||
# The PostgreSQL migration tests are the only ones that run the startup migration chain against
|
||||
# the provider production uses, and the filter above has always skipped them.
|
||||
# The server runs inside this step: the kubernetes backend has no docker daemon for
|
||||
# testcontainers, and a postgres service container deadlocks the step because the backend mounts
|
||||
# the ReadWriteOnce workspace volume into service pods and schedules them on another node.
|
||||
# Its data directory lives on the step's ephemeral storage, not on the workspace volume.
|
||||
# Scoped to Jellyfin.Server.Tests: the three classes in Jellyfin.Database.Tests.PostgreSQL still
|
||||
# start their own container, and PostgreSqlProviderTests already fails on main - on an EF 10
|
||||
# scalar query and on its own data - which a third test in the class then inherits.
|
||||
- name: postgres-migration-chain
|
||||
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||
depends_on:
|
||||
- build-test
|
||||
environment:
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||
DOTNET_NOLOGO: "1"
|
||||
JELLYFIN_TEST_POSTGRES: "Host=127.0.0.1;Port=5432;Database=postgres;Username=postgres"
|
||||
commands:
|
||||
- apt-get -o Acquire::Retries=3 update || apt-get -o Acquire::Retries=3 update
|
||||
- DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends postgresql
|
||||
- install -d -o postgres -g postgres /tmp/pgdata /tmp/pgrun
|
||||
- PGBIN=$(ls -d /usr/lib/postgresql/*/bin | tail -1)
|
||||
- su postgres -c "$PGBIN/initdb -D /tmp/pgdata -A trust -U postgres"
|
||||
- su postgres -c "$PGBIN/pg_ctl -D /tmp/pgdata -o \"-c listen_addresses=127.0.0.1 -k /tmp/pgrun\" -l /tmp/pg.log -w start"
|
||||
- dotnet build tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj -c Release
|
||||
- dotnet test tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj -c Release --no-build --verbosity minimal --filter "Category=RequiresDocker"
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: jellyfin-ha-src
|
||||
resources:
|
||||
requests:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
ephemeral-storage: 6Gi
|
||||
limits:
|
||||
memory: 6Gi
|
||||
cpu: 4
|
||||
ephemeral-storage: 12Gi
|
||||
|
||||
@@ -461,6 +461,18 @@ dotnet test Jellyfin.sln \
|
||||
--filter "Category!=RequiresDocker&FullyQualifiedName!~Integration"
|
||||
```
|
||||
|
||||
### Run the PostgreSQL migration tests
|
||||
|
||||
They run the startup migration chain against a real server. Set `JELLYFIN_TEST_POSTGRES` to a
|
||||
connection string for an already running server and they use it; without it they start a container
|
||||
through testcontainers.
|
||||
|
||||
```bash
|
||||
dotnet test tests/Jellyfin.Server.Tests \
|
||||
--configuration Release \
|
||||
--filter "Category=RequiresDocker"
|
||||
```
|
||||
|
||||
### Run HA-specific tests
|
||||
|
||||
The transcode session store and HA recovery tests live in:
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Emby.Server.Implementations;
|
||||
using Emby.Server.Implementations.Branding;
|
||||
using Emby.Server.Implementations.Configuration;
|
||||
using Emby.Server.Implementations.Serialization;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.DbConfiguration;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.PostgreSQL;
|
||||
using Jellyfin.LiveTv.Configuration;
|
||||
using Jellyfin.Server.Implementations.DatabaseConfiguration;
|
||||
using Jellyfin.Server.Migrations;
|
||||
using Jellyfin.Server.Migrations.Stages;
|
||||
using Jellyfin.Server.ServerSetupApp;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Controller.Playlists;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Controller.Trickplay;
|
||||
using MediaBrowser.MediaEncoding.Configuration;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using MediaBrowser.XbmcMetadata.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Tests.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the startup migration sequence that <c>Program</c> runs - both database stages, schema and code
|
||||
/// migrations interleaved by <see cref="JellyfinMigrationService"/> - against a real PostgreSQL holding a
|
||||
/// library representative of one being upgraded.
|
||||
/// </summary>
|
||||
[Trait("Category", "RequiresDocker")]
|
||||
public sealed class PostgreSqlStartupMigrationTests : IAsyncLifetime
|
||||
{
|
||||
private const string BaselineMigrationId = "20260305010333_InitialPostgreSql";
|
||||
|
||||
private const string MovieItemId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string UnratedMovieItemId = "22222222-2222-2222-2222-222222222222";
|
||||
private const string EmptyRatingMovieItemId = "33333333-3333-3333-3333-333333333333";
|
||||
private const string TrailerItemId = "44444444-4444-4444-4444-444444444444";
|
||||
private const string SeriesItemId = "55555555-5555-5555-5555-555555555555";
|
||||
private const string EpisodeItemId = "66666666-6666-6666-6666-666666666666";
|
||||
private const string ArtistItemId = "77777777-7777-7777-7777-777777777777";
|
||||
private const string DuplicateArtistItemId = "88888888-8888-8888-8888-888888888888";
|
||||
private const string AliceId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
private const string BobId = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
|
||||
|
||||
/// <summary>
|
||||
/// The last code migration the build that wrote the database shipped. Everything before it is recorded as
|
||||
/// applied, so the pending set is the one a real upgrade faces; everything from here on reads and writes
|
||||
/// the seeded library. The routines before it import a SQLite library.db that a PostgreSQL install never
|
||||
/// had, so they only ever run on a database the previous build already marked them on.
|
||||
/// </summary>
|
||||
private static readonly DateTime _previousReleaseOrder = new(2026, 2, 7, 0, 0, 0, DateTimeKind.Unspecified);
|
||||
|
||||
private PostgreSqlTestServer _server = null!;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_server = await PostgreSqlTestServer.StartAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _server.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A library written by the previous build has to reach the current schema through the whole startup
|
||||
/// sequence, not through the schema migrations alone.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExistingLibrary_CompletesEveryStage()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var connectionString = await _server.CreateDatabaseAsync("startup_existing", cancellationToken);
|
||||
|
||||
await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
|
||||
await MigrateToBaselineAsync(dataSource, cancellationToken);
|
||||
await RecordPreviousReleaseMigrationsAsync(dataSource, cancellationToken);
|
||||
await SeedLibraryAsync(dataSource, cancellationToken);
|
||||
|
||||
await RunStartupMigrationsAsync(dataSource, cancellationToken);
|
||||
|
||||
await using var context = CreateContext(dataSource);
|
||||
Assert.Empty(await context.Database.GetPendingMigrationsAsync(cancellationToken));
|
||||
Assert.False(context.Database.HasPendingModelChanges());
|
||||
|
||||
// The rating recalculation is the code migration that reads every distinct rating and writes each
|
||||
// group, so its result proves the code migrations really ran against the seeded rows.
|
||||
Assert.Equal(13, await RatingValueAsync(dataSource, MovieItemId, cancellationToken));
|
||||
Assert.Null(await RatingValueAsync(dataSource, UnratedMovieItemId, cancellationToken));
|
||||
Assert.Null(await RatingValueAsync(dataSource, EmptyRatingMovieItemId, cancellationToken));
|
||||
|
||||
// The username code migration has to have filled the column the unique index is built on.
|
||||
Assert.Equal("ALICE", await ScalarStringAsync(dataSource, $"""SELECT "NormalizedUsername" FROM "Users" WHERE "Id" = '{AliceId}'""", cancellationToken));
|
||||
Assert.Equal("BOB", await ScalarStringAsync(dataSource, $"""SELECT "NormalizedUsername" FROM "Users" WHERE "Id" = '{BobId}'""", cancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fresh install goes through the same sequence, starting from the seeding the startup path does on a
|
||||
/// database that has never been migrated.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task FreshInstall_CompletesEveryStage()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var connectionString = await _server.CreateDatabaseAsync("startup_fresh", cancellationToken);
|
||||
|
||||
await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
|
||||
await RunStartupMigrationsAsync(dataSource, cancellationToken, seedFirstTimeRun: true);
|
||||
|
||||
await using var context = CreateContext(dataSource);
|
||||
Assert.Empty(await context.Database.GetPendingMigrationsAsync(cancellationToken));
|
||||
Assert.False(context.Database.HasPendingModelChanges());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs both database migration stages in the order <c>Program</c> runs them, leaving the migration
|
||||
/// service to interleave the schema and code migrations.
|
||||
/// </summary>
|
||||
private static async Task RunStartupMigrationsAsync(NpgsqlDataSource dataSource, CancellationToken cancellationToken, bool seedFirstTimeRun = false)
|
||||
{
|
||||
var dataPath = Directory.CreateTempSubdirectory("jellyfin-migration-test").FullName;
|
||||
try
|
||||
{
|
||||
await using var serviceProvider = BuildServiceProvider(dataSource, dataPath);
|
||||
var migrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(serviceProvider);
|
||||
|
||||
if (seedFirstTimeRun)
|
||||
{
|
||||
await migrationService
|
||||
.CheckFirstTimeRunOrMigration(serviceProvider.GetRequiredService<IApplicationPaths>(), new StartupOptions())
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await migrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, serviceProvider).ConfigureAwait(false);
|
||||
await migrationService.MigrateStepAsync(JellyfinMigrationStageTypes.AppInitialisation, serviceProvider).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dataPath, true);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records every code migration the previous build shipped as applied, the way that build left the
|
||||
/// database behind.
|
||||
/// </summary>
|
||||
private static async Task RecordPreviousReleaseMigrationsAsync(NpgsqlDataSource dataSource, CancellationToken cancellationToken)
|
||||
{
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
var applied = typeof(JellyfinMigrationService).Assembly.GetTypes()
|
||||
.Select(type => (Type: type, Metadata: type.GetCustomAttribute<JellyfinMigrationAttribute>()))
|
||||
.Where(candidate => candidate.Metadata is not null && candidate.Metadata.Order < _previousReleaseOrder)
|
||||
.Select(candidate => new CodeMigration(candidate.Type, candidate.Metadata!, null).BuildCodeMigrationId())
|
||||
.ToList();
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
|
||||
Assert.NotEmpty(applied);
|
||||
|
||||
var values = string.Join(", ", applied.Select(id => $"('{id}', '12.0.0.0')"));
|
||||
await ExecuteAsync(
|
||||
dataSource,
|
||||
$"""INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") VALUES {values}""",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the container the migration routines are resolved from. The services a routine reaches past the
|
||||
/// database are stubbed out; everything touching the database is real.
|
||||
/// </summary>
|
||||
private static ServiceProvider BuildServiceProvider(NpgsqlDataSource dataSource, string dataPath)
|
||||
{
|
||||
var loggerFactory = NullLoggerFactory.Instance;
|
||||
var applicationPaths = new ServerApplicationPaths(
|
||||
dataPath,
|
||||
Ensure(dataPath, "log"),
|
||||
Ensure(dataPath, "config"),
|
||||
Ensure(dataPath, "cache"),
|
||||
Ensure(dataPath, "web"));
|
||||
|
||||
// The real configuration manager with the real stores: a routine that rewrites a configuration file
|
||||
// would otherwise never get past looking its store up.
|
||||
var configurationManager = new ServerConfigurationManager(applicationPaths, loggerFactory, new MyXmlSerializer());
|
||||
configurationManager.AddParts(
|
||||
[
|
||||
new DatabaseConfigurationFactory(),
|
||||
new EncodingConfigurationFactory(),
|
||||
new MetadataConfigurationStore(),
|
||||
new NetworkConfigurationFactory(),
|
||||
new BrandingConfigurationFactory(),
|
||||
new NfoConfigurationFactory(),
|
||||
new LiveTvConfigurationFactory()
|
||||
]);
|
||||
|
||||
var libraryManager = new Mock<ILibraryManager>();
|
||||
libraryManager.Setup(manager => manager.GetItemList(It.IsAny<InternalItemsQuery>())).Returns([]);
|
||||
libraryManager.Setup(manager => manager.GetVirtualFolders(It.IsAny<bool>())).Returns([]);
|
||||
|
||||
var applicationHost = new Mock<IServerApplicationHost>();
|
||||
applicationHost.Setup(host => host.ExpandVirtualPath(It.IsAny<string>())).Returns<string>(path => path);
|
||||
applicationHost.Setup(host => host.ReverseVirtualPath(It.IsAny<string>())).Returns<string>(path => path);
|
||||
|
||||
var fileSystem = new Mock<IFileSystem>();
|
||||
fileSystem.Setup(system => system.GetValidFilename(It.IsAny<string>())).Returns<string>(name => name);
|
||||
|
||||
var trickplayManager = new Mock<ITrickplayManager>();
|
||||
trickplayManager
|
||||
.Setup(manager => manager.GetTrickplayItemsAsync(It.IsAny<int>(), It.IsAny<int>()))
|
||||
.ReturnsAsync([]);
|
||||
|
||||
var localizationManager = new Mock<ILocalizationManager>();
|
||||
localizationManager.Setup(manager => manager.GetRatingScore("PG-13", null)).Returns(new ParentalRatingScore(13, 2));
|
||||
|
||||
return new ServiceCollection()
|
||||
.AddLogging()
|
||||
.RegisterStartupLogger()
|
||||
.AddSingleton<IDbContextFactory<JellyfinDbContext>>(new DataSourceContextFactory(dataSource))
|
||||
.AddSingleton<IJellyfinDatabaseProvider>(new PostgreSqlDatabaseProvider(dataSource))
|
||||
.AddSingleton<ServerApplicationPaths>(applicationPaths)
|
||||
.AddSingleton<IServerApplicationPaths>(applicationPaths)
|
||||
.AddSingleton<IApplicationPaths>(applicationPaths)
|
||||
.AddSingleton<IServerConfigurationManager>(configurationManager)
|
||||
.AddSingleton<IConfigurationManager>(configurationManager)
|
||||
.AddSingleton<IXmlSerializer>(new MyXmlSerializer())
|
||||
.AddSingleton(libraryManager.Object)
|
||||
.AddSingleton(applicationHost.Object)
|
||||
.AddSingleton(fileSystem.Object)
|
||||
.AddSingleton(trickplayManager.Object)
|
||||
.AddSingleton(localizationManager.Object)
|
||||
.AddSingleton(Mock.Of<IProviderManager>())
|
||||
.AddSingleton(Mock.Of<IItemRepository>())
|
||||
.AddSingleton(Mock.Of<IItemCountService>())
|
||||
.AddSingleton(Mock.Of<IItemPersistenceService>())
|
||||
.AddSingleton(Mock.Of<IPathManager>())
|
||||
.AddSingleton(Mock.Of<IPlaylistManager>())
|
||||
.AddSingleton(Mock.Of<IUserManager>())
|
||||
.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private static string Ensure(string root, params string[] parts)
|
||||
{
|
||||
var path = Path.Combine([root, .. parts]);
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brings the database to the state the previous build left it in: the baseline schema migration and
|
||||
/// nothing else, so the startup sequence has the whole chain still ahead of it.
|
||||
/// </summary>
|
||||
private static async Task MigrateToBaselineAsync(NpgsqlDataSource dataSource, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var context = CreateContext(dataSource);
|
||||
await context.GetService<IMigrator>()
|
||||
.MigrateAsync(BaselineMigrationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a library big enough for the code migrations to have work to do: rated, unrated and
|
||||
/// empty-rated items, an owned extra, a series with an episode, duplicate artists, users with
|
||||
/// permissions and preferences.
|
||||
/// </summary>
|
||||
private static async Task SeedLibraryAsync(NpgsqlDataSource dataSource, CancellationToken cancellationToken)
|
||||
{
|
||||
await ExecuteAsync(
|
||||
dataSource,
|
||||
$"""
|
||||
INSERT INTO "Users" ("Id", "Username", "MustUpdatePassword", "AuthenticationProviderId", "PasswordResetProviderId",
|
||||
"InvalidLoginAttemptCount", "MaxActiveSessions", "SubtitleMode", "PlayDefaultAudioTrack", "DisplayMissingEpisodes",
|
||||
"DisplayCollectionsView", "EnableLocalPassword", "HidePlayedInLatest", "RememberAudioSelections",
|
||||
"RememberSubtitleSelections", "EnableNextEpisodeAutoPlay", "EnableAutoLogin", "EnableUserPreferenceAccess",
|
||||
"InternalId", "SyncPlayAccess", "RowVersion")
|
||||
VALUES
|
||||
('{AliceId}', 'alice', false, 'provider', 'provider', 0, 0, 0, true, false, true, false, false, true, true, true, false, true, 1, 0, 0),
|
||||
('{BobId}', 'bob', false, 'provider', 'provider', 0, 0, 0, true, false, true, false, false, true, true, true, false, true, 2, 0, 0);
|
||||
|
||||
INSERT INTO "Permissions" ("UserId", "Kind", "Value", "RowVersion")
|
||||
VALUES ('{AliceId}', 0, true, 0), ('{BobId}', 0, true, 0);
|
||||
|
||||
INSERT INTO "Preferences" ("UserId", "Kind", "Value", "RowVersion")
|
||||
VALUES ('{AliceId}', 0, 'kept', 0), ('{BobId}', 0, 'kept', 0);
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
// NULL and empty OfficialRating are the rows that make the rating migration write while it is still
|
||||
// reading the distinct rating list.
|
||||
await InsertItemAsync(dataSource, MovieItemId, "MediaBrowser.Controller.Entities.Movies.Movie", "Rated Movie", "PG-13", null, null, cancellationToken);
|
||||
await InsertItemAsync(dataSource, UnratedMovieItemId, "MediaBrowser.Controller.Entities.Movies.Movie", "Unrated Movie", null, null, null, cancellationToken);
|
||||
await InsertItemAsync(dataSource, EmptyRatingMovieItemId, "MediaBrowser.Controller.Entities.Movies.Movie", "Empty Rating Movie", string.Empty, null, null, cancellationToken);
|
||||
await InsertItemAsync(dataSource, TrailerItemId, "MediaBrowser.Controller.Entities.Video", "Trailer", null, MovieItemId, null, cancellationToken);
|
||||
await InsertItemAsync(dataSource, SeriesItemId, "MediaBrowser.Controller.Entities.TV.Series", "Series", "TV-14", null, null, cancellationToken);
|
||||
await InsertItemAsync(dataSource, EpisodeItemId, "MediaBrowser.Controller.Entities.TV.Episode", "Episode", "TV-14", null, SeriesItemId, cancellationToken);
|
||||
|
||||
// Two artists differing only by casing are what the merge migrations look for.
|
||||
await InsertItemAsync(dataSource, ArtistItemId, "MediaBrowser.Controller.Entities.Audio.MusicArtist", "The Band", null, null, null, cancellationToken);
|
||||
await InsertItemAsync(dataSource, DuplicateArtistItemId, "MediaBrowser.Controller.Entities.Audio.MusicArtist", "the band", null, null, null, cancellationToken);
|
||||
|
||||
await ExecuteAsync(
|
||||
dataSource,
|
||||
$"""
|
||||
INSERT INTO "Peoples" ("Id", "Name", "PersonType")
|
||||
VALUES ('{Guid.NewGuid()}', 'Jane Doe', 'Actor'), ('{Guid.NewGuid()}', 'jane doe', 'Actor');
|
||||
""",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static Task InsertItemAsync(
|
||||
NpgsqlDataSource dataSource,
|
||||
string itemId,
|
||||
string type,
|
||||
string name,
|
||||
string? officialRating,
|
||||
string? ownerId,
|
||||
string? parentId,
|
||||
CancellationToken cancellationToken)
|
||||
=> ExecuteAsync(
|
||||
dataSource,
|
||||
$"""
|
||||
INSERT INTO "BaseItems" ("Id", "Type", "IsMovie", "IsLocked", "IsSeries", "IsRepeat", "IsInMixedFolder",
|
||||
"IsFolder", "IsVirtualItem", "Name", "CleanName", "Path", "OfficialRating", "OwnerId", "ParentId",
|
||||
"InheritedParentalRatingValue", "InheritedParentalRatingSubValue")
|
||||
VALUES ('{itemId}', '{type}', false, false, false, false, false, false, false,
|
||||
'{name}', '{name.ToLowerInvariant()}', '/media/{itemId}.mkv', {Literal(officialRating)}, {Literal(ownerId)}, {Literal(parentId)},
|
||||
99, 98);
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
private static string Literal(string? value) => value is null ? "NULL" : $"'{value}'";
|
||||
|
||||
private static async Task ExecuteAsync(NpgsqlDataSource dataSource, string sql, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand(sql);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<int?> RatingValueAsync(NpgsqlDataSource dataSource, string itemId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand($"""SELECT "InheritedParentalRatingValue" FROM "BaseItems" WHERE "Id" = '{itemId}'""");
|
||||
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return value is null or DBNull ? null : (int?)value;
|
||||
}
|
||||
|
||||
private static async Task<string?> ScalarStringAsync(NpgsqlDataSource dataSource, string sql, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand(sql);
|
||||
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return value is null or DBNull ? null : (string)value;
|
||||
}
|
||||
|
||||
private sealed class DataSourceContextFactory : IDbContextFactory<JellyfinDbContext>
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
|
||||
public DataSourceContextFactory(NpgsqlDataSource dataSource)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
}
|
||||
|
||||
public JellyfinDbContext CreateDbContext() => CreateContext(_dataSource);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
|
||||
namespace Jellyfin.Server.Tests.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Hands out a PostgreSQL server for the tests that need one. A server named by
|
||||
/// <c>JELLYFIN_TEST_POSTGRES</c> is used as is, so CI can attach a service container instead of running
|
||||
/// a docker daemon of its own; without it a container is started through testcontainers.
|
||||
/// </summary>
|
||||
public sealed class PostgreSqlTestServer : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The connection string of an already running server. Must be able to create databases.
|
||||
/// </summary>
|
||||
public const string ConnectionStringVariable = "JELLYFIN_TEST_POSTGRES";
|
||||
|
||||
private readonly PostgreSqlContainer? _container;
|
||||
|
||||
private PostgreSqlTestServer(PostgreSqlContainer? container, string connectionString)
|
||||
{
|
||||
_container = container;
|
||||
ConnectionString = connectionString;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection string of the server holding the test databases.
|
||||
/// </summary>
|
||||
public string ConnectionString { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Starts or attaches to a PostgreSQL server and waits until it accepts connections.
|
||||
/// </summary>
|
||||
/// <returns>The running server.</returns>
|
||||
public static async Task<PostgreSqlTestServer> StartAsync()
|
||||
{
|
||||
var provided = Environment.GetEnvironmentVariable(ConnectionStringVariable);
|
||||
if (!string.IsNullOrWhiteSpace(provided))
|
||||
{
|
||||
var attached = new PostgreSqlTestServer(null, provided);
|
||||
await attached.WaitUntilReadyAsync().ConfigureAwait(false);
|
||||
return attached;
|
||||
}
|
||||
|
||||
var container = new PostgreSqlBuilder("postgres:16-alpine")
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
|
||||
.Build();
|
||||
await container.StartAsync().ConfigureAwait(false);
|
||||
|
||||
var started = new PostgreSqlTestServer(container, container.GetConnectionString());
|
||||
await started.WaitUntilReadyAsync().ConfigureAwait(false);
|
||||
return started;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty database and returns a connection string pointing at it.
|
||||
/// </summary>
|
||||
/// <param name="name">The database name.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The connection string of the new database.</returns>
|
||||
public async Task<string> CreateDatabaseAsync(string name, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var adminDataSource = new NpgsqlDataSourceBuilder(ConnectionString).Build();
|
||||
|
||||
// A server handed in through the environment outlives the run, so a second run finds the
|
||||
// databases the first one left behind.
|
||||
await using var drop = adminDataSource.CreateCommand($"DROP DATABASE IF EXISTS {name}");
|
||||
await drop.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = adminDataSource.CreateCommand($"CREATE DATABASE {name}");
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new NpgsqlConnectionStringBuilder(ConnectionString) { Database = name }.ConnectionString;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_container is not null)
|
||||
{
|
||||
await _container.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits until a real connection is accepted. <c>pg_isready</c> also answers for the short-lived server
|
||||
/// the entrypoint runs while it initializes the data directory.
|
||||
/// </summary>
|
||||
private async Task WaitUntilReadyAsync()
|
||||
{
|
||||
await using var dataSource = new NpgsqlDataSourceBuilder(ConnectionString).Build();
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("SELECT 1");
|
||||
await command.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
catch (NpgsqlException) when (attempt < 60)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ using System.Globalization;
|
||||
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;
|
||||
@@ -23,7 +22,6 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Tests.Migrations;
|
||||
@@ -56,46 +54,23 @@ public sealed class PostgreSqlUpgradeTests : IAsyncLifetime
|
||||
// does not.
|
||||
private const string MalformedGuid = "1111111-11111-1111-1111-111111111111";
|
||||
|
||||
private readonly PostgreSqlContainer _container;
|
||||
|
||||
public PostgreSqlUpgradeTests()
|
||||
{
|
||||
_container = new PostgreSqlBuilder("postgres:16-alpine")
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
|
||||
.Build();
|
||||
}
|
||||
private PostgreSqlTestServer _server = null!;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _container.StartAsync().ConfigureAwait(false);
|
||||
|
||||
// pg_isready also answers for the short-lived server the entrypoint runs while initializing the data
|
||||
// directory, so wait until a real connection is accepted before handing the container to a test.
|
||||
await using var dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build();
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ExecuteAsync(dataSource, "SELECT 1", CancellationToken.None).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
catch (NpgsqlException) when (attempt < 30)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
_server = await PostgreSqlTestServer.StartAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _container.DisposeAsync().ConfigureAwait(false);
|
||||
await _server.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FreshInstall_AppliesTheWholeChain()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var connectionString = await CreateDatabaseAsync("fresh_install", cancellationToken);
|
||||
var connectionString = await _server.CreateDatabaseAsync("fresh_install", cancellationToken);
|
||||
|
||||
await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
|
||||
await MigrateAsync(dataSource, null, cancellationToken);
|
||||
@@ -110,7 +85,7 @@ public sealed class PostgreSqlUpgradeTests : IAsyncLifetime
|
||||
public async Task LegacyDatabase_UpgradesToServer12Schema()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var connectionString = await CreateDatabaseAsync("legacy_upgrade", cancellationToken);
|
||||
var connectionString = await _server.CreateDatabaseAsync("legacy_upgrade", cancellationToken);
|
||||
|
||||
await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
|
||||
await CreateLegacyDatabaseAsync(dataSource, cancellationToken);
|
||||
@@ -154,9 +129,9 @@ public sealed class PostgreSqlUpgradeTests : IAsyncLifetime
|
||||
public async Task LegacyDatabaseAndFreshInstall_EndUpWithTheModelSchema()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var modelConnectionString = await CreateDatabaseAsync("schema_model", cancellationToken);
|
||||
var freshConnectionString = await CreateDatabaseAsync("schema_fresh", cancellationToken);
|
||||
var upgradedConnectionString = await CreateDatabaseAsync("schema_upgraded", cancellationToken);
|
||||
var modelConnectionString = await _server.CreateDatabaseAsync("schema_model", cancellationToken);
|
||||
var freshConnectionString = await _server.CreateDatabaseAsync("schema_fresh", cancellationToken);
|
||||
var upgradedConnectionString = await _server.CreateDatabaseAsync("schema_upgraded", cancellationToken);
|
||||
|
||||
// The model built straight from the context is the reference: comparing the two migrated databases against
|
||||
// each other only proves they drifted together.
|
||||
@@ -187,7 +162,7 @@ public sealed class PostgreSqlUpgradeTests : IAsyncLifetime
|
||||
public async Task LegacyDatabase_MalformedGuidIds_AreCleared()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var connectionString = await CreateDatabaseAsync("malformed_guid", cancellationToken);
|
||||
var connectionString = await _server.CreateDatabaseAsync("malformed_guid", cancellationToken);
|
||||
|
||||
await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
|
||||
await CreateLegacyDatabaseAsync(dataSource, cancellationToken);
|
||||
@@ -206,7 +181,7 @@ public sealed class PostgreSqlUpgradeTests : IAsyncLifetime
|
||||
public async Task LegacyDatabase_UpgradesWhetherOrNotThePlaceholderItemExists(bool placeholderExists)
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var connectionString = await CreateDatabaseAsync(placeholderExists ? "placeholder_present" : "placeholder_missing", cancellationToken);
|
||||
var connectionString = await _server.CreateDatabaseAsync(placeholderExists ? "placeholder_present" : "placeholder_missing", cancellationToken);
|
||||
|
||||
await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
|
||||
await CreateLegacyDatabaseAsync(dataSource, cancellationToken);
|
||||
@@ -227,7 +202,7 @@ public sealed class PostgreSqlUpgradeTests : IAsyncLifetime
|
||||
public async Task LegacyDatabase_ItemOwnedByThePlaceholder_SurvivesCleanupOrphanedExtras()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var connectionString = await CreateDatabaseAsync("placeholder_owner", cancellationToken);
|
||||
var connectionString = await _server.CreateDatabaseAsync("placeholder_owner", cancellationToken);
|
||||
|
||||
await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
|
||||
await CreateLegacyDatabaseAsync(dataSource, cancellationToken);
|
||||
@@ -258,7 +233,7 @@ public sealed class PostgreSqlUpgradeTests : IAsyncLifetime
|
||||
public async Task MigrateRatingLevels_RecalculatesEveryRating()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var connectionString = await CreateDatabaseAsync("rating_levels", cancellationToken);
|
||||
var connectionString = await _server.CreateDatabaseAsync("rating_levels", cancellationToken);
|
||||
|
||||
await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
|
||||
await MigrateAsync(dataSource, null, cancellationToken);
|
||||
@@ -291,7 +266,7 @@ public sealed class PostgreSqlUpgradeTests : IAsyncLifetime
|
||||
public async Task UniqueUsernameIndex_CannotBeAppliedBeforeTheUsernameCodeMigration()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var connectionString = await CreateDatabaseAsync("username_index_order", cancellationToken);
|
||||
var connectionString = await _server.CreateDatabaseAsync("username_index_order", cancellationToken);
|
||||
|
||||
await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
|
||||
await CreateLegacyDatabaseAsync(dataSource, cancellationToken);
|
||||
@@ -614,14 +589,6 @@ public sealed class PostgreSqlUpgradeTests : IAsyncLifetime
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> CreateDatabaseAsync(string name, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var adminDataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build();
|
||||
await ExecuteAsync(adminDataSource, $"CREATE DATABASE {name}", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new NpgsqlConnectionStringBuilder(_container.GetConnectionString()) { Database = name }.ConnectionString;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stands in for the startup logger. Moq cannot proxy a logger whose category is an internal migration.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user