diff options
Diffstat (limited to 'tests/Jellyfin.Server.Implementations.Tests')
13 files changed, 1362 insertions, 4 deletions
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs new file mode 100644 index 0000000000..96625ae670 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs @@ -0,0 +1,137 @@ +using System; +using Emby.Server.Implementations.Dto; +using Emby.Server.Implementations.Playlists; +using Jellyfin.Data.Enums; +using MediaBrowser.Common; +using MediaBrowser.Controller.Chapters; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Trickplay; +using MediaBrowser.Model.Entities; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Dto; + +public class DtoServiceImageInheritanceTests +{ + [Fact] + public void GetBaseItemDto_PlaylistsUserViewWithDisplayParentPrimary_UsesDisplayParentPrimaryImage() + { + var displayParent = new PlaylistsFolder + { + Id = Guid.NewGuid(), + ImageInfos = + [ + new ItemImageInfo + { + Type = ImageType.Primary, + Path = "/images/playlists-custom.jpg", + DateModified = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc) + } + ] + }; + + var userView = new UserView + { + Id = Guid.NewGuid(), + ViewType = CollectionType.playlists, + DisplayParentId = displayParent.Id, + ImageInfos = + [ + new ItemImageInfo + { + Type = ImageType.Primary, + Path = "/images/generated.png", + DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc) + } + ] + }; + + var dtoService = BuildDtoService(displayParent); + + var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false)); + + Assert.NotNull(dto.ParentPrimaryImageItemId); + Assert.Equal(displayParent.Id, dto.ParentPrimaryImageItemId); + Assert.Equal("/images/playlists-custom.jpg", dto.ParentPrimaryImageTag); + Assert.False(dto.ImageTags?.ContainsKey(ImageType.Primary)); + } + + [Fact] + public void GetBaseItemDto_PlaylistsUserViewWithoutDisplayParentPrimary_KeepsOwnPrimaryImage() + { + var displayParent = new PlaylistsFolder + { + Id = Guid.NewGuid(), + ImageInfos = [] + }; + + var userView = new UserView + { + Id = Guid.NewGuid(), + ViewType = CollectionType.playlists, + DisplayParentId = displayParent.Id, + ImageInfos = + [ + new ItemImageInfo + { + Type = ImageType.Primary, + Path = "/images/generated.png", + DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc) + } + ] + }; + + var dtoService = BuildDtoService(displayParent); + + var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false)); + + Assert.Null(dto.ParentPrimaryImageItemId); + Assert.Null(dto.ParentPrimaryImageTag); + Assert.NotNull(dto.ImageTags); + Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary)); + Assert.Equal("/images/generated.png", dto.ImageTags[ImageType.Primary]); + } + + private static DtoService BuildDtoService(BaseItem displayParent) + { + var libraryManager = new Mock<ILibraryManager>(); + var userDataManager = new Mock<IUserDataManager>(); + var imageProcessor = new Mock<IImageProcessor>(); + var providerManager = new Mock<IProviderManager>(); + var recordingsManager = new Mock<IRecordingsManager>(); + var appHost = new Mock<IApplicationHost>(); + var mediaSourceManager = new Mock<IMediaSourceManager>(); + var liveTvManager = new Mock<ILiveTvManager>(); + var trickplayManager = new Mock<ITrickplayManager>(); + var chapterManager = new Mock<IChapterManager>(); + var logger = new Mock<Microsoft.Extensions.Logging.ILogger<DtoService>>(); + + libraryManager + .Setup(x => x.GetItemById(displayParent.Id)) + .Returns(displayParent); + + imageProcessor + .Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>())) + .Returns<BaseItem, ItemImageInfo>((_, image) => image.Path); + + return new DtoService( + logger.Object, + libraryManager.Object, + userDataManager.Object, + imageProcessor.Object, + providerManager.Object, + recordingsManager.Object, + appHost.Object, + mediaSourceManager.Object, + new Lazy<ILiveTvManager>(() => liveTvManager.Object), + trickplayManager.Object, + chapterManager.Object); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs new file mode 100644 index 0000000000..9c247d54b9 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs @@ -0,0 +1,138 @@ +using System; +using Emby.Server.Implementations.Dto; +using MediaBrowser.Common; +using MediaBrowser.Controller.Chapters; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Trickplay; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Dto; + +public class DtoServiceTests +{ + private readonly Mock<ILibraryManager> _libraryManagerMock; + private readonly DtoService _dtoService; + + public DtoServiceTests() + { + _libraryManagerMock = new Mock<ILibraryManager>(); + + var imageProcessor = new Mock<IImageProcessor>(); + // Deterministic tag derived from the image so each item gets a distinct, assertable tag. + imageProcessor + .Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>())) + .Returns((BaseItem _, ItemImageInfo image) => "tag:" + image.Path); + + var appHost = new Mock<IApplicationHost>(); + appHost.Setup(x => x.SystemId).Returns("test-server"); + + // Video.SourceType probes the active-recording manager; provide one so it doesn't NRE. + Video.RecordingsManager = new Mock<IRecordingsManager>().Object; + + _dtoService = new DtoService( + NullLogger<DtoService>.Instance, + _libraryManagerMock.Object, + new Mock<IUserDataManager>().Object, + imageProcessor.Object, + new Mock<IProviderManager>().Object, + new Mock<IRecordingsManager>().Object, + appHost.Object, + new Mock<IMediaSourceManager>().Object, + new Lazy<ILiveTvManager>(() => new Mock<ILiveTvManager>().Object), + new Mock<ITrickplayManager>().Object, + new Mock<IChapterManager>().Object); + + // Episode.Series / Episode.Season resolve through the static BaseItem.LibraryManager. + BaseItem.LibraryManager = _libraryManagerMock.Object; + } + + [Fact] + public void GetBaseItemDto_Episode_AttachesSeasonPosterAsParentPrimaryImage() + { + var (episode, season, _) = BuildEpisode(seasonHasPoster: true); + var options = new DtoOptions(false) { Fields = [ItemFields.PrimaryImageAspectRatio] }; + + var dto = _dtoService.GetBaseItemDto(episode, options); + + // The season poster is attached additively; the episode keeps its own primary and 16:9 ratio, + // and clients decide per view whether to prefer the parent/series poster over the episode still. + Assert.NotNull(dto.ImageTags); + Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary)); + Assert.NotNull(dto.SeriesPrimaryImageTag); + Assert.Equal(season.Id, dto.ParentPrimaryImageItemId); + Assert.Equal("tag:" + season.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag); + // Aspect ratio stays the episode's own image, not the poster's. + Assert.Equal(episode.GetDefaultPrimaryImageAspectRatio(), dto.PrimaryImageAspectRatio); + } + + [Fact] + public void GetBaseItemDto_Episode_ParentPrimaryImageFallsBackToSeriesWhenSeasonHasNoPoster() + { + var (episode, _, series) = BuildEpisode(seasonHasPoster: false); + var options = new DtoOptions(false); + + var dto = _dtoService.GetBaseItemDto(episode, options); + + // Episode image is retained; ParentPrimaryImage falls back to the series poster. + Assert.NotNull(dto.ImageTags); + Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary)); + Assert.NotNull(dto.SeriesPrimaryImageTag); + Assert.Equal(series.Id, dto.ParentPrimaryImageItemId); + Assert.Equal("tag:" + series.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag); + } + + [Fact] + public void GetBaseItemDto_Episode_WithoutParentPosters_KeepsOnlyEpisodePrimary() + { + var (episode, _, _) = BuildEpisode(seasonHasPoster: false, seriesHasPoster: false); + var options = new DtoOptions(false); + + var dto = _dtoService.GetBaseItemDto(episode, options); + + // With no season or series poster there is nothing to attach; the episode keeps its own primary. + Assert.NotNull(dto.ImageTags); + Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary)); + Assert.Null(dto.ParentPrimaryImageItemId); + } + + private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster, bool seriesHasPoster = true) + { + // Non-local (http) paths keep aspect-ratio resolution off the image processor and on the + // item's default ratio, which is portrait (2/3) for Season/Series and 16:9 for Episode. + var series = new Series { Id = Guid.NewGuid(), Name = "Series" }; + if (seriesHasPoster) + { + series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0); + } + + var season = new Season { Id = Guid.NewGuid(), Name = "Season", SeriesId = series.Id }; + if (seasonHasPoster) + { + season.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/season.jpg" }, 0); + } + + var episode = new Episode + { + Id = Guid.NewGuid(), + Name = "Episode", + SeasonId = season.Id, + SeriesId = series.Id + }; + episode.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/episode.jpg" }, 0); + + _libraryManagerMock.Setup(x => x.GetItemById(season.Id)).Returns(season); + _libraryManagerMock.Setup(x => x.GetItemById(series.Id)).Returns(series); + + return (episode, season, series); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs index c06279af2d..6cadfacce8 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs @@ -10,7 +10,7 @@ using Xunit; namespace Jellyfin.Server.Implementations.Tests.IO; -public class ManagedFileSystemTests +public partial class ManagedFileSystemTests { private readonly IFixture _fixture; private readonly ManagedFileSystem _sut; @@ -117,7 +117,7 @@ public class ManagedFileSystemTests } [SuppressMessage("Naming Rules", "SA1300:ElementMustBeginWithUpperCaseLetter", Justification = "Have to")] - [DllImport("libc", SetLastError = true, CharSet = CharSet.Ansi)] + [LibraryImport("libc", SetLastError = true)] [DefaultDllImportSearchPaths(DllImportSearchPath.UserDirectories)] - private static extern int symlink(string target, string linkpath); + private static partial int symlink([MarshalAs(UnmanagedType.LPStr)] string target, [MarshalAs(UnmanagedType.LPStr)] string linkpath); } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs new file mode 100644 index 0000000000..c8aa14af58 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs @@ -0,0 +1,233 @@ +#pragma warning disable RS0030 // Do not use banned APIs: Guid == is required inside EF expression trees to mirror the production query shapes. + +using System; +using System.Linq; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Verifies that the alternate-version-aware query shapes used by the resume filter +/// (BaseItemRepository.TranslateQuery) and the DatePlayed ordering (OrderMapper) translate +/// and evaluate correctly on the SQLite provider. +/// </summary> +public sealed class AlternateVersionQueryTranslationTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + + public AlternateVersionQueryTranslationTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + using var ctx = CreateDbContext(); + ctx.Database.EnsureCreated(); + } + + [Fact] + public void ResumeFilter_VersionProgress_SurfacesPlayedVersion() + { + Guid userId, primaryId, versionId, otherId; + + using (var ctx = CreateDbContext()) + { + (userId, primaryId, versionId, otherId) = Seed(ctx); + } + + using (var ctx = CreateDbContext()) + { + var inProgress = ctx.UserData + .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0); + + // Scope to the seeded items; EnsureCreated also seeds a placeholder row. + var seededIds = new[] { primaryId, versionId, otherId }; + + // Mirrors the resumable=true filter in BaseItemRepository.TranslateQuery. + var inProgressIds = inProgress.Select(ud => ud.ItemId); + var resumable = ctx.BaseItems + .Where(e => seededIds.Contains(e.Id)) + .Where(e => inProgressIds.Contains(e.Id)) + .Where(e => !ctx.BaseItems + .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id)) + .Any(s => + inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) + || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) + && s.Id.CompareTo(e.Id) < 0))) + .Select(e => e.Id) + .ToList(); + + Assert.Equal([versionId], resumable); + + // The not-resumable direction keeps primaries only. + var resumableMovieIds = inProgress + .Join(ctx.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id); + var notResumable = ctx.BaseItems + .Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null) + .Where(e => !resumableMovieIds.Contains(e.Id)) + .Select(e => e.Id) + .ToList(); + + Assert.Equal([otherId], notResumable); + } + } + + [Fact] + public void ResumeFilter_TiedLastPlayedDate_KeepsSingleVersion() + { + Guid userId, primaryId, versionAId, versionBId; + + using (var ctx = CreateDbContext()) + { + (userId, primaryId, versionAId, versionBId) = SeedTiedVersions(ctx); + } + + using (var ctx = CreateDbContext()) + { + var inProgress = ctx.UserData + .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0); + + var seededIds = new[] { primaryId, versionAId, versionBId }; + var inProgressIds = inProgress.Select(ud => ud.ItemId); + + // The exact production dedup, including the Guid.CompareTo tie-break. This asserts the + // expression translates on SQLite and that two versions sharing an identical LastPlayedDate + // collapse to a single row instead of double-listing the item in Continue Watching. + var resumable = ctx.BaseItems + .Where(e => seededIds.Contains(e.Id)) + .Where(e => inProgressIds.Contains(e.Id)) + .Where(e => !ctx.BaseItems + .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id)) + .Any(s => + inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) + || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) + && s.Id.CompareTo(e.Id) < 0))) + .Select(e => e.Id) + .ToList(); + + var survivor = Assert.Single(resumable); + Assert.Contains(survivor, new[] { versionAId, versionBId }); + } + } + + [Fact] + public void DatePlayedOrdering_VersionProgress_SortsPrimaryByVersionDate() + { + Guid userId, primaryId, otherId; + + using (var ctx = CreateDbContext()) + { + (userId, primaryId, _, otherId) = Seed(ctx); + } + + using (var ctx = CreateDbContext()) + { + // Scope to the seeded items; EnsureCreated also seeds a placeholder row. + var seededIds = new[] { primaryId, otherId }; + + // Mirrors the DatePlayed mapping in OrderMapper. + var ordered = ctx.BaseItems + .Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null) + .OrderByDescending(e => ctx.UserData + .Where(w => w.UserId == userId && (w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id)) + .Max(f => f.LastPlayedDate)) + .Select(e => e.Id) + .ToList(); + + // The movie whose only progress is on its alternate version sorts before the unplayed one. + Assert.Equal([primaryId, otherId], ordered); + } + } + + private static (Guid UserId, Guid PrimaryId, Guid VersionId, Guid OtherId) Seed(JellyfinDbContext ctx) + { + var user = new User("test", "auth-provider", "reset-provider"); + ctx.Users.Add(user); + + var primary = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" }; + var version = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id }; + var other = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" }; + ctx.BaseItems.AddRange(primary, version, other); + + // Progress only on the alternate version. + ctx.UserData.Add(new UserData + { + ItemId = version.Id, + Item = version, + UserId = user.Id, + User = user, + CustomDataKey = version.Id.ToString("N"), + PlaybackPositionTicks = 1000, + LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) + }); + + ctx.SaveChanges(); + return (user.Id, primary.Id, version.Id, other.Id); + } + + private static (Guid UserId, Guid PrimaryId, Guid VersionAId, Guid VersionBId) SeedTiedVersions(JellyfinDbContext ctx) + { + var user = new User("test", "auth-provider", "reset-provider"); + ctx.Users.Add(user); + + var primary = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" }; + var versionA = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id }; + var versionB = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id }; + ctx.BaseItems.AddRange(primary, versionA, versionB); + + // Both versions in progress with the exact same LastPlayedDate - the tie that a strict '>' cannot break. + var tied = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); + ctx.UserData.Add(new UserData + { + ItemId = versionA.Id, + Item = versionA, + UserId = user.Id, + User = user, + CustomDataKey = versionA.Id.ToString("N"), + PlaybackPositionTicks = 1000, + LastPlayedDate = tied + }); + ctx.UserData.Add(new UserData + { + ItemId = versionB.Id, + Item = versionB, + UserId = user.Id, + User = user, + CustomDataKey = versionB.Id.ToString("N"), + PlaybackPositionTicks = 2000, + LastPlayedDate = tied + }); + + ctx.SaveChanges(); + return (user.Id, primary.Id, versionA.Id, versionB.Id); + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } + + public void Dispose() + { + _connection.Dispose(); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs new file mode 100644 index 0000000000..083f725db9 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs @@ -0,0 +1,144 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public sealed class BaseItemRepositoryGroupingTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly BaseItemRepository _repository; + private readonly string _movieTypeName; + + public BaseItemRepositoryGroupingTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + using (var ctx = CreateDbContext()) + { + ctx.Database.EnsureCreated(); + } + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + + var itemTypeLookup = new ItemTypeLookup(); + _movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; + + var serverConfigurationManager = new Mock<IServerConfigurationManager>(); + serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + + _repository = new BaseItemRepository( + factory.Object, + new Mock<IServerApplicationHost>().Object, + itemTypeLookup, + serverConfigurationManager.Object, + NullLogger<BaseItemRepository>.Instance); + } + + public void Dispose() + { + _connection.Dispose(); + } + + [Fact] + public void GetItemList_VersionGroup_ReturnsPrimaryVersion() + { + // The alternate version sorts before the primary by id, so a plain Min(Id) per + // presentation key would wrongly pick the alternate as the group representative. + var primaryId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); + var alternateId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var presentationKey = primaryId.ToString("N"); + + using (var ctx = CreateDbContext()) + { + ctx.BaseItems.Add(CreateMovieEntity(primaryId, "Movie", presentationKey, null)); + ctx.BaseItems.Add(CreateMovieEntity(alternateId, "Movie - 1080p", presentationKey, primaryId)); + ctx.SaveChanges(); + } + + var result = _repository.GetItemList(CreateQuery()); + + var item = Assert.Single(result); + Assert.Equal(primaryId, item.Id); + } + + [Fact] + public void GetItemList_GroupWithoutPrimary_FallsBackToMinId() + { + var firstId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + var secondId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"); + var otherPrimaryId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); + var presentationKey = otherPrimaryId.ToString("N"); + + using (var ctx = CreateDbContext()) + { + ctx.BaseItems.Add(CreateMovieEntity(firstId, "Movie", presentationKey, otherPrimaryId)); + ctx.BaseItems.Add(CreateMovieEntity(secondId, "Movie - 4K", presentationKey, otherPrimaryId)); + ctx.SaveChanges(); + } + + var result = _repository.GetItemList(CreateQuery()); + + var item = Assert.Single(result); + Assert.Equal(firstId, item.Id); + } + + private static InternalItemsQuery CreateQuery() + { + // IncludeOwnedItems keeps the alternate version rows in the query so the + // grouping collapse is what picks the group representative. + return new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset")) + { + IncludeItemTypes = [BaseItemKind.Movie], + IncludeOwnedItems = true + }; + } + + private BaseItemEntity CreateMovieEntity(Guid id, string name, string presentationKey, Guid? primaryVersionId) + { + return new BaseItemEntity + { + Id = id, + Type = _movieTypeName, + Name = name, + PresentationUniqueKey = presentationKey, + PrimaryVersionId = primaryVersionId, + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false + }; + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj index 958ffb8b6e..29de52a2ba 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj +++ b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj @@ -4,6 +4,7 @@ <PropertyGroup> <ProjectGuid>{2E3A1B4B-4225-4AAA-8B29-0181A84E7AEE}</ProjectGuid> <OutputType>Exe</OutputType> + <AllowUnsafeBlocks>true</AllowUnsafeBlocks> </PropertyGroup> <ItemGroup> diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs index facdb2bc2e..c80f899498 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using AutoFixture; using AutoFixture.AutoMoq; using Castle.Components.DictionaryAdapter; @@ -7,6 +9,8 @@ using Emby.Server.Implementations.Library; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; @@ -144,5 +148,105 @@ namespace Jellyfin.Server.Implementations.Tests.Library _mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user); Assert.Equal(expectedIndex, mediaInfo.DefaultAudioStreamIndex); } + + [Fact] + public void GetStaticMediaSources_PrimaryQueried_DefaultsToMostRecentlyPlayedVersion() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + SetupUserDataBatch(new Dictionary<Guid, UserItemData> + { + [alt1.Id] = new UserItemData { Key = "alt1", PlaybackPositionTicks = 10, LastPlayedDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc) }, + [alt2.Id] = new UserItemData { Key = "alt2", PlaybackPositionTicks = 20, LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) } + }); + + var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user); + + // The most recently played version is the default source, so resuming plays the right file. + // Per-user positions live in each version's UserData, not on the source. + Assert.Equal(alt2.Id.ToString("N"), sources[0].Id); + } + + [Fact] + public void GetStaticMediaSources_AlternateQueried_KeepsOwnSourceFirst() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + SetupUserDataBatch(new Dictionary<Guid, UserItemData> + { + [alt2.Id] = new UserItemData { Key = "alt2", PlaybackPositionTicks = 20, LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) } + }); + + var sources = _mediaSourceManager.GetStaticMediaSources(alt1, false, _user); + + // An explicitly opened version keeps its own source first, even when a sibling was + // played more recently. + Assert.Equal(alt1.Id.ToString("N"), sources[0].Id); + Assert.Equal(3, sources.Count); + } + + [Fact] + public void GetStaticMediaSources_NoProgress_KeepsQueriedItemFirst() + { + var (primary, _, _) = SetupVersionGroup(); + SetupUserDataBatch([]); + + var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user); + + Assert.Equal(primary.Id.ToString("N"), sources[0].Id); + } + + [Fact] + public void GetStaticMediaSources_NoUser_DoesNotTouchUserData() + { + var (primary, _, _) = SetupVersionGroup(); + + var sources = _mediaSourceManager.GetStaticMediaSources(primary, false); + + Assert.Equal(primary.Id.ToString("N"), sources[0].Id); + _mockUserDataManager.Verify(x => x.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<User>()), Times.Never); + } + + private void SetupUserDataBatch(Dictionary<Guid, UserItemData> userData) + { + _mockUserDataManager + .Setup(x => x.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<User>())) + .Returns((IReadOnlyList<BaseItem> items, User _) => items + .Where(i => userData.ContainsKey(i.Id)) + .ToDictionary(i => i.Id, i => userData[i.Id])); + } + + private static (Video Primary, Video Alt1, Video Alt2) SetupVersionGroup() + { + var primary = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie.mkv" }; + var alt1 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 1080p.mkv", PrimaryVersionId = primary.Id }; + var alt2 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv", PrimaryVersionId = primary.Id }; + + // BaseItem.GetMediaSources runs against the static service locators. + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File); + mediaSourceManager.Setup(x => x.GetMediaStreams(It.IsAny<Guid>())).Returns(new List<MediaStream>()); + mediaSourceManager.Setup(x => x.GetMediaAttachments(It.IsAny<Guid>())).Returns(new List<MediaAttachment>()); + + var segmentManager = new Mock<IMediaSegmentManager>(); + segmentManager.Setup(x => x.IsTypeSupported(It.IsAny<BaseItem>())).Returns(false); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>()); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(primary)).Returns(new[] { alt1.Id, alt2.Id }); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt1)).Returns(Array.Empty<Guid>()); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt2)).Returns(Array.Empty<Guid>()); + libraryManager.Setup(x => x.GetItemById(primary.Id)).Returns(primary); + libraryManager.Setup(x => x.GetItemById(alt1.Id)).Returns(alt1); + libraryManager.Setup(x => x.GetItemById(alt2.Id)).Returns(alt2); + + var recordingsManager = new Mock<IRecordingsManager>(); + recordingsManager.Setup(x => x.GetActiveRecordingInfo(It.IsAny<string>())).Returns((ActiveRecordingInfo?)null); + + BaseItem.MediaSegmentManager = segmentManager.Object; + BaseItem.MediaSourceManager = mediaSourceManager.Object; + BaseItem.LibraryManager = libraryManager.Object; + Video.RecordingsManager = recordingsManager.Object; + + return (primary, alt1, alt2); + } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs index aed584355c..e1346a8436 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs @@ -1,7 +1,13 @@ +using System.Collections.Generic; using Emby.Naming.Common; +using Emby.Naming.Video; using Emby.Server.Implementations.Library.Resolvers.Movies; +using Jellyfin.Data.Enums; using MediaBrowser.Controller; using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; @@ -14,11 +20,12 @@ namespace Jellyfin.Server.Implementations.Tests.Library; public class MovieResolverTests { private static readonly NamingOptions _namingOptions = new(); + private static readonly VideoListResolver _videoListResolver = new(_namingOptions); [Fact] public void Resolve_GivenLocalAlternateVersion_ResolvesToVideo() { - var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>()); + var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver); var itemResolveArgs = new ItemResolveArgs( Mock.Of<IServerApplicationPaths>(), null) @@ -32,4 +39,54 @@ public class MovieResolverTests Assert.NotNull(movieResolver.Resolve(itemResolveArgs)); } + + [Fact] + public void ResolveMultiple_GivenTvShowsCollection_CreatesEpisodeItems() + { + // For a tvshows collection, the multi-version grouping must still produce + // Episode BaseItems (not generic Video) so downstream metadata fetching + // and series-aware logic apply. + var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver); + + var parent = new Folder { Path = "/TV/Show/Season 1" }; + var files = new List<FileSystemMetadata> + { + new() { FullName = "/TV/Show/Season 1/Show - S01E01 - 1080p.mkv", Name = "Show - S01E01 - 1080p.mkv", IsDirectory = false }, + new() { FullName = "/TV/Show/Season 1/Show - S01E01 - 720p.mkv", Name = "Show - S01E01 - 720p.mkv", IsDirectory = false }, + new() { FullName = "/TV/Show/Season 1/Show - S01E02.mkv", Name = "Show - S01E02.mkv", IsDirectory = false } + }; + + var result = movieResolver.ResolveMultiple(parent, files, CollectionType.tvshows, Mock.Of<IDirectoryService>()); + + Assert.NotNull(result); + Assert.Equal(2, result.Items.Count); + Assert.All(result.Items, item => Assert.IsType<Episode>(item)); + + // The S01E01 item should have one alternate version + var s01e01 = result.Items.Find(i => i.Path.Contains("S01E01", System.StringComparison.Ordinal)); + Assert.NotNull(s01e01); + Assert.Single(((Video)s01e01).LocalAlternateVersions); + } + + [Fact] + public void ResolveMultiple_GivenMoviesCollection_CreatesMovieItems() + { + // For a movies collection, the multi-version grouping must produce Movie + // BaseItems (not generic Video) so downstream movie-specific logic applies. + var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver); + + var parent = new Folder { Path = "/movies/Inception (2010)" }; + var files = new List<FileSystemMetadata> + { + new() { FullName = "/movies/Inception (2010)/Inception (2010) - 1080p.mkv", Name = "Inception (2010) - 1080p.mkv", IsDirectory = false }, + new() { FullName = "/movies/Inception (2010)/Inception (2010) - 720p.mkv", Name = "Inception (2010) - 720p.mkv", IsDirectory = false } + }; + + var result = movieResolver.ResolveMultiple(parent, files, CollectionType.movies, Mock.Of<IDirectoryService>()); + + Assert.NotNull(result); + Assert.Single(result.Items); + Assert.All(result.Items, item => Assert.IsType<Movie>(item)); + Assert.Single(((Video)result.Items[0]).LocalAlternateVersions); + } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index acabaf3acb..bdb726f06d 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using BitFaster.Caching; @@ -305,6 +306,112 @@ namespace Jellyfin.Server.Implementations.Tests.Localization Assert.Equal(key, translated); } + [Fact] + public void GetLocalizedString_WithCulture_ReturnsTranslation() + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "en-US" + }); + + var translated = localizationManager.GetLocalizedString("Artists", "de"); + Assert.Equal("Interpreten", translated); + } + + [Fact] + public void GetLocalizedString_WithCulture_FallsBackToEnUs() + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "en-US" + }); + + // A culture with no translation file should fall back to en-US + var translated = localizationManager.GetLocalizedString("Artists", "zz"); + Assert.Equal("Artists", translated); + } + + [Fact] + public void GetLocalizedString_WithBcp47Normalization_ReturnsTranslation() + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "en-US" + }); + + // es-419 is stored as es_419 in Jellyfin + var translated = localizationManager.GetLocalizedString("Default", "es-419"); + Assert.NotEqual("Default", translated); + } + + [Fact] + public void GetLocalizedString_WithBcp47NormalizationToUppercaseRegion_ReturnsTranslation() + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "en-US" + }); + + // he-IL normalizes to the underscore resource he_IL. The resource lookup is case-sensitive, + // so the region casing has to be preserved or the file is not found and we fall back to en-US. + var translated = localizationManager.GetLocalizedString("Books", "he-IL"); + Assert.Equal("ספרים", translated); + } + + [Fact] + public void GetServerLocalizedString_UsesServerCulture() + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "de" + }); + + // Even if CurrentUICulture is fr, GetServerLocalizedString should use the server's "de" + var previousCulture = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("fr"); + var translated = localizationManager.GetServerLocalizedString("Artists"); + Assert.Equal("Interpreten", translated); + } + finally + { + CultureInfo.CurrentUICulture = previousCulture; + } + } + + [Fact] + public void GetLocalizedString_UsesCurrentUICulture() + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "en-US" + }); + + var previousCulture = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("de"); + var translated = localizationManager.GetLocalizedString("Artists"); + Assert.Equal("Interpreten", translated); + } + finally + { + CultureInfo.CurrentUICulture = previousCulture; + } + } + + [Fact] + public void GetSupportedUICultures_IncludesCommonCultures() + { + var supported = LocalizationManager.GetSupportedUICultures(); + Assert.Contains(supported, c => c.Name.Equals("de", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(supported, c => c.Name.Equals("en-US", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(supported, c => c.Name.Equals("fr", StringComparison.OrdinalIgnoreCase)); + // Underscore variants get normalized to BCP-47 hyphen form for CultureInfo compatibility. + Assert.Contains(supported, c => c.Name.Equals("es-419", StringComparison.OrdinalIgnoreCase)); + } + private LocalizationManager Setup(ServerConfiguration config) { var mockConfiguration = new Mock<IServerConfigurationManager>(); diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs new file mode 100644 index 0000000000..bd9e680cd9 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using Emby.Server.Implementations.SyncPlay; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.SyncPlay; + +public class GroupTests +{ + public GroupTests() + { + var mockLogger = new Mock<ILogger<Emby.Server.Implementations.SyncPlay.Group>>(); + MockLoggerFactory = new Mock<ILoggerFactory>(); + MockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny<string>())).Returns(mockLogger.Object); + + MockUserManager = new Mock<IUserManager>(); + MockSessionManager = new Mock<ISessionManager>(); + MockLibraryManager = new Mock<ILibraryManager>(); + MockItem = new Mock<BaseItem>(); + MockItem.Setup(i => i.IsVisibleStandalone(It.IsAny<User>())).Returns(true); + } + + private Mock<ILoggerFactory> MockLoggerFactory { get; } + + private Mock<IUserManager> MockUserManager { get; } + + private Mock<ISessionManager> MockSessionManager { get; } + + private Mock<ILibraryManager> MockLibraryManager { get; } + + private Mock<BaseItem> MockItem { get; } + + [Fact] + public void HasAccessToPlayQueue_ReturnsTrue_WhenItemsAreVisible() + { + MockLibraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns(MockItem.Object); + + var group = new Emby.Server.Implementations.SyncPlay.Group(MockLoggerFactory.Object, MockUserManager.Object, MockSessionManager.Object, MockLibraryManager.Object); + var itemId = Guid.NewGuid(); + var playlist = new List<Guid> { itemId }; + group.PlayQueue.Reset(); + group.PlayQueue.SetPlaylist(playlist); + + Assert.Single(group.PlayQueue.GetPlaylist()); + Assert.Equal(itemId, group.PlayQueue.GetPlaylist()[0].ItemId); + + var user = new User("test-user", "auth-provider", "pwdreset-provider"); + var result = group.HasAccessToPlayQueue(user); + + Assert.True(result); + } + + [Fact] + public void HasAccessToPlayQueue_ReturnsFalse_WhenLibraryReturnsNullForItem() + { + MockLibraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns((BaseItem?)null); + + Assert.Null(MockLibraryManager.Object.GetItemById(Guid.NewGuid())); + + var group = new Emby.Server.Implementations.SyncPlay.Group(MockLoggerFactory.Object, MockUserManager.Object, MockSessionManager.Object, MockLibraryManager.Object); + var itemId = Guid.NewGuid(); + var playlist = new List<Guid> { itemId }; + group.PlayQueue.Reset(); + group.PlayQueue.SetPlaylist(playlist); + + Assert.Single(group.PlayQueue.GetPlaylist()); + Assert.Equal(itemId, group.PlayQueue.GetPlaylist()[0].ItemId); + + var user = new User("test-user", "auth-provider", "pwdreset-provider"); + var result = group.HasAccessToPlayQueue(user); + + Assert.False(result); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs index 92e10c9f92..4a10b2f607 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs @@ -109,5 +109,29 @@ namespace Jellyfin.Server.Implementations.Tests.Updates var ex = await Record.ExceptionAsync(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None)); Assert.Null(ex); } + + [Theory] + [InlineData("../evil")] + [InlineData("..\\evil")] + [InlineData("../../escape_attempt")] + [InlineData("..")] + [InlineData(".")] + [InlineData("")] + [InlineData(" ")] + [InlineData("foo/bar")] + [InlineData("foo\\bar")] + [InlineData("/absolute")] + [InlineData("foo\0bar")] + public async Task InstallPackage_InvalidName_ThrowsInvalidDataException(string name) + { + var packageInfo = new InstallationInfo() + { + Name = name, + SourceUrl = "https://repo.jellyfin.org/releases/plugin/empty/empty.zip", + Checksum = "11b5b2f1a9ebc4f66d6ef19018543361" + }; + + await Assert.ThrowsAsync<InvalidDataException>(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None)); + } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs new file mode 100644 index 0000000000..8149938b4d --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs @@ -0,0 +1,93 @@ +using System; +using System.Threading.Tasks; +using Jellyfin.Server.Implementations.Users; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Users +{ + public class UserManagerLockHelperTests + { + [Fact] + public async Task LockAsync_WhenNested_DoesNotAcquireSecondLockAndRestoresStateOnDispose() + { + UserManager.LockHelper.IsNestedLock.Value = 0; + using var helper = new UserManager.LockHelper(); + var key = Guid.NewGuid(); + + Assert.True(helper.ShouldLock()); + + var outerHandle = await helper.LockAsync(key); + Assert.False(helper.ShouldLock()); + + var innerHandle = await helper.LockAsync(key); + Assert.False(helper.ShouldLock()); + + innerHandle.Dispose(); + Assert.False(helper.ShouldLock()); + + outerHandle.Dispose(); + Assert.True(helper.ShouldLock()); + } + + [Fact] + public async Task LockAsync_WithSameKey_BlocksSecondLockUntilFirstIsReleased() + { + UserManager.LockHelper.IsNestedLock.Value = 0; + using var helper = new UserManager.LockHelper(); + var key = Guid.NewGuid(); + + var firstAcquired = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseFirst = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously); + var secondEntered = false; + + var firstTask = Task.Run( + async () => + { + using var firstHandle = await helper.LockAsync(key); + firstAcquired.SetResult(true); + await releaseFirst.Task; + }, + TestContext.Current.CancellationToken); + + await firstAcquired.Task; + + var secondTask = Task.Run( + async () => + { + using var secondHandle = await helper.LockAsync(key); + secondEntered = true; + }, + TestContext.Current.CancellationToken); + + await Task.Delay(100, TestContext.Current.CancellationToken); + Assert.False(secondEntered); + + releaseFirst.SetResult(true); + + await Task.WhenAll(firstTask, secondTask); + Assert.True(secondEntered); + } + + [Fact] + public async Task LockAsync_WhenDisposed_ThrowsObjectDisposedException() + { + UserManager.LockHelper.IsNestedLock.Value = 0; + using var helper = new UserManager.LockHelper(); + helper.Dispose(); + + await Assert.ThrowsAsync<ObjectDisposedException>(async () => await helper.LockAsync(Guid.NewGuid())); + } + + [Fact] + public void Dispose_WhenCalledMultipleTimes_DoesNotThrow() + { + UserManager.LockHelper.IsNestedLock.Value = 0; + using var helper = new UserManager.LockHelper(); + + helper.Dispose(); + var ex = Record.Exception(() => helper.Dispose()); + + Assert.Null(ex); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerNormalizedUsernameTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerNormalizedUsernameTests.cs new file mode 100644 index 0000000000..596bf58fb1 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerNormalizedUsernameTests.cs @@ -0,0 +1,240 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Users; +using MediaBrowser.Common; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Events; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Cryptography; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Users +{ + public sealed class UserManagerNormalizedUsernameTests : IDisposable + { + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly UserManager _userManager; + + public UserManagerNormalizedUsernameTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + // Create the schema + using var ctx = CreateDbContext(); + ctx.Database.EnsureCreated(); + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())) + .ReturnsAsync(CreateDbContext); + + var cryptoProvider = new Mock<ICryptoProvider>(); + var configManager = new Mock<IServerConfigurationManager>(); + var appPaths = new Mock<IServerApplicationPaths>(); + appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath()); + configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object); + + var appHost = new Mock<IApplicationHost>(); + + var defaultAuthProvider = new DefaultAuthenticationProvider( + NullLogger<DefaultAuthenticationProvider>.Instance, + cryptoProvider.Object); + var invalidAuthProvider = new InvalidAuthProvider(); + var defaultPasswordResetProvider = new DefaultPasswordResetProvider( + configManager.Object, + appHost.Object); + + _userManager = new UserManager( + factory.Object, + new NoopEventManager(), + new Mock<INetworkManager>().Object, + appHost.Object, + new Mock<IImageProcessor>().Object, + NullLogger<UserManager>.Instance, + configManager.Object, + new IPasswordResetProvider[] { defaultPasswordResetProvider }, + new IAuthenticationProvider[] { defaultAuthProvider, invalidAuthProvider }); + } + + public void Dispose() + { + _userManager.Dispose(); + _connection.Dispose(); + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } + + // ----- GetUserByName tests ----- + + [Theory] + // German umlauts + [InlineData("münchen", "MÜNCHEN")] + // Spanish tilde-n + [InlineData("Ñoño", "ÑOÑO")] + // ASCII, invariant uppercase lookup + [InlineData("jellyfin", "JELLYFIN")] + // Turkish cedilla: invariant 'i' uppercases to 'I' (U+0049), not Turkish 'İ' (U+0130) + [InlineData("Çelebi", "ÇELEBI")] + public async Task GetUserByName_WithNonAsciiUsername_FindsUserByNormalizedName( + string username, string normalizedLookup) + { + await _userManager.CreateUserAsync(username); + + var found = _userManager.GetUserByName(normalizedLookup); + + Assert.NotNull(found); + Assert.Equal(username, found.Username); + } + + [Theory] + // German umlaut, look up by both upper and lower case + [InlineData("münchen")] + // Spanish tilde-n + [InlineData("Ñoño")] + // lowercase 'i' — invariant ToUpperInvariant gives 'I', not Turkish 'İ' + [InlineData("ali")] + // mixed ASCII + umlaut + [InlineData("testüser")] + public async Task GetUserByName_WithVariousCase_FindsUserCaseInsensitively(string username) + { + await _userManager.CreateUserAsync(username); + + var upperFound = _userManager.GetUserByName(username.ToUpperInvariant()); + var lowerFound = _userManager.GetUserByName(username.ToLowerInvariant()); + var exactFound = _userManager.GetUserByName(username); + + Assert.NotNull(upperFound); + Assert.NotNull(lowerFound); + Assert.NotNull(exactFound); + } + + [Theory] + [InlineData("nonexistent")] + // No user with NormalizedUsername = "MÜNCHEN" has been created + [InlineData("MÜNCHEN")] + public void GetUserByName_WhenUserDoesNotExist_ReturnsNull(string lookupName) + { + var result = _userManager.GetUserByName(lookupName); + + Assert.Null(result); + } + + // ----- CreateUserAsync duplicate detection tests ----- + + [Theory] + // German umlaut, case-swapped duplicate + [InlineData("münchen", "MÜNCHEN")] + // Spanish tilde-n, lowercase duplicate + [InlineData("Ñoño", "ñoño")] + // ASCII, uppercase duplicate + [InlineData("alice", "ALICE")] + // Turkish cedilla: "çelebi".ToUpperInvariant() == "ÇELEBI" == "ÇELEBI".ToUpperInvariant() + [InlineData("çelebi", "ÇELEBI")] + public async Task CreateUserAsync_WhenNormalizedNameAlreadyExists_ThrowsArgumentException( + string existingUsername, string duplicateUsername) + { + await _userManager.CreateUserAsync(existingUsername); + + await Assert.ThrowsAsync<ArgumentException>( + () => _userManager.CreateUserAsync(duplicateUsername)); + } + + [Theory] + // Different non-ASCII names that do not collide after normalization + [InlineData("münchen", "münchen2")] + [InlineData("ali", "ali2")] + // Visually similar but different Unicode code points: ñ (U+00F1) vs n (U+006E) + [InlineData("noño", "nono")] + public async Task CreateUserAsync_WithDistinctNonAsciiUsernames_CreatesBothUsers( + string firstUsername, string secondUsername) + { + var first = await _userManager.CreateUserAsync(firstUsername); + var second = await _userManager.CreateUserAsync(secondUsername); + + Assert.NotNull(first); + Assert.NotNull(second); + Assert.NotEqual(first.Id, second.Id); + } + + // ----- RenameUser tests ----- + + [Theory] + // Rename to non-ASCII name + [InlineData("alice", "münchen")] + // Rename between similar non-ASCII and ASCII + [InlineData("müller", "mueller")] + // Contains 'i': invariant uppercase is always 'I', never Turkish 'İ' + [InlineData("ali", "ALI2")] + // Rename to Spanish tilde-n name + [InlineData("testuser", "Ñoño")] + public async Task RenameUser_SetsNormalizedUsernameToUpperInvariant( + string originalName, string newName) + { + var user = await _userManager.CreateUserAsync(originalName); + + await _userManager.RenameUser(user.Id, originalName, newName); + + var renamed = _userManager.GetUserById(user.Id); + Assert.NotNull(renamed); + Assert.Equal(newName, renamed.Username); + Assert.Equal(newName.ToUpperInvariant(), renamed.NormalizedUsername); + } + + [Theory] + // Same name different case: NormalizedUsername already taken + [InlineData("münchen", "MÜNCHEN")] + // Spanish, lowercase conflicts with existing uppercase-normalised entry + [InlineData("Ñoño", "ñoño")] + // ASCII, capitalised conflict + [InlineData("alice", "Alice")] + // Mixed ASCII + umlaut + [InlineData("testüser", "TESTÜSER")] + public async Task RenameUser_WhenNormalizedNameConflictsWithExistingUser_ThrowsArgumentException( + string existingUsername, string conflictingNewName) + { + var targetUser = await _userManager.CreateUserAsync("renametarget"); + await _userManager.CreateUserAsync(existingUsername); + + await Assert.ThrowsAsync<ArgumentException>( + () => _userManager.RenameUser(targetUser.Id, "renametarget", conflictingNewName)); + } + + private sealed class NoopEventManager : IEventManager + { + public void Publish<T>(T eventArgs) + where T : EventArgs + { + } + + public Task PublishAsync<T>(T eventArgs) + where T : EventArgs + => Task.CompletedTask; + } + } +} |
