Add focused unit tests

This commit is contained in:
ajp_anton
2026-07-15 22:45:54 +00:00
parent dbf83a4f9c
commit 21ebaf4227
5 changed files with 447 additions and 0 deletions
@@ -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);
}
@@ -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;
}
}
@@ -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<string, Dictionary<string, string>> TextTranslations(params (string Lang, string Field, string Value)[] rows)
{
var result = new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
foreach (var (lang, field, value) in rows)
{
if (!result.TryGetValue(lang, out var byField))
{
byField = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
result[lang] = byField;
}
byField[field] = value;
}
return result;
}
private static Dictionary<string, Dictionary<string, string>> AssetTranslations(params (string Kind, string Lang, string Value)[] rows)
{
var result = new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
foreach (var (kind, lang, value) in rows)
{
if (!result.TryGetValue(kind, out var byLang))
{
byLang = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
result[kind] = byLang;
}
byLang[lang] = value;
}
return result;
}
private static FactsData Facts(string originalTitle = "")
=> new(
"item",
"tmdb",
"movie",
originalTitle,
"",
"",
"[]",
"[]",
"[]",
"",
"[]",
0,
0);
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Jellyfin.Plugin.Multilang\Jellyfin.Plugin.Multilang.csproj" />
</ItemGroup>
</Project>