diff options
Diffstat (limited to 'tests')
8 files changed, 686 insertions, 11 deletions
diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs new file mode 100644 index 0000000000..d7ae6a8a18 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.IO; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Streaming; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using Moq; +using Xunit; + +using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration; + +namespace Jellyfin.Controller.Tests.MediaEncoding; + +public class EncodingHelperTests +{ + [Fact] + public void GetMapArgs_NoSubtitle_ExcludesAllSubs() + { + var state = BuildState(subtitle: null, deliveryMethod: null); + var args = CreateHelper().GetMapArgs(state); + + Assert.Contains("-map -0:s", args, StringComparison.Ordinal); + Assert.DoesNotContain("-map 1:", args, StringComparison.Ordinal); + } + + [Fact] + public void GetMapArgs_InternalSrt_MapsFromPrimaryInput() + { + var sub = new MediaStream { Index = 2, Type = MediaStreamType.Subtitle, Codec = "srt" }; + var state = BuildState(sub, SubtitleDeliveryMethod.Embed); + var args = CreateHelper().GetMapArgs(state); + + Assert.Contains("-map 0:2", args, StringComparison.Ordinal); + Assert.DoesNotContain("-map 1:", args, StringComparison.Ordinal); + } + + [Fact] + public void GetMapArgs_InternalSubAtHigherIndex_MapsCorrectIndex() + { + var sub0 = new MediaStream { Index = 2, Type = MediaStreamType.Subtitle, Codec = "srt" }; + var sub1 = new MediaStream { Index = 3, Type = MediaStreamType.Subtitle, Codec = "ass" }; + var state = BuildState(sub1, SubtitleDeliveryMethod.Embed, additionalStreams: [sub0, sub1]); + var args = CreateHelper().GetMapArgs(state); + + Assert.Contains("-map 0:3", args, StringComparison.Ordinal); + } + + [Fact] + public void GetMapArgs_ExternalSrt_MapsFirstStreamFromInput1() + { + var sub = new MediaStream + { + Index = 2, + Type = MediaStreamType.Subtitle, + Codec = "srt", + IsExternal = true, + SupportsExternalStream = true, + Path = "/media/movie.en.srt" + }; + var state = BuildState(sub, SubtitleDeliveryMethod.Embed); + var args = CreateHelper().GetMapArgs(state); + + Assert.Contains("-map 1:0", args, StringComparison.Ordinal); + } + + [Fact] + public void GetMapArgs_SecondExternalSrt_StillMaps1Colon0() + { + // Two separate .srt files — selecting the second one still maps 1:0 + // because Jellyfin feeds only the selected file as ffmpeg input 1. + var ext1 = new MediaStream + { + Index = 2, + Type = MediaStreamType.Subtitle, + Codec = "srt", + IsExternal = true, + SupportsExternalStream = true, + Path = "/media/movie.en.srt" + }; + var ext2 = new MediaStream + { + Index = 3, + Type = MediaStreamType.Subtitle, + Codec = "srt", + IsExternal = true, + SupportsExternalStream = true, + Path = "/media/movie.fr.srt" + }; + var state = BuildState(ext2, SubtitleDeliveryMethod.Embed, additionalStreams: [ext1, ext2]); + var args = CreateHelper().GetMapArgs(state); + + Assert.Contains("-map 1:0", args, StringComparison.Ordinal); + } + + [Fact] + public void GetMapArgs_MksFirstTrack_MapsInFileIndex0() + { + var mks0 = new MediaStream + { + Index = 2, + Type = MediaStreamType.Subtitle, + Codec = "subrip", + IsExternal = true, + SupportsExternalStream = true, + Path = "/media/movie.mks" + }; + var mks1 = new MediaStream + { + Index = 3, + Type = MediaStreamType.Subtitle, + Codec = "ass", + IsExternal = true, + SupportsExternalStream = true, + Path = "/media/movie.mks" + }; + var state = BuildState(mks0, SubtitleDeliveryMethod.Embed, additionalStreams: [mks0, mks1]); + var args = CreateHelper().GetMapArgs(state); + + Assert.Contains("-map 1:0", args, StringComparison.Ordinal); + } + + [Fact] + public void GetMapArgs_MksSecondTrack_MapsInFileIndex1() + { + var mks0 = new MediaStream + { + Index = 2, + Type = MediaStreamType.Subtitle, + Codec = "subrip", + IsExternal = true, + SupportsExternalStream = true, + Path = "/media/movie.mks" + }; + var mks1 = new MediaStream + { + Index = 3, + Type = MediaStreamType.Subtitle, + Codec = "ass", + IsExternal = true, + SupportsExternalStream = true, + Path = "/media/movie.mks" + }; + var mks2 = new MediaStream + { + Index = 4, + Type = MediaStreamType.Subtitle, + Codec = "subrip", + IsExternal = true, + SupportsExternalStream = true, + Path = "/media/movie.mks" + }; + var state = BuildState(mks1, SubtitleDeliveryMethod.Embed, additionalStreams: [mks0, mks1, mks2]); + var args = CreateHelper().GetMapArgs(state); + + Assert.Contains("-map 1:1", args, StringComparison.Ordinal); + } + + [Theory] + [InlineData(SubtitleDeliveryMethod.Embed, true, "movie.idx")] + [InlineData(SubtitleDeliveryMethod.Encode, true, "movie.idx")] + [InlineData(SubtitleDeliveryMethod.Embed, false, "movie.sub")] + [InlineData(SubtitleDeliveryMethod.Encode, false, "movie.sub")] + public void GetInputArgument_VobSub_UsesCorrectPath( + SubtitleDeliveryMethod deliveryMethod, + bool createIdxFile, + string expectedFilename) + { + var tempDir = Directory.CreateTempSubdirectory("jellyfin-test-"); + try + { + var subFile = Path.Combine(tempDir.FullName, "movie.sub"); + File.WriteAllText(subFile, "dummy"); + + if (createIdxFile) + { + File.WriteAllText(Path.Combine(tempDir.FullName, "movie.idx"), "dummy"); + } + + var sub = new MediaStream + { + Index = 2, + Type = MediaStreamType.Subtitle, + Codec = "dvdsub", + IsExternal = true, + SupportsExternalStream = true, + Path = subFile + }; + var state = BuildState(sub, deliveryMethod); + var inputArgs = CreateHelper().GetInputArgument(state, new EncodingOptions(), null); + + Assert.Contains(expectedFilename, inputArgs, StringComparison.Ordinal); + } + finally + { + tempDir.Delete(true); + } + } + + private static EncodingJobInfo BuildState( + MediaStream? subtitle, + SubtitleDeliveryMethod? deliveryMethod, + MediaStream[]? additionalStreams = null) + { + var video = new MediaStream { Index = 0, Type = MediaStreamType.Video, Codec = "h264" }; + var audio = new MediaStream { Index = 1, Type = MediaStreamType.Audio, Codec = "aac" }; + var streams = new List<MediaStream> { video, audio }; + + if (additionalStreams is not null) + { + streams.AddRange(additionalStreams); + } + else if (subtitle is not null) + { + streams.Add(subtitle); + } + + return new EncodingJobInfo(TranscodingJobType.Progressive) + { + MediaSource = new MediaSourceInfo + { + Container = "mkv", + MediaStreams = streams, + }, + VideoStream = video, + AudioStream = audio, + SubtitleStream = subtitle, + SubtitleDeliveryMethod = deliveryMethod ?? SubtitleDeliveryMethod.Drop, + BaseRequest = new VideoRequestDto(), + IsVideoRequest = true, + IsInputVideo = true, + }; + } + + private static EncodingHelper CreateHelper() + { + var appPaths = Mock.Of<IApplicationPaths>(); + var mediaEncoder = new Mock<IMediaEncoder>(); + var subtitleEncoder = new Mock<ISubtitleEncoder>(); + var config = new Mock<IConfiguration>(); + var configurationManager = new Mock<IConfigurationManager>(); + var pathManager = new Mock<IPathManager>(); + + return new EncodingHelper( + appPaths, + mediaEncoder.Object, + subtitleEncoder.Object, + config.Object, + configurationManager.Object, + pathManager.Object); + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/Recordings/RecordingsMetadataManagerTests.cs b/tests/Jellyfin.LiveTv.Tests/Recordings/RecordingsMetadataManagerTests.cs new file mode 100644 index 0000000000..14ce470fb4 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Recordings/RecordingsMetadataManagerTests.cs @@ -0,0 +1,64 @@ +using System; +using System.Globalization; +using System.IO; +using System.Threading.Tasks; +using System.Xml; +using Jellyfin.Extensions; +using Jellyfin.LiveTv.Recordings; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.LiveTv.Tests.Recordings; + +public sealed class RecordingsMetadataManagerTests +{ + private readonly string _tempDir = + Path.Combine(Path.GetTempPath(), "jellyfin-test-" + Guid.NewGuid()); + + [Fact] + public async Task SaveRecordingMetadata_DateAddedIsUtc() + { + Directory.CreateDirectory(_tempDir); + var recordingPath = Path.Combine(_tempDir, "test-recording.ts"); + FileHelper.CreateEmpty(recordingPath); + + var config = new Mock<IConfigurationManager>(); + config.Setup(c => c.GetConfiguration("livetv")) + .Returns(new LiveTvOptions { SaveRecordingNFO = true, SaveRecordingImages = false }); + config.Setup(c => c.GetConfiguration("xbmcmetadata")) + .Returns(new XbmcMetadataOptions()); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager + .Setup(l => l.GetItemList(It.IsAny<InternalItemsQuery>())) + .Returns(Array.Empty<BaseItem>()); + + var manager = new RecordingsMetadataManager( + NullLogger<RecordingsMetadataManager>.Instance, + config.Object, + libraryManager.Object); + + var timer = new TimerInfo { Name = "Test Recording", ProgramId = null }; + + var beforeUtc = DateTime.UtcNow.AddSeconds(-2); + await manager.SaveRecordingMetadata(timer, recordingPath, null); + var afterUtc = DateTime.UtcNow.AddSeconds(2); + + var doc = new XmlDocument(); + doc.Load(Path.ChangeExtension(recordingPath, ".nfo")); + var dateAddedText = doc.SelectSingleNode("//dateadded")?.InnerText ?? string.Empty; + var parsed = DateTime.ParseExact( + dateAddedText, + "yyyy-MM-dd HH:mm:ss", + CultureInfo.InvariantCulture); + + Assert.InRange(parsed, beforeUtc, afterUtc); + } +} diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs index 0b103debad..16c586bcda 100644 --- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs @@ -675,5 +675,59 @@ namespace Jellyfin.Model.Tests Assert.Equal(expectedMethod, result.Method); } + + [Theory] + // External text subs embedded into MKV when transcoding (#16403) + [InlineData("srt", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)] + [InlineData("ass", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)] + // External graphical subs embedded into MKV when transcoding + [InlineData("pgssub", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)] + [InlineData("dvdsub", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)] + // External subs remain external when transcoding to non-MKV containers + [InlineData("srt", true, PlayMethod.Transcode, "mp4", MediaStreamProtocol.hls, SubtitleDeliveryMethod.External)] + [InlineData("srt", true, PlayMethod.Transcode, "ts", MediaStreamProtocol.hls, SubtitleDeliveryMethod.External)] + // External subs remain external during DirectPlay even with MKV + [InlineData("srt", true, PlayMethod.DirectPlay, "mkv", null, SubtitleDeliveryMethod.External)] + // Internal subs still embedded into MKV when transcoding (existing behavior) + [InlineData("srt", false, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)] + [InlineData("pgssub", false, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)] + public void GetSubtitleProfile_ReturnsExpectedDeliveryMethod( + string codec, + bool isExternal, + PlayMethod playMethod, + string outputContainer, + MediaStreamProtocol? transcodingSubProtocol, + SubtitleDeliveryMethod expectedMethod) + { + var mediaSource = new MediaSourceInfo(); + var subtitleStream = new MediaStream + { + Codec = codec, + Language = "eng", + IsExternal = isExternal, + Type = MediaStreamType.Subtitle, + SupportsExternalStream = true + }; + + var subtitleProfiles = new[] + { + new SubtitleProfile { Format = codec, Method = SubtitleDeliveryMethod.Embed }, + new SubtitleProfile { Format = codec, Method = SubtitleDeliveryMethod.External } + }; + + var transcoderSupport = new Mock<ITranscoderSupport>(); + transcoderSupport.Setup(x => x.CanExtractSubtitles(It.IsAny<string>())).Returns(true); + + var result = StreamBuilder.GetSubtitleProfile( + mediaSource, + subtitleStream, + subtitleProfiles, + playMethod, + transcoderSupport.Object, + outputContainer, + transcodingSubProtocol); + + Assert.Equal(expectedMethod, result.Method); + } } } diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs index 66eec077dc..1f523f7f21 100644 --- a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs +++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs @@ -136,6 +136,65 @@ namespace Jellyfin.Networking.Tests } /// <summary> + /// Verifies that IPv4 entries whose '!' polarity doesn't match the requested pass are skipped silently, + /// not logged as invalid. Callers parse the same list twice (LAN and excluded) so the off-polarity + /// entries are expected, not erroneous. + /// </summary> + [Fact] + public static void TryParseToSubnets_PolarityMismatchIPv4_DoesNotWarn() + { + var logger = new Mock<ILogger>(); + var values = new[] { "127.0.0.0/8", "192.168.178.0/24", "!10.0.0.0/8" }; + + // Non-negated pass picks up the two non-'!' entries and ignores '!10.0.0.0/8' silently. + Assert.True(NetworkUtils.TryParseToSubnets(values, out var lanResult, false, logger.Object)); + Assert.NotNull(lanResult); + Assert.Equal(2, lanResult.Count); + + // Negated pass picks up the single '!' entry and ignores the others silently. + Assert.True(NetworkUtils.TryParseToSubnets(values, out var excludedResult, true, logger.Object)); + Assert.NotNull(excludedResult); + Assert.Single(excludedResult); + + logger.Verify( + l => l.Log( + LogLevel.Warning, + It.IsAny<EventId>(), + It.IsAny<It.IsAnyType>(), + It.IsAny<Exception>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + Times.Never); + } + + /// <summary> + /// Same as the IPv4 case but for IPv6 entries — makes sure the polarity pre-check works + /// for IPv6 CIDR notation (with '::') as well. + /// </summary> + [Fact] + public static void TryParseToSubnets_PolarityMismatchIPv6_DoesNotWarn() + { + var logger = new Mock<ILogger>(); + var values = new[] { "fd00::/8", "fe80::/10", "!fd12:3456:789a::/48" }; + + Assert.True(NetworkUtils.TryParseToSubnets(values, out var lanResult, false, logger.Object)); + Assert.NotNull(lanResult); + Assert.Equal(2, lanResult.Count); + + Assert.True(NetworkUtils.TryParseToSubnets(values, out var excludedResult, true, logger.Object)); + Assert.NotNull(excludedResult); + Assert.Single(excludedResult); + + logger.Verify( + l => l.Log( + LogLevel.Warning, + It.IsAny<EventId>(), + It.IsAny<It.IsAnyType>(), + It.IsAny<Exception>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + Times.Never); + } + + /// <summary> /// Checks if IPv4 address is within a defined subnet. /// </summary> /// <param name="netMask">Network mask.</param> diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs index 99604e0933..aaa500b762 100644 --- a/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs @@ -1,7 +1,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Model.Entities; -using MediaBrowser.Providers.Plugins.ComicVine; +using MediaBrowser.Providers.Books.ComicVine; using Xunit; namespace Jellyfin.Providers.Tests.ExternalId diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs index eec64ac53f..b9ce895dbc 100644 --- a/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs @@ -1,7 +1,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Model.Entities; -using MediaBrowser.Providers.Plugins.GoogleBooks; +using MediaBrowser.Providers.Books.GoogleBooks; using Xunit; namespace Jellyfin.Providers.Tests.ExternalId diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs index a7491f42e9..2438ef06d1 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs @@ -37,9 +37,9 @@ public class FFProbeVideoInfoTests { Assert.Throws<ArgumentException>( () => _fFProbeVideoInfo.CreateDummyChapters(new Video() - { - RunTimeTicks = runtime - })); + { + RunTimeTicks = runtime + })); } [Theory] @@ -53,9 +53,9 @@ public class FFProbeVideoInfoTests public void CreateDummyChapters_ValidRuntime_CorrectChaptersCount(long? runtime, int chaptersCount) { var chapters = _fFProbeVideoInfo.CreateDummyChapters(new Video() - { - RunTimeTicks = runtime - }); + { + RunTimeTicks = runtime + }); Assert.Equal(chaptersCount, chapters.Length); } @@ -69,9 +69,9 @@ public class FFProbeVideoInfoTests public void CreateDummyChapters_PositiveRuntime_NoChapterBeyondRuntime(long runtime) { var chapters = _fFProbeVideoInfo.CreateDummyChapters(new Video() - { - RunTimeTicks = runtime - }); + { + RunTimeTicks = runtime + }); Assert.All(chapters, chapter => Assert.True(chapter.StartPositionTicks < runtime)); } 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; + } + } +} |
