aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs258
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Recordings/RecordingsMetadataManagerTests.cs64
-rw-r--r--tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs54
-rw-r--r--tests/Jellyfin.Networking.Tests/NetworkParseTests.cs59
-rw-r--r--tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs2
-rw-r--r--tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs2
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs18
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerNormalizedUsernameTests.cs240
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;
+ }
+ }
+}