using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Jellyfin.Server.Migrations.Routines;
using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.IO;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Server.Tests.Migrations;
///
/// Covers the references a database carried up from 10.x can still hold against a view the migration
/// is about to drop. Only ParentId cascades; everything else here is a NO ACTION foreign key that
/// used to abort the migration, and with it the whole startup.
///
public sealed class ConsolidateLocalizedUserViewsTests : IDisposable
{
private const string MetadataPath = "/metadata";
private static readonly Guid _staleId = new("11111111-1111-1111-1111-111111111111");
private static readonly Guid _canonicalId = new("22222222-2222-2222-2222-222222222222");
private readonly SqliteConnection _connection;
private readonly DbContextOptions _dbOptions;
public ConsolidateLocalizedUserViewsTests()
{
_connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder()
.UseSqlite(_connection)
.Options;
using var context = CreateDbContext();
context.Database.EnsureCreated();
}
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Fact]
public async Task PerformAsync_ItemOwnedByStaleView_MovesItAndDropsTheView()
{
using (var context = CreateDbContext())
{
context.BaseItems.Add(StaleView());
context.BaseItems.Add(new BaseItemEntity { Id = Guid.NewGuid(), Type = "Trailer", OwnerId = _staleId });
await context.SaveChangesAsync(Ct);
}
await CreateMigration().PerformAsync(Ct);
using (var context = CreateDbContext())
{
Assert.Null(await context.BaseItems.FindAsync([_staleId], Ct));
Assert.Equal(_canonicalId, (await context.BaseItems.SingleAsync(e => e.Type == "Trailer", Ct)).OwnerId);
}
}
[Fact]
public async Task PerformAsync_StaleViewInLinkedChildren_DropsTheLinksAndTheView()
{
var movieId = Guid.NewGuid();
using (var context = CreateDbContext())
{
context.BaseItems.Add(StaleView());
context.BaseItems.Add(new BaseItemEntity { Id = movieId, Type = "Movie" });
context.LinkedChildren.Add(new LinkedChildEntity { ParentId = _staleId, SortOrder = 0, ChildId = movieId, ChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType.Manual });
context.LinkedChildren.Add(new LinkedChildEntity { ParentId = movieId, SortOrder = 0, ChildId = _staleId, ChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType.Manual });
await context.SaveChangesAsync(Ct);
}
await CreateMigration().PerformAsync(Ct);
using (var context = CreateDbContext())
{
Assert.Null(await context.BaseItems.FindAsync([_staleId], Ct));
Assert.Empty(context.LinkedChildren);
Assert.NotNull(await context.BaseItems.FindAsync([movieId], Ct));
}
}
[Fact]
public async Task PerformAsync_OrphanedAncestry_IsNotResurrectedUnderTheCanonicalView()
{
var childId = Guid.NewGuid();
var orphanId = Guid.NewGuid();
using (var context = CreateDbContext())
{
context.BaseItems.Add(StaleView());
context.BaseItems.Add(new BaseItemEntity { Id = childId, Type = "Movie", ParentId = _staleId });
await context.SaveChangesAsync(Ct);
context.AncestorIds.Add(new AncestorId { ItemId = childId, ParentItemId = _staleId, Item = null!, ParentItem = null! });
await context.SaveChangesAsync(Ct);
// Written while foreign keys went unenforced: the item behind it is long gone.
await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF", Ct);
context.AncestorIds.Add(new AncestorId { ItemId = orphanId, ParentItemId = _staleId, Item = null!, ParentItem = null! });
await context.SaveChangesAsync(Ct);
await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = ON", Ct);
}
await CreateMigration().PerformAsync(Ct);
using (var context = CreateDbContext())
{
Assert.Null(await context.BaseItems.FindAsync([_staleId], Ct));
Assert.Equal(_canonicalId, (await context.BaseItems.SingleAsync(e => e.Id.Equals(childId), Ct)).ParentId);
var ancestors = await context.AncestorIds.ToListAsync(Ct);
Assert.Equal(new[] { childId }, ancestors.Select(e => e.ItemId));
Assert.Equal(_canonicalId, ancestors[0].ParentItemId);
}
}
public void Dispose()
{
_connection.Dispose();
GC.SuppressFinalize(this);
}
private static BaseItemEntity StaleView() => new()
{
Id = _staleId,
Type = "MediaBrowser.Controller.Entities.UserView",
Path = Path.Combine(MetadataPath, "views", "livetv")
};
private JellyfinDbContext CreateDbContext() => new(
_dbOptions,
NullLogger.Instance,
new SqliteDatabaseProvider(new Mock().Object, NullLogger.Instance),
new NoLockBehavior(NullLogger.Instance));
private ConsolidateLocalizedUserViews CreateMigration()
{
var view = new UserView
{
Id = _staleId,
Path = Path.Combine(MetadataPath, "views", "livetv"),
Name = "Live TV",
ViewType = CollectionType.livetv
};
var applicationPaths = new Mock();
applicationPaths.Setup(e => e.InternalMetadataPath).Returns(MetadataPath);
var configurationManager = new Mock();
configurationManager.Setup(e => e.ApplicationPaths).Returns(applicationPaths.Object);
var fileSystem = new Mock();
fileSystem.Setup(e => e.GetValidFilename(It.IsAny())).Returns((string name) => name);
var libraryManager = new Mock();
libraryManager.Setup(e => e.GetItemList(It.IsAny()))
.Returns(new List { view });
libraryManager.Setup(e => e.GetNewItemId(It.IsAny(), It.IsAny()))
.Returns(_canonicalId);
libraryManager.Setup(e => e.CreateItem(It.IsAny(), It.IsAny()))
.Callback((BaseItem item, BaseItem? parent) =>
{
using var context = CreateDbContext();
context.BaseItems.Add(new BaseItemEntity
{
Id = item.Id,
Type = item.GetType().FullName!,
Path = item.Path
});
context.SaveChanges();
});
var factory = new Mock>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
factory.Setup(f => f.CreateDbContextAsync(It.IsAny())).ReturnsAsync(CreateDbContext);
return new ConsolidateLocalizedUserViews(
new StartupLogger(NullLogger.Instance),
libraryManager.Object,
configurationManager.Object,
fileSystem.Object,
factory.Object);
}
}