Refactor Trakt settings validation and add unit tests for rating and year filters

This commit is contained in:
Andrew Nekowitsch 2026-05-04 16:52:15 -05:00
parent a410d0d57e
commit 109738660f
7 changed files with 236 additions and 27 deletions

View file

@ -1,5 +1,5 @@
{
"sdk": {
"version": "10.0.102"
"version": "10.0.103"
}
}

View file

@ -0,0 +1,137 @@
using System;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.ImportLists.Trakt;
using NzbDrone.Core.ImportLists.Trakt.List;
using NzbDrone.Core.ImportLists.Trakt.Popular;
using NzbDrone.Core.ImportLists.Trakt.User;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.ImportListTests.Trakt
{
[TestFixture]
public class TraktValidationFixture : CoreTest
{
[TestCase("0-100")]
[TestCase("50-50")]
[TestCase("100-100")]
public void should_accept_supported_rating_ranges(string rating)
{
CreateValidListSettings(rating: rating).Validate().IsValid.Should().BeTrue();
CreateValidPopularSettings(rating: rating).Validate().IsValid.Should().BeTrue();
CreateValidUserSettings(rating: rating).Validate().IsValid.Should().BeTrue();
}
[TestCase("10")]
[TestCase("10-5")]
[TestCase("00-10")]
[TestCase("10-101")]
[TestCase("1000-1000")]
[TestCase("10 - 20")]
public void should_reject_invalid_rating_ranges(string rating)
{
CreateValidListSettings(rating: rating).Validate().IsValid.Should().BeFalse();
CreateValidPopularSettings(rating: rating).Validate().IsValid.Should().BeFalse();
CreateValidUserSettings(rating: rating).Validate().IsValid.Should().BeFalse();
}
[TestCase("1990")]
[TestCase("1990-2000")]
public void should_accept_supported_year_filters(string years)
{
CreateValidListSettings(years: years).Validate().IsValid.Should().BeTrue();
CreateValidPopularSettings(years: years).Validate().IsValid.Should().BeTrue();
CreateValidUserSettings(years: years).Validate().IsValid.Should().BeTrue();
}
[TestCase("234923498237423-234723477")]
[TestCase("199")]
[TestCase("1990-1980")]
[TestCase("1990 - 2000")]
public void should_reject_invalid_year_filters(string years)
{
CreateValidListSettings(years: years).Validate().IsValid.Should().BeFalse();
CreateValidPopularSettings(years: years).Validate().IsValid.Should().BeFalse();
CreateValidUserSettings(years: years).Validate().IsValid.Should().BeFalse();
}
[TestCase("genres=comedy")]
[TestCase("ratings=80-100")]
[TestCase("years=1990-2000")]
[TestCase("limit=10")]
public void should_reject_reserved_additional_parameters(string additionalParameters)
{
CreateValidListSettings(additionalParameters: additionalParameters).Validate().IsValid.Should().BeFalse();
CreateValidPopularSettings(additionalParameters: additionalParameters).Validate().IsValid.Should().BeFalse();
CreateValidUserSettings(additionalParameters: additionalParameters).Validate().IsValid.Should().BeFalse();
}
[Test]
public void should_allow_non_reserved_additional_parameters()
{
CreateValidListSettings(additionalParameters: "languages=en").Validate().IsValid.Should().BeTrue();
CreateValidPopularSettings(additionalParameters: "languages=en").Validate().IsValid.Should().BeTrue();
CreateValidUserSettings(additionalParameters: "languages=en").Validate().IsValid.Should().BeTrue();
}
[Test]
public void should_ignore_reserved_additional_parameters_when_building_filter_parameters()
{
var parameters = TraktQueryHelper.BuildFilterParameters(
"80-100",
"Drama",
"1990-2000",
25,
"genres=comedy&ratings=10-20&years=2000-2010&limit=10&languages=en");
parameters.Should().ContainKey("genres").WhoseValue.Should().Be("drama");
parameters.Should().ContainKey("ratings").WhoseValue.Should().Be("80-100");
parameters.Should().ContainKey("years").WhoseValue.Should().Be("1990-2000");
parameters.Should().ContainKey("limit").WhoseValue.Should().Be("25");
parameters.Should().ContainKey("languages").WhoseValue.Should().Be("en");
parameters.Should().HaveCount(5);
}
private static TraktListSettings CreateValidListSettings(string rating = "80-100", string years = "1990-2000", string additionalParameters = "languages=en")
{
return new TraktListSettings
{
AccessToken = "access-token",
RefreshToken = "refresh-token",
Expires = DateTime.UtcNow.AddDays(1),
Username = "sonarr",
Listname = "watchlist",
Rating = rating,
Years = years,
TraktAdditionalParameters = additionalParameters
};
}
private static TraktPopularSettings CreateValidPopularSettings(string rating = "80-100", string years = "1990-2000", string additionalParameters = "languages=en")
{
return new TraktPopularSettings
{
AccessToken = "access-token",
RefreshToken = "refresh-token",
Expires = DateTime.UtcNow.AddDays(1),
Rating = rating,
Years = years,
TraktAdditionalParameters = additionalParameters
};
}
private static TraktUserSettings CreateValidUserSettings(string rating = "80-100", string years = "1990-2000", string additionalParameters = "languages=en")
{
return new TraktUserSettings
{
AccessToken = "access-token",
RefreshToken = "refresh-token",
Expires = DateTime.UtcNow.AddDays(1),
AuthUser = "sonarr-user",
Rating = rating,
Years = years,
TraktAdditionalParameters = additionalParameters
};
}
}
}

View file

@ -1,4 +1,3 @@
using System.Text.RegularExpressions;
using FluentValidation;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Annotations;
@ -14,7 +13,7 @@ public TraktListSettingsValidator()
RuleFor(c => c.Listname).NotEmpty();
RuleFor(c => c.Years)
.Matches(@"^\d+(\-\d+)?$", RegexOptions.IgnoreCase)
.Must(BeValidYearRange)
.When(c => c.Years.IsNotNullOrWhiteSpace())
.WithMessage("Not a valid year or range of years");
}

View file

@ -1,4 +1,3 @@
using System.Text.RegularExpressions;
using FluentValidation;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Annotations;
@ -19,15 +18,8 @@ public TraktPopularSettingsValidator()
.WithMessage("Yearly lists are no longer supported");
#pragma warning restore CS0612
// Loose validation @TODO
RuleFor(c => c.Rating)
.Matches(@"^\d+\-\d+$", RegexOptions.IgnoreCase)
.When(c => c.Rating.IsNotNullOrWhiteSpace())
.WithMessage("Not a valid rating");
// Loose validation @TODO
RuleFor(c => c.Years)
.Matches(@"^\d+(\-\d+)?$", RegexOptions.IgnoreCase)
.Must(BeValidYearRange)
.When(c => c.Years.IsNotNullOrWhiteSpace())
.WithMessage("Not a valid year or range of years");
}

View file

@ -7,11 +7,18 @@ namespace NzbDrone.Core.ImportLists.Trakt
{
public static class TraktQueryHelper
{
private static readonly HashSet<string> ReservedFilterParameters = new(StringComparer.OrdinalIgnoreCase)
{
"genres",
"ratings",
"years",
"limit"
};
public static Dictionary<string, string> BuildFilterParameters(string rating, string genres, string years, int limit, string additionalParameters)
{
var parameters = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
// Parse additional parameters first (lower priority)
if (additionalParameters.IsNotNullOrWhiteSpace())
{
var trimmed = additionalParameters.TrimStart('?').TrimStart('&');
@ -24,11 +31,7 @@ public static Dictionary<string, string> BuildFilterParameters(string rating, st
{
var key = parts[0].Trim();
// Skip explicitly handled parameters
if (key.Equals("genres", StringComparison.OrdinalIgnoreCase) ||
key.Equals("ratings", StringComparison.OrdinalIgnoreCase) ||
key.Equals("years", StringComparison.OrdinalIgnoreCase) ||
key.Equals("limit", StringComparison.OrdinalIgnoreCase))
if (ReservedFilterParameters.Contains(key))
{
continue;
}
@ -58,6 +61,28 @@ public static Dictionary<string, string> BuildFilterParameters(string rating, st
return parameters;
}
public static bool ContainsReservedFilterParameters(string additionalParameters)
{
if (additionalParameters.IsNullOrWhiteSpace())
{
return false;
}
var trimmed = additionalParameters.TrimStart('?').TrimStart('&');
foreach (var param in trimmed.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var parts = param.Split('=', 2);
if (parts.Length == 2 && parts[0].IsNotNullOrWhiteSpace() && ReservedFilterParameters.Contains(parts[0].Trim()))
{
return true;
}
}
return false;
}
public static string ToQueryString(this Dictionary<string, string> parameters)
{
return string.Join("&", parameters.Where(p => p.Value.IsNotNullOrWhiteSpace()).Select(p => $"{p.Key}={p.Value}"));

View file

@ -1,5 +1,5 @@
using System;
using System.Text.RegularExpressions;
using System.Globalization;
using FluentValidation;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Annotations;
@ -34,9 +34,71 @@ public TraktSettingsBaseValidator()
.WithMessage("Must be integer greater than 0");
RuleFor(c => c.Rating)
.Matches(@"^\d+\-\d+$", RegexOptions.IgnoreCase)
.Must(BeValidRatingRange)
.When(c => c.Rating.IsNotNullOrWhiteSpace())
.WithMessage("Not a valid rating");
RuleFor(c => c.TraktAdditionalParameters)
.Must(additionalParameters => !TraktQueryHelper.ContainsReservedFilterParameters(additionalParameters))
.When(c => c.TraktAdditionalParameters.IsNotNullOrWhiteSpace())
.WithMessage("Additional parameters cannot include genres, ratings, years, or limit");
}
protected static bool BeValidYearRange(string years)
{
var parts = years.Split('-', StringSplitOptions.None);
if (parts.Length == 1)
{
return TryParseYear(parts[0], out _);
}
if (parts.Length != 2)
{
return false;
}
return TryParseYear(parts[0], out var startYear) &&
TryParseYear(parts[1], out var endYear) &&
startYear <= endYear;
}
private static bool BeValidRatingRange(string rating)
{
var parts = rating.Split('-', StringSplitOptions.None);
if (parts.Length != 2)
{
return false;
}
return TryParseRating(parts[0], out var minimumRating) &&
TryParseRating(parts[1], out var maximumRating) &&
minimumRating <= maximumRating;
}
private static bool TryParseYear(string token, out int year)
{
year = default;
return token.Length == 4 &&
int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out year) &&
year >= 1000;
}
private static bool TryParseRating(string token, out int rating)
{
if (!int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out rating))
{
return false;
}
if (rating is < 0 or > 100)
{
return false;
}
return token == rating.ToString(CultureInfo.InvariantCulture);
}
}

View file

@ -1,4 +1,3 @@
using System.Text.RegularExpressions;
using FluentValidation;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Annotations;
@ -14,13 +13,8 @@ public TraktUserSettingsValidator()
RuleFor(c => c.TraktWatchedListType).NotNull();
RuleFor(c => c.AuthUser).NotEmpty();
RuleFor(c => c.Rating)
.Matches(@"^\d+\-\d+$", RegexOptions.IgnoreCase)
.When(c => c.Rating.IsNotNullOrWhiteSpace())
.WithMessage("Not a valid rating");
RuleFor(c => c.Years)
.Matches(@"^\d+(\-\d+)?$", RegexOptions.IgnoreCase)
.Must(BeValidYearRange)
.When(c => c.Years.IsNotNullOrWhiteSpace())
.WithMessage("Not a valid year or range of years");
}