diff --git a/jellyfin-multilang.sln b/jellyfin-multilang.sln index b472427..e7a9ac5 100644 --- a/jellyfin-multilang.sln +++ b/jellyfin-multilang.sln @@ -7,6 +7,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{D1AD3B53-612 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Multilang", "src\Jellyfin.Plugin.Multilang\Jellyfin.Plugin.Multilang.csproj", "{138DB6FA-E217-41B3-A9D1-F24FB724DD44}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{CFC94729-5950-416B-AAA2-640C80D578C7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Multilang.Tests", "tests\Jellyfin.Plugin.Multilang.Tests\Jellyfin.Plugin.Multilang.Tests.csproj", "{C58F148E-F158-4D78-B3C1-2B3317B24A12}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -20,8 +24,13 @@ Global {138DB6FA-E217-41B3-A9D1-F24FB724DD44}.Debug|Any CPU.Build.0 = Debug|Any CPU {138DB6FA-E217-41B3-A9D1-F24FB724DD44}.Release|Any CPU.ActiveCfg = Release|Any CPU {138DB6FA-E217-41B3-A9D1-F24FB724DD44}.Release|Any CPU.Build.0 = Release|Any CPU + {C58F148E-F158-4D78-B3C1-2B3317B24A12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C58F148E-F158-4D78-B3C1-2B3317B24A12}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C58F148E-F158-4D78-B3C1-2B3317B24A12}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C58F148E-F158-4D78-B3C1-2B3317B24A12}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {138DB6FA-E217-41B3-A9D1-F24FB724DD44} = {D1AD3B53-6123-4F98-87D5-517B5BF6ADAF} + {C58F148E-F158-4D78-B3C1-2B3317B24A12} = {CFC94729-5950-416B-AAA2-640C80D578C7} EndGlobalSection EndGlobal diff --git a/tests/Jellyfin.Plugin.Multilang.Tests/CategoryRuleEvaluatorTests.cs b/tests/Jellyfin.Plugin.Multilang.Tests/CategoryRuleEvaluatorTests.cs new file mode 100644 index 0000000..7032db7 --- /dev/null +++ b/tests/Jellyfin.Plugin.Multilang.Tests/CategoryRuleEvaluatorTests.cs @@ -0,0 +1,127 @@ +using System.Text.Json; +using Jellyfin.Plugin.Multilang.Data; +using Jellyfin.Plugin.Multilang.Rules; + +namespace Jellyfin.Plugin.Multilang.Tests; + +public sealed class CategoryRuleEvaluatorTests +{ + [Fact] + public void IsWithAndListRequiresExactSet() + { + var category = Rule("production_countries", "is", useOr: false, "SE", "FI"); + + Assert.True(CategoryRuleEvaluator.Matches(category, Facts(productionCountries: ["FI", "SE"]))); + Assert.False(CategoryRuleEvaluator.Matches(category, Facts(productionCountries: ["FI"]))); + Assert.False(CategoryRuleEvaluator.Matches(category, Facts(productionCountries: ["FI", "SE", "NO"]))); + } + + [Fact] + public void IsWithOrListRequiresSingleMatchingValue() + { + var category = Rule("original_language", "is", useOr: true, "sv", "fi"); + + Assert.True(CategoryRuleEvaluator.Matches(category, Facts(originalLanguage: "sv"))); + Assert.False(CategoryRuleEvaluator.Matches(category, Facts(originalLanguage: "en"))); + Assert.False(CategoryRuleEvaluator.Matches(category, Facts(originalLanguage: "", spokenLanguages: ["sv", "fi"]))); + } + + [Fact] + public void ContainsWithAndListRequiresEveryExpectedValue() + { + var category = Rule("spoken_languages", "contains", useOr: false, "sv", "fi"); + + Assert.True(CategoryRuleEvaluator.Matches(category, Facts(spokenLanguages: ["en", "sv", "fi"]))); + Assert.False(CategoryRuleEvaluator.Matches(category, Facts(spokenLanguages: ["en", "sv"]))); + } + + [Fact] + public void ContainsWithOrListRequiresAnyExpectedValue() + { + var category = Rule("origin_countries", "contains", useOr: true, "SE", "FI"); + + Assert.True(CategoryRuleEvaluator.Matches(category, Facts(originCountries: ["NO", "FI"]))); + Assert.False(CategoryRuleEvaluator.Matches(category, Facts(originCountries: ["NO", "DK"]))); + } + + [Fact] + public void MultipleConditionsRespectCategoryMatchMode() + { + var all = new UserCategoryRule + { + Scopes = ["M"], + MatchAllConditions = true, + Requirements = + [ + Requirement("original_language", "is", false, "sv"), + Requirement("production_countries", "contains", true, "SE") + ] + }; + var any = CopyWithMatchAll(all, false); + var swedishLanguageOnly = Facts(originalLanguage: "sv", productionCountries: ["FI"]); + + Assert.False(CategoryRuleEvaluator.Matches(all, swedishLanguageOnly)); + Assert.True(CategoryRuleEvaluator.Matches(any, swedishLanguageOnly)); + } + + [Fact] + public void ScopeMustMatchItemKind() + { + var movieOnly = Rule("original_language", "is", useOr: false, "sv"); + movieOnly.Scopes = ["M"]; + + Assert.True(CategoryRuleEvaluator.Matches(movieOnly, Facts(kind: "movie", originalLanguage: "sv"))); + Assert.False(CategoryRuleEvaluator.Matches(movieOnly, Facts(kind: "tv", originalLanguage: "sv"))); + } + + private static UserCategoryRule Rule(string field, string relation, bool useOr, params string[] values) + => new() + { + Scopes = ["M", "S", "C"], + Requirements = [Requirement(field, relation, useOr, values)] + }; + + private static UserCategoryRequirement Requirement(string field, string relation, bool useOr, params string[] values) + => new() + { + Fields = [field], + Relation = relation, + UseOr = useOr, + Values = values + }; + + private static UserCategoryRule CopyWithMatchAll(UserCategoryRule rule, bool matchAll) + => new() + { + Id = rule.Id, + Label = rule.Label, + CriteriaText = rule.CriteriaText, + Requirements = rule.Requirements, + MatchAllConditions = matchAll, + Scopes = rule.Scopes, + FieldActions = rule.FieldActions, + FieldActionLists = rule.FieldActionLists + }; + + private static FactsData Facts( + string kind = "movie", + string originalLanguage = "", + string[]? originCountries = null, + string[]? productionCountries = null, + string[]? spokenLanguages = null, + string audioLanguage = "") + => new( + "item", + "tmdb", + kind, + "Original title", + originalLanguage, + originalLanguage, + JsonSerializer.Serialize(originCountries ?? []), + JsonSerializer.Serialize(productionCountries ?? []), + JsonSerializer.Serialize(spokenLanguages ?? []), + audioLanguage, + "[]", + 0, + 0); +} diff --git a/tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyRequestBuilderTests.cs b/tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyRequestBuilderTests.cs new file mode 100644 index 0000000..ae2d7e7 --- /dev/null +++ b/tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyRequestBuilderTests.cs @@ -0,0 +1,119 @@ +using Jellyfin.Plugin.Multilang.Services; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Plugin.Multilang.Tests; + +public sealed class ItemsProxyRequestBuilderTests +{ + private const string UserId = "11111111111111111111111111111111"; + private const string OtherUserId = "22222222222222222222222222222222"; + + [Fact] + public void BuildNormalizesRootItemsPathToCurrentUser() + { + var request = HttpRequest(); + + var result = ItemsProxyRequestBuilder.Build( + request, + "/Items?Recursive=true&api_key=old", + "token", + UserId, + multilangEnabled: true); + + Assert.NotNull(result); + Assert.Equal($"/Users/{UserId}/Items", result.Upstream.AbsolutePath); + Assert.Contains("api_key=token", result.Upstream.Query); + Assert.DoesNotContain("api_key=old", result.Upstream.Query); + Assert.DoesNotContain("api_key", result.NormalizedUrlForCache, StringComparison.OrdinalIgnoreCase); + Assert.Contains("userId=" + UserId, result.NormalizedUrlForCache); + } + + [Fact] + public void BuildReplacesForeignUserIdInUpstreamPath() + { + var request = HttpRequest(); + + var result = ItemsProxyRequestBuilder.Build( + request, + $"/Users/{OtherUserId}/Items?Limit=5", + "token", + UserId, + multilangEnabled: true); + + Assert.NotNull(result); + Assert.Equal($"/Users/{UserId}/Items", result.Upstream.AbsolutePath); + } + + [Fact] + public void BuildKeepsLocalListControlsOutOfUpstreamWhenEnabled() + { + var request = HttpRequest("?mlLocale=fi-FI"); + + var result = ItemsProxyRequestBuilder.Build( + request, + "/Items?SortBy=SortName&SortOrder=Descending&StartIndex=20&Limit=10&NameStartsWith=L", + "token", + UserId, + multilangEnabled: true); + + Assert.NotNull(result); + Assert.Equal("SortName", result.Controls.SortBy); + Assert.Equal("Descending", result.Controls.SortOrder); + Assert.Equal(20, result.Controls.StartIndex); + Assert.Equal(10, result.Controls.Limit); + Assert.Equal("L", result.Controls.NameStartsWith); + Assert.Equal("fi-FI", result.Controls.ClientLocale); + Assert.DoesNotContain("SortBy=", result.Upstream.Query); + Assert.DoesNotContain("Limit=", result.Upstream.Query); + Assert.Contains("|SortName|Descending|L|20|10|fi-FI|", result.CacheKey); + } + + [Fact] + public void BuildLeavesSortAndPagingUpstreamWhenMultilangDisabled() + { + var request = HttpRequest(); + + var result = ItemsProxyRequestBuilder.Build( + request, + "/Items?SortBy=SortName&Limit=10", + "token", + UserId, + multilangEnabled: false); + + Assert.NotNull(result); + Assert.Equal(string.Empty, result.Controls.SortBy); + Assert.Equal(0, result.Controls.Limit); + Assert.Contains("SortBy=SortName", result.Upstream.Query); + Assert.Contains("Limit=10", result.Upstream.Query); + } + + [Fact] + public void BuildRejectsAbsoluteUrlForAnotherHost() + { + var request = HttpRequest(); + + var result = ItemsProxyRequestBuilder.Build( + request, + "http://other-host:8096/Items", + "token", + UserId, + multilangEnabled: true); + + Assert.Null(result); + } + + [Fact] + public void ParseLocalGenreIdsOnlyReturnsTmdbIds() + { + Assert.Equal([1, 2], ItemsProxyRequestBuilder.ParseLocalGenreIds("tmdb-1,abc,2,tmdb-2,tmdb-x,1")); + } + + private static HttpRequest HttpRequest(string query = "") + { + var context = new DefaultHttpContext(); + context.Request.Scheme = "http"; + context.Request.Host = new HostString("server.local", 8096); + context.Request.QueryString = new QueryString(query); + return context.Request; + } +} diff --git a/tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyTransformerResolutionTests.cs b/tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyTransformerResolutionTests.cs new file mode 100644 index 0000000..84da9e4 --- /dev/null +++ b/tests/Jellyfin.Plugin.Multilang.Tests/ItemsProxyTransformerResolutionTests.cs @@ -0,0 +1,163 @@ +using Jellyfin.Plugin.Multilang.Data; +using Jellyfin.Plugin.Multilang.Services; + +namespace Jellyfin.Plugin.Multilang.Tests; + +public sealed class ItemsProxyTransformerResolutionTests +{ + private const string TitleField = "title"; + private const string OverviewField = "overview"; + private const string TaglineField = "tagline"; + private const string PosterKind = "poster"; + private const string LogoKind = "logo"; + private const string OriginalAction = "Original"; + private const string LanguagePrefix = "Language:"; + + [Fact] + public void ResolveFieldUsesFirstLanguageWithData() + { + var translations = TextTranslations( + ("sv", OverviewField, ""), + ("fi", OverviewField, "Finnish overview"), + ("en", OverviewField, "English overview")); + + var result = ItemsProxyTransformer.ResolveField( + OverviewField, + [LanguagePrefix + "sv", LanguagePrefix + "fi", LanguagePrefix + "en"], + Facts(), + translations); + + Assert.True(result.Change); + Assert.Equal("Finnish overview", result.Value); + } + + [Fact] + public void ResolveFieldUsesOriginalTitleBeforeTranslations() + { + var result = ItemsProxyTransformer.ResolveField( + TitleField, + [OriginalAction, LanguagePrefix + "en"], + Facts(originalTitle: "Original title"), + TextTranslations(("en", TitleField, "English title"))); + + Assert.True(result.Change); + Assert.Equal("Original title", result.Value); + } + + [Fact] + public void ResolveFieldFallsBackToJellyfinForMissingTitle() + { + var result = ItemsProxyTransformer.ResolveField( + TitleField, + [LanguagePrefix + "sv"], + Facts(), + TextTranslations()); + + Assert.False(result.Change); + Assert.Null(result.Value); + } + + [Fact] + public void ResolveFieldClearsMissingOptionalText() + { + var result = ItemsProxyTransformer.ResolveField( + TaglineField, + [LanguagePrefix + "sv"], + Facts(), + TextTranslations()); + + Assert.True(result.Change); + Assert.Equal(string.Empty, result.Value); + } + + [Fact] + public void ResolveAssetUsesFirstLanguageWithData() + { + var assets = AssetTranslations( + (PosterKind, "sv", ""), + (PosterKind, "fi", "https://image/fi.jpg"), + (PosterKind, "en", "https://image/en.jpg")); + + var result = ItemsProxyTransformer.ResolveAsset( + PosterKind, + [LanguagePrefix + "sv", LanguagePrefix + "fi", LanguagePrefix + "en"], + assets); + + Assert.True(result.Change); + Assert.Equal("https://image/fi.jpg", result.Value); + } + + [Fact] + public void ResolveAssetFallsBackToJellyfinForMissingPoster() + { + var result = ItemsProxyTransformer.ResolveAsset( + PosterKind, + [LanguagePrefix + "sv"], + AssetTranslations()); + + Assert.False(result.Change); + Assert.Null(result.Value); + } + + [Fact] + public void ResolveAssetClearsMissingOptionalImage() + { + var result = ItemsProxyTransformer.ResolveAsset( + LogoKind, + [LanguagePrefix + "sv"], + AssetTranslations()); + + Assert.True(result.Change); + Assert.Equal(string.Empty, result.Value); + } + + private static Dictionary> TextTranslations(params (string Lang, string Field, string Value)[] rows) + { + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var (lang, field, value) in rows) + { + if (!result.TryGetValue(lang, out var byField)) + { + byField = new Dictionary(StringComparer.OrdinalIgnoreCase); + result[lang] = byField; + } + + byField[field] = value; + } + + return result; + } + + private static Dictionary> AssetTranslations(params (string Kind, string Lang, string Value)[] rows) + { + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var (kind, lang, value) in rows) + { + if (!result.TryGetValue(kind, out var byLang)) + { + byLang = new Dictionary(StringComparer.OrdinalIgnoreCase); + result[kind] = byLang; + } + + byLang[lang] = value; + } + + return result; + } + + private static FactsData Facts(string originalTitle = "") + => new( + "item", + "tmdb", + "movie", + originalTitle, + "", + "", + "[]", + "[]", + "[]", + "", + "[]", + 0, + 0); +} diff --git a/tests/Jellyfin.Plugin.Multilang.Tests/Jellyfin.Plugin.Multilang.Tests.csproj b/tests/Jellyfin.Plugin.Multilang.Tests/Jellyfin.Plugin.Multilang.Tests.csproj new file mode 100644 index 0000000..ef24ea9 --- /dev/null +++ b/tests/Jellyfin.Plugin.Multilang.Tests/Jellyfin.Plugin.Multilang.Tests.csproj @@ -0,0 +1,29 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + +