added seeker funnel and preview tests

This commit is contained in:
Flaminel committed 2026-10-21 17:25:02 +03:00
1 parent eeade84f98
commit becb58b147
4 files changed
+1541 -1

No files matched your search

@@ -79,6 +79,61 @@ public class SearchStatsControllerTests : IDisposable
#endregion
#region GetSummary Funnel
[Fact]
public async Task GetSummary_Funnel_IsNullUntilARunCountedIt()
{
ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
Guid currentCycleId = Guid.NewGuid();
AddInstanceConfig(radarr.Id, currentCycleId);
AddCycle(currentCycleId, radarr.Id, eligibleNow: 10, remainingUnits: 4, startedAt: Now.AddDays(-1));
SearchStatsSummaryResponse summary = Summary(await _controller.GetSummary());
summary.PerInstanceStats.ShouldHaveSingleItem().Funnel.ShouldBeNull();
}
[Fact]
public async Task GetSummary_Funnel_MapsTheCycleColumns()
{
ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
Guid currentCycleId = Guid.NewGuid();
AddInstanceConfig(radarr.Id, currentCycleId);
AddCycle(currentCycleId, radarr.Id, eligibleNow: 10, remainingUnits: 4, startedAt: Now.AddDays(-1),
funnelCountedAt: Now.AddMinutes(-5), queuedUnits: 2, recentlyGrabbedUnits: 1);
SearchStatsSummaryResponse summary = Summary(await _controller.GetSummary());
InstanceFunnelStat funnel = summary.PerInstanceStats.ShouldHaveSingleItem().Funnel.ShouldNotBeNull();
funnel.LibraryItems.ShouldBe(50);
funnel.ReleasedItems.ShouldBe(40);
funnel.MonitoredItems.ShouldBe(30);
funnel.TagFilteredItems.ShouldBe(20);
funnel.EligibleUnits.ShouldBe(10);
funnel.QueuedUnits.ShouldBe(2);
funnel.RecentlyGrabbedUnits.ShouldBe(1);
funnel.RemainingUnits.ShouldBe(4);
funnel.SearchedUnits.ShouldBe(3);
funnel.CountedAt.ShouldBe(Now.AddMinutes(-5));
}
[Fact]
public async Task GetSummary_Funnel_SearchedUnitsNeverGoesNegative()
{
ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
Guid currentCycleId = Guid.NewGuid();
AddInstanceConfig(radarr.Id, currentCycleId);
AddCycle(currentCycleId, radarr.Id, eligibleNow: 3, remainingUnits: 2, startedAt: Now.AddDays(-1),
funnelCountedAt: Now, queuedUnits: 2, recentlyGrabbedUnits: 1);
SearchStatsSummaryResponse summary = Summary(await _controller.GetSummary());
summary.PerInstanceStats.ShouldHaveSingleItem().Funnel.ShouldNotBeNull().SearchedUnits.ShouldBe(0);
}
#endregion
#region GetSummary PerInstanceStats
[Fact]
@@ -231,7 +286,10 @@ public class SearchStatsControllerTests : IDisposable
DateTimeOffset startedAt,
DateTimeOffset? completedAt = null,
bool isDryRun = false,
int searchedUnits = 0)
int searchedUnits = 0,
DateTimeOffset? funnelCountedAt = null,
int queuedUnits = 0,
int recentlyGrabbedUnits = 0)
{
_eventsContext.SeekerCycles.Add(new SeekerCycle
{
@@ -245,6 +303,13 @@ public class SearchStatsControllerTests : IDisposable
RemainingUnits = remainingUnits,
SearchedUnits = searchedUnits,
IsDryRun = isDryRun,
LibraryItems = 50,
ReleasedItems = 40,
MonitoredItems = 30,
TagFilteredItems = 20,
QueuedUnits = queuedUnits,
RecentlyGrabbedUnits = recentlyGrabbedUnits,
FunnelCountedAt = funnelCountedAt,
});
_eventsContext.SaveChanges();
}
@@ -0,0 +1,333 @@
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
using Cleanuparr.Api.Features.Seeker.Controllers;
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
using Cleanuparr.Api.Tests.TestHelpers;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Seeker;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Cleanuparr.Persistence.Models.State;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.Seeker;
public class SeekerPreviewControllerTests : IDisposable
{
private static readonly DateTimeOffset Now = new(2026, 1, 10, 12, 0, 0, TimeSpan.Zero);
private readonly DataContext _dataContext;
private readonly EventsContext _eventsContext;
private readonly IRadarrClient _radarrClient;
private readonly ISonarrClient _sonarrClient;
private readonly IArrClientFactory _arrClientFactory;
private readonly IArrQueueReader _arrQueueReader;
private readonly SeekerPreviewController _controller;
public SeekerPreviewControllerTests()
{
_dataContext = SeekerTestDataFactory.CreateDataContext();
_eventsContext = SeekerTestDataFactory.CreateEventsContext();
_radarrClient = Substitute.For<IRadarrClient>();
_sonarrClient = Substitute.For<ISonarrClient>();
_arrClientFactory = Substitute.For<IArrClientFactory>();
_arrQueueReader = Substitute.For<IArrQueueReader>();
FakeTimeProvider timeProvider = new(Now);
_arrClientFactory.GetClient(Arg.Any<InstanceType>(), Arg.Any<float>()).Returns(Substitute.For<IArrClient>());
_arrQueueReader.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>()).Returns([]);
_radarrClient.GetAllTagsAsync(Arg.Any<ArrInstance>()).Returns([]);
_radarrClient.GetQualityProfilesAsync(Arg.Any<ArrInstance>()).Returns([]);
_radarrClient
.GetRecentlyGrabbedMovieIdsAsync(Arg.Any<ArrInstance>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(new HashSet<long>());
_radarrClient
.GetCutoffUnmetMoviesAsync(Arg.Any<ArrInstance>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns([]);
SeekerUnitEvaluator evaluator = new(
NullLogger<SeekerUnitEvaluator>.Instance,
_eventsContext,
_radarrClient,
_sonarrClient,
timeProvider);
_controller = new SeekerPreviewController(
NullLogger<SeekerPreviewController>.Instance,
_dataContext,
_eventsContext,
_arrClientFactory,
_arrQueueReader,
evaluator,
timeProvider);
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
}
public void Dispose()
{
_dataContext.Dispose();
_eventsContext.Dispose();
GC.SuppressFinalize(this);
}
private static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(IEnumerable<T> items)
{
foreach (T item in items)
{
yield return item;
}
await Task.CompletedTask;
}
private static SearchableMovie Movie(long id, string status = "released")
{
return new SearchableMovie { Id = id, Title = $"Movie {id}", Status = status, Monitored = true, Tags = [] };
}
private void SetupMovies(params SearchableMovie[] movies)
{
List<SearchableMovie> library = movies.ToList();
_radarrClient.StreamAllMoviesAsync(Arg.Any<ArrInstance>(), Arg.Any<CancellationToken>())
.Returns(_ => ToAsyncEnumerable(library));
_radarrClient.GetMissingMoviesAsync(Arg.Any<ArrInstance>(), true, Arg.Any<CancellationToken>())
.Returns(library.Where(m => !m.HasFile).ToList());
_radarrClient.GetMissingMoviesAsync(Arg.Any<ArrInstance>(), false, Arg.Any<CancellationToken>())
.Returns([]);
}
private ArrInstance AddRadarr(Guid? currentCycleId = null, int minCycleTimeDays = 7)
{
ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarr.Id,
Enabled = true,
CurrentCycleId = currentCycleId ?? Guid.NewGuid(),
MinCycleTimeDays = minCycleTimeDays,
});
_dataContext.SaveChanges();
return radarr;
}
private void AddCycle(Guid id, Guid arrInstanceId, DateTimeOffset startedAt, int searchedUnits, bool isDryRun = false)
{
_eventsContext.SeekerCycles.Add(new SeekerCycle
{
Id = id,
ArrInstanceId = arrInstanceId,
StartedAt = startedAt,
SearchedUnits = searchedUnits,
IsDryRun = isDryRun,
});
_eventsContext.SaveChanges();
}
private void AddHistory(Guid arrInstanceId, Guid cycleId, long movieId, bool isDryRun = false)
{
_eventsContext.SeekerHistory.Add(new SeekerHistory
{
ArrInstanceId = arrInstanceId,
ExternalItemId = movieId,
ItemType = InstanceType.Radarr,
CycleId = cycleId,
LastSearchedAt = Now.AddDays(-1),
ItemTitle = $"Movie {movieId}",
IsDryRun = isDryRun,
});
_eventsContext.SaveChanges();
}
private static SeekerPreviewResponse Preview(IActionResult result) =>
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<SeekerPreviewResponse>();
private static int StatusOf(IActionResult result) =>
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldNotBeNull();
[Fact]
public async Task GetPreview_UnknownInstance_ReturnsNotFound()
{
IActionResult result = await _controller.GetPreview(Guid.NewGuid(), CancellationToken.None);
StatusOf(result).ShouldBe(StatusCodes.Status404NotFound);
}
[Fact]
public async Task GetPreview_UnsupportedInstanceType_ReturnsBadRequest()
{
ArrInstance lidarr = SeekerTestDataFactory.AddLidarrInstance(_dataContext);
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig { ArrInstanceId = lidarr.Id, Enabled = true });
await _dataContext.SaveChangesAsync();
IActionResult result = await _controller.GetPreview(lidarr.Id, CancellationToken.None);
StatusOf(result).ShouldBe(StatusCodes.Status400BadRequest);
}
[Fact]
public async Task GetPreview_Radarr_ReturnsNextUpExcludedAndFunnel()
{
ArrInstance radarr = AddRadarr();
SetupMovies(Movie(1), Movie(2), Movie(3, status: "announced"));
SeekerPreviewResponse response = Preview(await _controller.GetPreview(radarr.Id, CancellationToken.None));
response.NextUp.Select(u => u.Name).ShouldBe(["Movie 1", "Movie 2"], ignoreOrder: true);
response.NextUp.ShouldAllBe(u => u.Reason == SeekerSearchReason.Missing);
SeekerPreviewExclusionResponse excluded = response.Excluded.ShouldHaveSingleItem();
excluded.Name.ShouldBe("Movie 3");
excluded.Reason.ShouldBe(SeekerExclusionReason.NotReleased);
response.CycleWaitingUntil.ShouldBeNull();
response.Funnel.LibraryItems.ShouldBe(3);
response.Funnel.ReleasedItems.ShouldBe(2);
response.Funnel.EligibleUnits.ShouldBe(2);
response.Funnel.RemainingUnits.ShouldBe(2);
response.Funnel.SearchedUnits.ShouldBe(0);
response.Funnel.CountedAt.ShouldBe(Now);
}
[Fact]
public async Task GetPreview_QueuedMovie_LeavesItOutOfNextUp()
{
ArrInstance radarr = AddRadarr();
SetupMovies(Movie(1), Movie(2));
_arrQueueReader.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>()).Returns(
[
new QueueRecord { Id = 1, Title = "Movie 1 Download", DownloadId = "hash", Protocol = "torrent", SizeLeft = 1, MovieId = 1, TrackedDownloadState = "downloading" },
]);
SeekerPreviewResponse response = Preview(await _controller.GetPreview(radarr.Id, CancellationToken.None));
response.NextUp.ShouldHaveSingleItem().Name.ShouldBe("Movie 2");
response.Excluded.ShouldHaveSingleItem().Reason.ShouldBe(SeekerExclusionReason.InQueue);
response.Funnel.QueuedUnits.ShouldBe(1);
}
[Fact]
public async Task GetPreview_CycleSearchedEverythingBeforeMinCycleTime_WaitsAndListsNothingNextUp()
{
Guid cycleId = Guid.NewGuid();
ArrInstance radarr = AddRadarr(cycleId, minCycleTimeDays: 7);
DateTimeOffset startedAt = Now.AddDays(-2);
AddCycle(cycleId, radarr.Id, startedAt, searchedUnits: 2);
AddHistory(radarr.Id, cycleId, 1);
AddHistory(radarr.Id, cycleId, 2);
SetupMovies(Movie(1), Movie(2));
SeekerPreviewResponse response = Preview(await _controller.GetPreview(radarr.Id, CancellationToken.None));
response.CycleWaitingUntil.ShouldBe(startedAt.AddDays(7));
response.NextUp.ShouldBeEmpty();
response.Excluded.ShouldAllBe(e => e.Reason == SeekerExclusionReason.SearchedThisCycle);
response.Excluded.Count.ShouldBe(2);
}
[Fact]
public async Task GetPreview_CycleSearchedEverythingAfterMinCycleTime_PreviewsTheNextCycle()
{
Guid cycleId = Guid.NewGuid();
ArrInstance radarr = AddRadarr(cycleId, minCycleTimeDays: 7);
AddCycle(cycleId, radarr.Id, Now.AddDays(-10), searchedUnits: 2);
AddHistory(radarr.Id, cycleId, 1);
AddHistory(radarr.Id, cycleId, 2);
SetupMovies(Movie(1), Movie(2));
SeekerPreviewResponse response = Preview(await _controller.GetPreview(radarr.Id, CancellationToken.None));
response.CycleWaitingUntil.ShouldBeNull();
response.NextUp.Count.ShouldBe(2);
response.Excluded.ShouldBeEmpty();
}
[Fact]
public async Task GetPreview_DryRun_ReadsTheNewestDryCyclesHistory()
{
(await _dataContext.GeneralConfigs.FirstAsync()).DryRun = true;
await _dataContext.SaveChangesAsync();
Guid liveCycleId = Guid.NewGuid();
ArrInstance radarr = AddRadarr(liveCycleId);
Guid oldDryCycleId = Guid.NewGuid();
Guid newDryCycleId = Guid.NewGuid();
AddCycle(liveCycleId, radarr.Id, Now.AddDays(-20), searchedUnits: 1);
AddCycle(oldDryCycleId, radarr.Id, Now.AddDays(-5), searchedUnits: 1, isDryRun: true);
AddCycle(newDryCycleId, radarr.Id, Now.AddDays(-1), searchedUnits: 1, isDryRun: true);
AddHistory(radarr.Id, liveCycleId, 1);
AddHistory(radarr.Id, oldDryCycleId, 3, isDryRun: true);
AddHistory(radarr.Id, newDryCycleId, 2, isDryRun: true);
SetupMovies(Movie(1), Movie(2), Movie(3));
SeekerPreviewResponse response = Preview(await _controller.GetPreview(radarr.Id, CancellationToken.None));
response.Excluded.ShouldHaveSingleItem().Name.ShouldBe("Movie 2");
response.NextUp.Select(u => u.Name).ShouldBe(["Movie 1", "Movie 3"], ignoreOrder: true);
}
[Fact]
public async Task GetPreview_WritesNothing()
{
Guid cycleId = Guid.NewGuid();
ArrInstance radarr = AddRadarr(cycleId);
AddCycle(cycleId, radarr.Id, Now.AddDays(-1), searchedUnits: 1);
AddHistory(radarr.Id, cycleId, 1);
SetupMovies(Movie(1), Movie(2));
Preview(await _controller.GetPreview(radarr.Id, CancellationToken.None));
_eventsContext.ChangeTracker.Clear();
(await _eventsContext.SeekerCycles.CountAsync()).ShouldBe(1);
(await _eventsContext.SeekerHistory.CountAsync()).ShouldBe(1);
SeekerCycle cycle = await _eventsContext.SeekerCycles.SingleAsync();
cycle.FunnelCountedAt.ShouldBeNull();
cycle.SearchedUnits.ShouldBe(1);
}
[Fact]
public async Task GetPreview_NoCycleYet_CreatesNoCycle()
{
ArrInstance radarr = AddRadarr();
SetupMovies(Movie(1));
SeekerPreviewResponse response = Preview(await _controller.GetPreview(radarr.Id, CancellationToken.None));
response.NextUp.ShouldHaveSingleItem();
(await _eventsContext.SeekerCycles.CountAsync()).ShouldBe(0);
(await _eventsContext.SeekerHistory.CountAsync()).ShouldBe(0);
}
[Fact]
public async Task GetPreview_QueueReadThrows_ReturnsBadGateway()
{
ArrInstance radarr = AddRadarr();
SetupMovies(Movie(1));
_arrQueueReader.ReadAllAsync(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>())
.ThrowsAsync(new HttpRequestException("radarr is down"));
IActionResult result = await _controller.GetPreview(radarr.Id, CancellationToken.None);
StatusOf(result).ShouldBe(StatusCodes.Status502BadGateway);
}
[Fact]
public async Task GetPreview_RadarrWithoutWantedEndpoints_ReturnsBadGateway()
{
ArrInstance radarr = AddRadarr();
SetupMovies(Movie(1));
_radarrClient.GetMissingMoviesAsync(Arg.Any<ArrInstance>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Not found", null, System.Net.HttpStatusCode.NotFound));
IActionResult result = await _controller.GetPreview(radarr.Id, CancellationToken.None);
StatusOf(result).ShouldBe(StatusCodes.Status502BadGateway);
}
}
@@ -2889,6 +2889,224 @@ public class SeekerTests : IDisposable
#endregion
#region Funnel Counts
private async Task<(ArrInstance Instance, IArrClient ArrClient)> SetupFunnelInstanceAsync(
InstanceType type,
Action<SeekerInstanceConfig>? configure = null)
{
SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
ArrInstance instance = type == InstanceType.Radarr
? TestDataContextFactory.AddRadarrInstance(_fixture.DataContext)
: TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
SeekerInstanceConfig instanceConfig = new()
{
ArrInstanceId = instance.Id,
ArrInstance = instance,
Enabled = true,
};
configure?.Invoke(instanceConfig);
_fixture.DataContext.SeekerInstanceConfigs.Add(instanceConfig);
await _fixture.DataContext.SaveChangesAsync();
IArrClient arrClient = Substitute.For<IArrClient>();
arrClient.SearchItemAsync(Arg.Any<ArrInstance>(), Arg.Any<SearchItem>()).Returns(100L);
_fixture.ArrClientFactory.GetClient(type, Arg.Any<float>()).Returns(arrClient);
_fixture.ArrQueueReader.ReadAllAsync(arrClient, Arg.Any<ArrInstance>()).Returns([]);
return (instance, arrClient);
}
private void StubFunnelMovies(ArrInstance instance, List<SearchableMovie> movies)
{
_radarrClient
.StreamAllMoviesAsync(instance, Arg.Any<CancellationToken>())
.Returns(_ => ToAsyncEnumerable(movies));
StubRadarrWanted(instance, movies);
}
private void StubQueue(IArrClient arrClient, params QueueRecord[] records)
{
_fixture.ArrQueueReader
.ReadAllAsync(arrClient, Arg.Any<ArrInstance>())
.Returns(records.ToList());
}
private static SearchableMovie ReleasedMovie(long id, bool monitored = true)
{
return new SearchableMovie { Id = id, Title = $"Movie {id}", Status = "released", Monitored = monitored, Tags = [] };
}
private async Task<SeekerCycle> GetCycleAsync(Guid arrInstanceId, bool isDryRun = false)
{
return await _fixture.EventsContext.SeekerCycles
.AsNoTracking()
.SingleAsync(c => c.ArrInstanceId == arrInstanceId && c.IsDryRun == isDryRun);
}
[Fact]
public async Task ExecuteAsync_Radarr_RecordsFunnelCountsOnTheCurrentCycle()
{
// Arrange
(ArrInstance radarrInstance, IArrClient arrClient) = await SetupFunnelInstanceAsync(InstanceType.Radarr);
StubFunnelMovies(radarrInstance,
[
ReleasedMovie(1),
ReleasedMovie(2),
ReleasedMovie(3),
ReleasedMovie(4, monitored: false),
new SearchableMovie { Id = 5, Title = "Movie 5", Status = "announced", Monitored = true, Tags = [] },
]);
StubQueue(arrClient, new QueueRecord { Id = 1, Title = "Movie 2 Download", DownloadId = "hash1", Protocol = "torrent", SizeLeft = 1000, MovieId = 2, TrackedDownloadState = "downloading" });
_radarrClient
.GetRecentlyGrabbedMovieIdsAsync(Arg.Any<ArrInstance>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(new HashSet<long> { 3 });
// Act
await CreateSut().ExecuteAsync();
// Assert
SeekerCycle cycle = await GetCycleAsync(radarrInstance.Id);
cycle.LibraryItems.ShouldBe(5);
cycle.ReleasedItems.ShouldBe(4);
cycle.MonitoredItems.ShouldBe(3);
cycle.TagFilteredItems.ShouldBe(3);
cycle.QueuedUnits.ShouldBe(1);
cycle.RecentlyGrabbedUnits.ShouldBe(1);
cycle.FunnelCountedAt.ShouldBe(_fixture.TimeProvider.GetUtcNow());
}
[Fact]
public async Task ExecuteAsync_Sonarr_RecordsFunnelCountsOnTheCurrentCycle()
{
// Arrange
(ArrInstance sonarrInstance, IArrClient arrClient) = await SetupFunnelInstanceAsync(InstanceType.Sonarr);
_sonarrClient
.StreamAllSeriesAsync(Arg.Any<ArrInstance>(), Arg.Any<CancellationToken>())
.Returns(_ => ToAsyncEnumerable<SearchableSeries>(
[
new SearchableSeries { Id = 10, Title = "Series 10", Status = "continuing", Monitored = true, Tags = [], SeriesType = "standard" },
new SearchableSeries { Id = 11, Title = "Series 11", Status = "continuing", Monitored = false, Tags = [], SeriesType = "standard" },
new SearchableSeries { Id = 12, Title = "Series 12", Status = "upcoming", Monitored = true, Tags = [], SeriesType = "standard" },
]));
DateTimeOffset pastDate = _fixture.TimeProvider.GetUtcNow().AddDays(-30);
StubSonarrWanted(sonarrInstance,
[
new SearchableEpisode { Id = 100, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate },
new SearchableEpisode { Id = 101, SeriesId = 10, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate },
]);
StubQueue(arrClient, new QueueRecord { Id = 1, Title = "Season 1", DownloadId = "hash1", Protocol = "torrent", SizeLeft = 1000, SeriesId = 10, SeasonNumber = 1, TrackedDownloadState = "downloading" });
// Act
await CreateSut().ExecuteAsync();
// Assert
SeekerCycle cycle = await GetCycleAsync(sonarrInstance.Id);
cycle.LibraryItems.ShouldBe(3);
cycle.ReleasedItems.ShouldBe(2);
cycle.MonitoredItems.ShouldBe(1);
cycle.TagFilteredItems.ShouldBe(1);
cycle.QueuedUnits.ShouldBe(1);
cycle.RecentlyGrabbedUnits.ShouldBe(0);
cycle.FunnelCountedAt.ShouldBe(_fixture.TimeProvider.GetUtcNow());
}
[Fact]
public async Task ExecuteAsync_Radarr_MovieQueuedAndRecentlyGrabbed_CountsAsQueuedOnly()
{
// Arrange
(ArrInstance radarrInstance, IArrClient arrClient) = await SetupFunnelInstanceAsync(InstanceType.Radarr);
StubFunnelMovies(radarrInstance, [ReleasedMovie(1), ReleasedMovie(2), ReleasedMovie(3), ReleasedMovie(4)]);
StubQueue(arrClient,
new QueueRecord { Id = 1, Title = "Movie 1 Download", DownloadId = "hash1", Protocol = "torrent", SizeLeft = 1000, MovieId = 1, TrackedDownloadState = "downloading" },
new QueueRecord { Id = 2, Title = "Movie 3 Download", DownloadId = "hash2", Protocol = "torrent", SizeLeft = 1000, MovieId = 3, TrackedDownloadState = "importing" });
_radarrClient
.GetRecentlyGrabbedMovieIdsAsync(Arg.Any<ArrInstance>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(new HashSet<long> { 2, 3 });
// Act
await CreateSut().ExecuteAsync();
// Assert
SeekerCycle cycle = await GetCycleAsync(radarrInstance.Id);
cycle.QueuedUnits.ShouldBe(2);
cycle.RecentlyGrabbedUnits.ShouldBe(1);
}
[Fact]
public async Task ExecuteAsync_Radarr_DryRun_RecordsFunnelCountsOnTheDryCycle()
{
// Arrange
_dryRunInterceptor.IsDryRunEnabled().Returns(true);
(ArrInstance radarrInstance, _) = await SetupFunnelInstanceAsync(InstanceType.Radarr);
StubFunnelMovies(radarrInstance, [ReleasedMovie(1), ReleasedMovie(2, monitored: false)]);
// Act
await CreateSut().ExecuteAsync();
// Assert
SeekerCycle dryCycle = await GetCycleAsync(radarrInstance.Id, isDryRun: true);
dryCycle.LibraryItems.ShouldBe(2);
dryCycle.MonitoredItems.ShouldBe(1);
dryCycle.FunnelCountedAt.ShouldNotBeNull();
int liveCyclesWithFunnel = await _fixture.EventsContext.SeekerCycles
.CountAsync(c => c.ArrInstanceId == radarrInstance.Id && !c.IsDryRun && c.FunnelCountedAt != null);
liveCyclesWithFunnel.ShouldBe(0);
}
[Fact]
public async Task ExecuteAsync_Radarr_CycleRollsOver_NewCycleCarriesTheFunnelCounts()
{
// Arrange: movies 1 and 2 were searched long ago, movie 3 sits in the queue
Guid oldCycleId = Guid.NewGuid();
(ArrInstance radarrInstance, IArrClient arrClient) = await SetupFunnelInstanceAsync(InstanceType.Radarr, c =>
{
c.MonitoredOnly = false;
c.CurrentCycleId = oldCycleId;
});
DateTime now = _fixture.TimeProvider.GetUtcNow().UtcDateTime;
foreach (long movieId in new long[] { 1, 2 })
{
_fixture.EventsContext.SeekerHistory.Add(new SeekerHistory
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = movieId,
ItemType = InstanceType.Radarr,
CycleId = oldCycleId,
LastSearchedAt = now.AddDays(-10),
ItemTitle = $"Movie {movieId}",
});
}
await _fixture.EventsContext.SaveChangesAsync();
StubFunnelMovies(radarrInstance, [ReleasedMovie(1), ReleasedMovie(2), ReleasedMovie(3)]);
StubQueue(arrClient, new QueueRecord { Id = 1, Title = "Movie 3 Download", DownloadId = "hash1", Protocol = "torrent", SizeLeft = 1000, MovieId = 3, TrackedDownloadState = "downloading" });
// Act
await CreateSut().ExecuteAsync();
// Assert
_fixture.DataContext.ChangeTracker.Clear();
SeekerInstanceConfig instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs
.FirstAsync(s => s.ArrInstanceId == radarrInstance.Id);
instanceConfig.CurrentCycleId.ShouldNotBe(oldCycleId);
SeekerCycle newCycle = await _fixture.EventsContext.SeekerCycles
.AsNoTracking()
.SingleAsync(c => c.Id == instanceConfig.CurrentCycleId);
newCycle.LibraryItems.ShouldBe(3);
newCycle.ReleasedItems.ShouldBe(3);
newCycle.QueuedUnits.ShouldBe(1);
newCycle.FunnelCountedAt.ShouldBe(_fixture.TimeProvider.GetUtcNow());
}
#endregion
#region Post-Release Grace Period Tests
[Fact]
@@ -0,0 +1,924 @@
using System.Net;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Seeker;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Cleanuparr.Persistence.Models.State;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Seeker;
public class SeekerUnitEvaluatorTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly EventsContext _eventsContext;
private readonly IRadarrClient _radarrClient;
private readonly ISonarrClient _sonarrClient;
private readonly FakeTimeProvider _timeProvider;
private readonly SeekerUnitEvaluator _sut;
private readonly SeekerConfig _config = new() { PostReleaseGraceHours = 6 };
private readonly ArrInstance _radarr;
private readonly ArrInstance _sonarr;
public SeekerUnitEvaluatorTests()
{
_dataContext = TestDataContextFactory.Create();
_eventsContext = TestDataContextFactory.CreateEvents();
_radarrClient = Substitute.For<IRadarrClient>();
_sonarrClient = Substitute.For<ISonarrClient>();
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
_radarr = TestDataContextFactory.AddRadarrInstance(_dataContext);
_sonarr = TestDataContextFactory.AddSonarrInstance(_dataContext);
_radarrClient.GetAllTagsAsync(Arg.Any<ArrInstance>()).Returns([]);
_sonarrClient.GetAllTagsAsync(Arg.Any<ArrInstance>()).Returns([]);
_radarrClient.GetQualityProfilesAsync(Arg.Any<ArrInstance>()).Returns([]);
_sonarrClient.GetQualityProfilesAsync(Arg.Any<ArrInstance>()).Returns([]);
_radarrClient.GetCutoffUnmetMoviesAsync(Arg.Any<ArrInstance>(), Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns([]);
_sonarrClient.GetCutoffUnmetEpisodesAsync(Arg.Any<ArrInstance>(), Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns([]);
_sut = new SeekerUnitEvaluator(
Substitute.For<ILogger<SeekerUnitEvaluator>>(),
_eventsContext,
_radarrClient,
_sonarrClient,
_timeProvider);
}
public void Dispose()
{
_dataContext.Dispose();
_eventsContext.Dispose();
GC.SuppressFinalize(this);
}
private static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(IEnumerable<T> items)
{
foreach (T item in items)
{
yield return item;
}
await Task.CompletedTask;
}
private static SearchableMovie Movie(
long id,
string status = "released",
bool monitored = true,
List<long>? tags = null,
bool hasFile = false,
int qualityProfileId = 1,
DateTimeOffset? digitalRelease = null,
DateTimeOffset? added = null)
{
return new SearchableMovie
{
Id = id,
Title = $"Movie {id}",
Status = status,
Monitored = monitored,
Tags = tags ?? [],
HasFile = hasFile,
QualityProfileId = qualityProfileId,
DigitalRelease = digitalRelease,
Added = added,
};
}
private static SearchableSeries Series(
long id,
string status = "continuing",
bool monitored = true,
List<long>? tags = null)
{
return new SearchableSeries
{
Id = id,
Title = $"Series {id}",
Status = status,
Monitored = monitored,
Tags = tags ?? [],
SeriesType = "standard",
};
}
private SearchableEpisode Episode(long id, long seriesId, int season = 1, int number = 1, DateTimeOffset? airDate = null, bool noAirDate = false)
{
return new SearchableEpisode
{
Id = id,
SeriesId = seriesId,
SeasonNumber = season,
EpisodeNumber = number,
Monitored = true,
AirDateUtc = noAirDate ? null : airDate ?? _timeProvider.GetUtcNow().AddDays(-30),
};
}
private static SeekerInstanceConfig InstanceConfig(ArrInstance instance, Action<SeekerInstanceConfig>? configure = null)
{
SeekerInstanceConfig instanceConfig = new()
{
ArrInstanceId = instance.Id,
ArrInstance = instance,
Enabled = true,
MonitoredOnly = true,
};
configure?.Invoke(instanceConfig);
return instanceConfig;
}
private static QueueRecord Queued(long movieId = 0, long seriesId = 0, long seasonNumber = 0, long episodeId = 0, string state = "downloading")
{
return new QueueRecord
{
Id = 1,
Title = "Download",
DownloadId = "hash",
Protocol = "torrent",
SizeLeft = 1000,
MovieId = movieId,
SeriesId = seriesId,
SeasonNumber = seasonNumber,
EpisodeId = episodeId,
TrackedDownloadState = state,
};
}
private void SetupRadarr(List<SearchableMovie> library, List<SearchableMovie>? missing = null)
{
List<SearchableMovie> wanted = missing ?? library.Where(m => !m.HasFile).ToList();
_radarrClient.StreamAllMoviesAsync(Arg.Any<ArrInstance>(), Arg.Any<CancellationToken>())
.Returns(_ => ToAsyncEnumerable(library));
_radarrClient.GetMissingMoviesAsync(Arg.Any<ArrInstance>(), true, Arg.Any<CancellationToken>())
.Returns(wanted.Where(m => m.Monitored).ToList());
_radarrClient.GetMissingMoviesAsync(Arg.Any<ArrInstance>(), false, Arg.Any<CancellationToken>())
.Returns(wanted.Where(m => !m.Monitored).ToList());
}
private void SetupSonarr(List<SearchableSeries> library, List<SearchableEpisode> missing)
{
_sonarrClient.StreamAllSeriesAsync(Arg.Any<ArrInstance>(), Arg.Any<CancellationToken>())
.Returns(_ => ToAsyncEnumerable(library));
_sonarrClient.GetMissingEpisodesAsync(Arg.Any<ArrInstance>(), true, Arg.Any<CancellationToken>())
.Returns(missing.Where(e => e.Monitored).ToList());
_sonarrClient.GetMissingEpisodesAsync(Arg.Any<ArrInstance>(), false, Arg.Any<CancellationToken>())
.Returns(missing.Where(e => !e.Monitored).ToList());
}
private Task<SeekerEvaluation> EvaluateRadarrAsync(
SeekerInstanceConfig? instanceConfig = null,
List<QueueRecord>? queue = null,
SeekerRecentGrabs? recentGrabs = null,
List<SeekerHistory>? history = null)
{
return _sut.EvaluateAsync(
_config,
instanceConfig ?? InstanceConfig(_radarr),
_radarr,
queue ?? [],
recentGrabs ?? new SeekerRecentGrabs(),
history ?? [],
CancellationToken.None);
}
private Task<SeekerEvaluation> EvaluateSonarrAsync(
SeekerInstanceConfig? instanceConfig = null,
List<QueueRecord>? queue = null,
List<SeekerHistory>? history = null)
{
return _sut.EvaluateAsync(
_config,
instanceConfig ?? InstanceConfig(_sonarr),
_sonarr,
queue ?? [],
new SeekerRecentGrabs(),
history ?? [],
CancellationToken.None);
}
private static SeekerHistory History(ArrInstance instance, long itemId, DateTimeOffset searchedAt, int seasonNumber = 0, long episodeId = 0)
{
return new SeekerHistory
{
ArrInstanceId = instance.Id,
ExternalItemId = itemId,
ItemType = instance.ArrConfig.Type,
SeasonNumber = seasonNumber,
EpisodeId = episodeId,
CycleId = Guid.NewGuid(),
LastSearchedAt = searchedAt,
ItemTitle = $"Item {itemId}",
};
}
private static SeekerExclusionReason ReasonOf(SeekerEvaluation evaluation, string name)
{
return evaluation.Excluded.Single(e => e.Name == name).Reason;
}
#region Radarr exclusion reasons
[Fact]
public async Task EvaluateAsync_Radarr_StatusNotReleased_ExcludesAsNotReleased()
{
// Arrange
SetupRadarr([Movie(1, status: "announced")]);
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync();
// Assert
ReasonOf(evaluation, "Movie 1").ShouldBe(SeekerExclusionReason.NotReleased);
evaluation.Units.ShouldBeEmpty();
}
[Fact]
public async Task EvaluateAsync_Radarr_ReleasedInsideGraceWindow_ExcludesAsInGracePeriod()
{
// Arrange
SetupRadarr([Movie(1, digitalRelease: _timeProvider.GetUtcNow().AddHours(-1))]);
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync();
// Assert
ReasonOf(evaluation, "Movie 1").ShouldBe(SeekerExclusionReason.InGracePeriod);
}
[Fact]
public async Task EvaluateAsync_Radarr_UnmonitoredWithMonitoredOnly_ExcludesAsNotMonitored()
{
// Arrange
SetupRadarr([Movie(1, monitored: false)]);
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync();
// Assert
ReasonOf(evaluation, "Movie 1").ShouldBe(SeekerExclusionReason.NotMonitored);
}
[Fact]
public async Task EvaluateAsync_Radarr_UnmonitoredWithoutMonitoredOnly_BecomesUnit()
{
// Arrange
SetupRadarr([Movie(1, monitored: false)]);
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync(InstanceConfig(_radarr, c => c.MonitoredOnly = false));
// Assert
evaluation.Units.ShouldHaveSingleItem().ItemId.ShouldBe(1);
evaluation.Excluded.ShouldBeEmpty();
}
[Fact]
public async Task EvaluateAsync_Radarr_SkipTag_ExcludesAsSkipTag()
{
// Arrange
_radarrClient.GetAllTagsAsync(Arg.Any<ArrInstance>()).Returns([new Tag { Id = 7, Label = "Skip-Me" }]);
SetupRadarr([Movie(1, tags: [7])]);
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync(InstanceConfig(_radarr, c => c.SkipTags = ["skip-me"]));
// Assert
ReasonOf(evaluation, "Movie 1").ShouldBe(SeekerExclusionReason.SkipTag);
}
[Fact]
public async Task EvaluateAsync_Radarr_IncludeTagsAndNoMatchingTag_ExcludesAsMissingIncludeTag()
{
// Arrange
_radarrClient.GetAllTagsAsync(Arg.Any<ArrInstance>()).Returns([new Tag { Id = 7, Label = "keep" }]);
SetupRadarr([Movie(1), Movie(2, tags: [7])]);
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync(InstanceConfig(_radarr, c => c.IncludeTags = ["keep"]));
// Assert
ReasonOf(evaluation, "Movie 1").ShouldBe(SeekerExclusionReason.MissingIncludeTag);
evaluation.Units.ShouldHaveSingleItem().ItemId.ShouldBe(2);
}
[Fact]
public async Task EvaluateAsync_Radarr_SkipTagAndIncludeTagOnSameMovie_SkipTagWins()
{
// Arrange
_radarrClient.GetAllTagsAsync(Arg.Any<ArrInstance>()).Returns(
[
new Tag { Id = 1, Label = "skip" },
new Tag { Id = 2, Label = "keep" },
]);
SetupRadarr([Movie(1, tags: [1, 2])]);
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync(InstanceConfig(_radarr, c =>
{
c.SkipTags = ["skip"];
c.IncludeTags = ["keep"];
}));
// Assert
ReasonOf(evaluation, "Movie 1").ShouldBe(SeekerExclusionReason.SkipTag);
}
[Fact]
public async Task EvaluateAsync_Radarr_MovieWithFileAndNothingWanted_ExcludesAsNothingToSearch()
{
// Arrange
SetupRadarr([Movie(1, hasFile: true)]);
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync();
// Assert
ReasonOf(evaluation, "Movie 1").ShouldBe(SeekerExclusionReason.NothingToSearch);
}
[Fact]
public async Task EvaluateAsync_Radarr_BelowCustomFormatCutoffWithUpgradesDisallowed_ExcludesAsUpgradesDisabled()
{
// Arrange
SetupRadarr([Movie(1, hasFile: true, qualityProfileId: 4)]);
_radarrClient.GetQualityProfilesAsync(Arg.Any<ArrInstance>())
.Returns([new ArrQualityProfile { Id = 4, Name = "Locked", UpgradeAllowed = false }]);
_eventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = _radarr.Id,
ExternalItemId = 1,
ItemType = InstanceType.Radarr,
CurrentScore = 5,
CutoffScore = 10,
});
await _eventsContext.SaveChangesAsync();
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync(InstanceConfig(_radarr, c => c.UseCustomFormatScore = true));
// Assert
ReasonOf(evaluation, "Movie 1").ShouldBe(SeekerExclusionReason.UpgradesDisabled);
}
[Fact]
public async Task EvaluateAsync_Radarr_BelowCustomFormatCutoffWithUpgradesAllowed_BecomesUnit()
{
// Arrange
SetupRadarr([Movie(1, hasFile: true, qualityProfileId: 4)]);
_radarrClient.GetQualityProfilesAsync(Arg.Any<ArrInstance>())
.Returns([new ArrQualityProfile { Id = 4, Name = "Open", UpgradeAllowed = true }]);
_eventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = _radarr.Id,
ExternalItemId = 1,
ItemType = InstanceType.Radarr,
CurrentScore = 5,
CutoffScore = 10,
});
await _eventsContext.SaveChangesAsync();
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync(InstanceConfig(_radarr, c => c.UseCustomFormatScore = true));
// Assert
SeekerSearchUnit unit = evaluation.Units.ShouldHaveSingleItem();
unit.Reasons.ShouldBe([SeekerSearchReason.CustomFormatScoreBelowCutoff]);
}
[Theory]
[InlineData("downloading")]
[InlineData("importing")]
[InlineData("importPending")]
[InlineData("importBlocked")]
public async Task EvaluateAsync_Radarr_ActiveQueueState_ExcludesAsInQueue(string state)
{
// Arrange
SetupRadarr([Movie(1)]);
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync(queue: [Queued(movieId: 1, state: state)]);
// Assert
ReasonOf(evaluation, "Movie 1").ShouldBe(SeekerExclusionReason.InQueue);
}
[Fact]
public async Task EvaluateAsync_Radarr_ImportFailedQueueState_StaysSearchable()
{
// Arrange
SetupRadarr([Movie(1)]);
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync(queue: [Queued(movieId: 1, state: "importFailed")]);
// Assert
evaluation.Remaining.ShouldHaveSingleItem().ItemId.ShouldBe(1);
evaluation.QueuedUnits.ShouldBe(0);
}
[Fact]
public async Task EvaluateAsync_Radarr_RecentlyGrabbed_ExcludesAsRecentlyGrabbed()
{
// Arrange
SetupRadarr([Movie(1)]);
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync(recentGrabs: new SeekerRecentGrabs { MovieIds = [1] });
// Assert
ReasonOf(evaluation, "Movie 1").ShouldBe(SeekerExclusionReason.RecentlyGrabbed);
}
[Fact]
public async Task EvaluateAsync_Radarr_QueuedAndRecentlyGrabbed_QueueWins()
{
// Arrange
SetupRadarr([Movie(1)]);
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync(
queue: [Queued(movieId: 1)],
recentGrabs: new SeekerRecentGrabs { MovieIds = [1] });
// Assert
ReasonOf(evaluation, "Movie 1").ShouldBe(SeekerExclusionReason.InQueue);
evaluation.QueuedUnits.ShouldBe(1);
evaluation.RecentlyGrabbedUnits.ShouldBe(0);
}
[Fact]
public async Task EvaluateAsync_Radarr_SearchedInCurrentCycle_ExcludesAsSearchedThisCycle()
{
// Arrange
SetupRadarr([Movie(1)]);
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync(history: [History(_radarr, 1, _timeProvider.GetUtcNow().AddDays(-1))]);
// Assert
ReasonOf(evaluation, "Movie 1").ShouldBe(SeekerExclusionReason.SearchedThisCycle);
evaluation.Remaining.ShouldBeEmpty();
}
[Fact]
public async Task EvaluateAsync_Radarr_FirstMatchingStageWins()
{
// Arrange
_radarrClient.GetAllTagsAsync(Arg.Any<ArrInstance>()).Returns([new Tag { Id = 1, Label = "skip" }]);
SetupRadarr([Movie(1, status: "announced", monitored: false, tags: [1])]);
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync(InstanceConfig(_radarr, c => c.SkipTags = ["skip"]));
// Assert
evaluation.Excluded.ShouldHaveSingleItem().Reason.ShouldBe(SeekerExclusionReason.NotReleased);
}
#endregion
#region Sonarr exclusion reasons
[Fact]
public async Task EvaluateAsync_Sonarr_StatusUpcoming_ExcludesSeriesAsNotReleased()
{
// Arrange
SetupSonarr([Series(1, status: "upcoming")], [Episode(10, 1)]);
// Act
SeekerEvaluation evaluation = await EvaluateSonarrAsync();
// Assert
ReasonOf(evaluation, "Series 1").ShouldBe(SeekerExclusionReason.NotReleased);
evaluation.Units.ShouldBeEmpty();
}
[Fact]
public async Task EvaluateAsync_Sonarr_UnmonitoredSeries_ExcludesAsNotMonitored()
{
// Arrange
SetupSonarr([Series(1, monitored: false)], [Episode(10, 1)]);
// Act
SeekerEvaluation evaluation = await EvaluateSonarrAsync();
// Assert
ReasonOf(evaluation, "Series 1").ShouldBe(SeekerExclusionReason.NotMonitored);
}
[Fact]
public async Task EvaluateAsync_Sonarr_SkipTag_ExcludesSeriesAsSkipTag()
{
// Arrange
_sonarrClient.GetAllTagsAsync(Arg.Any<ArrInstance>()).Returns([new Tag { Id = 3, Label = "skip" }]);
SetupSonarr([Series(1, tags: [3])], [Episode(10, 1)]);
// Act
SeekerEvaluation evaluation = await EvaluateSonarrAsync(InstanceConfig(_sonarr, c => c.SkipTags = ["skip"]));
// Assert
ReasonOf(evaluation, "Series 1").ShouldBe(SeekerExclusionReason.SkipTag);
}
[Fact]
public async Task EvaluateAsync_Sonarr_EpisodeAiredInsideGraceWindow_ExcludesAsInGracePeriod()
{
// Arrange
SetupSonarr([Series(1)], [Episode(10, 1, airDate: _timeProvider.GetUtcNow().AddHours(-1))]);
// Act
SeekerEvaluation evaluation = await EvaluateSonarrAsync();
// Assert
ReasonOf(evaluation, "Series 1 S01E01").ShouldBe(SeekerExclusionReason.InGracePeriod);
evaluation.Units.ShouldBeEmpty();
}
[Fact]
public async Task EvaluateAsync_Sonarr_EpisodeWithoutAirDate_ExcludesAsNotReleased()
{
// Arrange
SetupSonarr([Series(1)], [Episode(10, 1, noAirDate: true)]);
// Act
SeekerEvaluation evaluation = await EvaluateSonarrAsync();
// Assert
ReasonOf(evaluation, "Series 1 S01E01").ShouldBe(SeekerExclusionReason.NotReleased);
}
[Fact]
public async Task EvaluateAsync_Sonarr_SeriesWithoutWantedEpisodes_ExcludesAsNothingToSearch()
{
// Arrange
SetupSonarr([Series(1)], []);
// Act
SeekerEvaluation evaluation = await EvaluateSonarrAsync();
// Assert
ReasonOf(evaluation, "Series 1").ShouldBe(SeekerExclusionReason.NothingToSearch);
}
[Fact]
public async Task EvaluateAsync_Sonarr_SeasonInQueue_ExcludesSeasonUnitAsInQueue()
{
// Arrange
SetupSonarr([Series(1)], [Episode(10, 1), Episode(11, 1, number: 2)]);
// Act
SeekerEvaluation evaluation = await EvaluateSonarrAsync(queue: [Queued(seriesId: 1, seasonNumber: 1)]);
// Assert
ReasonOf(evaluation, "Series 1 S01").ShouldBe(SeekerExclusionReason.InQueue);
evaluation.QueuedUnits.ShouldBe(1);
}
[Fact]
public async Task EvaluateAsync_Sonarr_SeasonSearchedInCurrentCycle_ExcludesAsSearchedThisCycle()
{
// Arrange
SetupSonarr([Series(1)], [Episode(10, 1)]);
// Act
SeekerEvaluation evaluation = await EvaluateSonarrAsync(
history: [History(_sonarr, 1, _timeProvider.GetUtcNow().AddDays(-1), seasonNumber: 1)]);
// Assert
ReasonOf(evaluation, "Series 1 S01").ShouldBe(SeekerExclusionReason.SearchedThisCycle);
}
#endregion
#region Stage counts
[Fact]
public async Task EvaluateAsync_Radarr_CountsEveryStageOfTheFunnel()
{
// Arrange
_radarrClient.GetAllTagsAsync(Arg.Any<ArrInstance>()).Returns([new Tag { Id = 1, Label = "skip" }]);
SetupRadarr(
[
Movie(1, status: "announced"),
Movie(2, digitalRelease: _timeProvider.GetUtcNow().AddHours(-1)),
Movie(3, monitored: false),
Movie(4, tags: [1]),
Movie(5),
Movie(6),
]);
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync(
InstanceConfig(_radarr, c => c.SkipTags = ["skip"]),
queue: [Queued(movieId: 5)],
recentGrabs: new SeekerRecentGrabs { MovieIds = [6] });
// Assert
evaluation.AllLibraryIds.Count.ShouldBe(6);
evaluation.ReleasedItems.ShouldBe(4);
evaluation.MonitoredItems.ShouldBe(3);
evaluation.TagFilteredItems.ShouldBe(2);
evaluation.Units.Count.ShouldBe(2);
evaluation.QueuedUnits.ShouldBe(1);
evaluation.RecentlyGrabbedUnits.ShouldBe(1);
evaluation.Remaining.ShouldBeEmpty();
}
[Fact]
public async Task EvaluateAsync_Sonarr_CountsEveryStageOfTheFunnel()
{
// Arrange
_sonarrClient.GetAllTagsAsync(Arg.Any<ArrInstance>()).Returns([new Tag { Id = 1, Label = "skip" }]);
SetupSonarr(
[
Series(1, status: "upcoming"),
Series(2, monitored: false),
Series(3, tags: [1]),
Series(4),
Series(5),
],
[Episode(40, 4), Episode(50, 5)]);
// Act
SeekerEvaluation evaluation = await EvaluateSonarrAsync(
InstanceConfig(_sonarr, c => c.SkipTags = ["skip"]),
queue: [Queued(seriesId: 4, seasonNumber: 1)]);
// Assert
evaluation.AllLibraryIds.Count.ShouldBe(5);
evaluation.ReleasedItems.ShouldBe(4);
evaluation.MonitoredItems.ShouldBe(3);
evaluation.TagFilteredItems.ShouldBe(2);
evaluation.Units.Count.ShouldBe(2);
evaluation.QueuedUnits.ShouldBe(1);
evaluation.RecentlyGrabbedUnits.ShouldBe(0);
}
#endregion
#region SelectNext
[Fact]
public async Task SelectNext_MoreRemainingThanRequested_ReturnsExactlyTheRequestedCount()
{
// Arrange
SetupRadarr([Movie(1), Movie(2), Movie(3), Movie(4), Movie(5)]);
SeekerEvaluation evaluation = await EvaluateRadarrAsync();
// Act
List<SeekerSearchCandidate> candidates = SeekerUnitEvaluator.SelectNext(evaluation, SelectionStrategy.Random, 3);
// Assert
candidates.Count.ShouldBe(3);
candidates.Select(c => c.ItemId).Distinct().Count().ShouldBe(3);
}
[Fact]
public async Task SelectNext_FewerRemainingThanRequested_ReturnsAllRemaining()
{
// Arrange
SetupRadarr([Movie(1), Movie(2)]);
SeekerEvaluation evaluation = await EvaluateRadarrAsync();
// Act
List<SeekerSearchCandidate> candidates = SeekerUnitEvaluator.SelectNext(evaluation, SelectionStrategy.Random, 10);
// Assert
candidates.Count.ShouldBe(2);
}
[Fact]
public async Task SelectNext_BlockedAndSearchedUnits_AreNeverPicked()
{
// Arrange
SetupRadarr([Movie(1), Movie(2), Movie(3)]);
SeekerEvaluation evaluation = await EvaluateRadarrAsync(
queue: [Queued(movieId: 1)],
history: [History(_radarr, 2, _timeProvider.GetUtcNow().AddDays(-1))]);
// Act
List<SeekerSearchCandidate> candidates = SeekerUnitEvaluator.SelectNext(evaluation, SelectionStrategy.Random, 10);
// Assert
candidates.ShouldHaveSingleItem().ItemId.ShouldBe(3);
}
[Fact]
public async Task SelectNext_Sonarr_ReturnsAtMostOneUnitPerSeries()
{
// Arrange
SetupSonarr([Series(1), Series(2)],
[
Episode(10, 1, season: 1),
Episode(11, 1, season: 2),
Episode(12, 1, season: 3),
Episode(20, 2, season: 1),
]);
SeekerEvaluation evaluation = await EvaluateSonarrAsync();
// Act
List<SeekerSearchCandidate> candidates = SeekerUnitEvaluator.SelectNext(evaluation, SelectionStrategy.Random, 10);
// Assert
evaluation.Remaining.Count.ShouldBe(4);
candidates.Count.ShouldBe(2);
candidates.Select(c => c.ItemId).OrderBy(id => id).ShouldBe([1L, 2L]);
}
[Fact]
public async Task SelectNext_NewestFirst_OrdersByDateAdded()
{
// Arrange
DateTimeOffset now = _timeProvider.GetUtcNow();
SetupRadarr(
[
Movie(1, added: now.AddDays(-30)),
Movie(2, added: now.AddDays(-1)),
Movie(3, added: now.AddDays(-10)),
]);
SeekerEvaluation evaluation = await EvaluateRadarrAsync();
// Act
List<SeekerSearchCandidate> candidates = SeekerUnitEvaluator.SelectNext(evaluation, SelectionStrategy.NewestFirst, 3);
// Assert
candidates.Select(c => c.ItemId).ShouldBe([2L, 3L, 1L]);
}
[Fact]
public async Task SelectNext_OldestSearchFirst_PicksTheNeverSearchedSeriesBeforeThePartlySearchedOne()
{
// Arrange
SetupSonarr([Series(1), Series(2)],
[
Episode(10, 1, season: 1),
Episode(11, 1, season: 2),
Episode(20, 2, season: 1),
]);
SeekerEvaluation evaluation = await EvaluateSonarrAsync(
history: [History(_sonarr, 1, _timeProvider.GetUtcNow().AddDays(-2), seasonNumber: 1)]);
// Act
List<SeekerSearchCandidate> candidates = SeekerUnitEvaluator.SelectNext(evaluation, SelectionStrategy.OldestSearchFirst, 2);
// Assert
candidates.Select(c => c.ItemId).ShouldBe([2L, 1L]);
candidates[1].SeasonNumber.ShouldBe(2);
}
#endregion
#region WithoutCycleHistory
[Fact]
public async Task WithoutCycleHistory_Radarr_ClearsTheSearchedFlags()
{
// Arrange
SetupRadarr([Movie(1), Movie(2)]);
SeekerEvaluation evaluation = await EvaluateRadarrAsync(
history:
[
History(_radarr, 1, _timeProvider.GetUtcNow().AddDays(-3)),
History(_radarr, 2, _timeProvider.GetUtcNow().AddDays(-2)),
]);
evaluation.Remaining.ShouldBeEmpty();
// Act
SeekerEvaluation fresh = evaluation.WithoutCycleHistory();
// Assert
fresh.Units.ShouldAllBe(u => !u.Searched);
fresh.Remaining.Count.ShouldBe(2);
fresh.LastSearchedByItem.ShouldBeEmpty();
fresh.Excluded.ShouldBeEmpty();
}
[Fact]
public async Task WithoutCycleHistory_Sonarr_ClearsTheSearchedFlags()
{
// Arrange
SetupSonarr([Series(1)], [Episode(10, 1)]);
SeekerEvaluation evaluation = await EvaluateSonarrAsync(
history: [History(_sonarr, 1, _timeProvider.GetUtcNow().AddDays(-1), seasonNumber: 1)]);
evaluation.Remaining.ShouldBeEmpty();
// Act
SeekerEvaluation fresh = evaluation.WithoutCycleHistory();
// Assert
fresh.Units.ShouldAllBe(u => !u.Searched);
fresh.Remaining.ShouldHaveSingleItem().SeasonNumber.ShouldBe(1);
fresh.LastSearchedByItem.ShouldBeEmpty();
}
[Fact]
public async Task WithoutCycleHistory_KeepsQueueBlocks()
{
// Arrange
SetupRadarr([Movie(1)]);
SeekerEvaluation evaluation = await EvaluateRadarrAsync(queue: [Queued(movieId: 1)]);
// Act
SeekerEvaluation fresh = evaluation.WithoutCycleHistory();
// Assert
fresh.QueuedUnits.ShouldBe(1);
fresh.Remaining.ShouldBeEmpty();
}
#endregion
#region Wanted endpoint support
[Fact]
public async Task EvaluateAsync_Radarr_WantedEndpointNotFound_FlagsWantedUnsupported()
{
// Arrange
SetupRadarr([Movie(1), Movie(2)]);
_radarrClient.GetMissingMoviesAsync(Arg.Any<ArrInstance>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound));
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync();
// Assert
evaluation.WantedUnsupported.ShouldBeTrue();
evaluation.Units.ShouldBeEmpty();
evaluation.AllLibraryIds.Count.ShouldBe(2);
evaluation.WithoutCycleHistory().WantedUnsupported.ShouldBeTrue();
}
[Fact]
public async Task EvaluateAsync_Radarr_WantedEndpointServerError_Throws()
{
// Arrange
SetupRadarr([Movie(1)]);
_radarrClient.GetMissingMoviesAsync(Arg.Any<ArrInstance>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Boom", null, HttpStatusCode.InternalServerError));
// Act & Assert
await Should.ThrowAsync<HttpRequestException>(EvaluateRadarrAsync());
}
#endregion
#region ReadRecentGrabsAsync
[Fact]
public async Task ReadRecentGrabsAsync_WindowOff_ReturnsEmptyWithoutCallingTheArr()
{
// Act
SeekerRecentGrabs grabs = await _sut.ReadRecentGrabsAsync(
InstanceConfig(_radarr, c => c.RecentGrabWindowHours = 0), _radarr, CancellationToken.None);
// Assert
grabs.MovieIds.ShouldBeEmpty();
grabs.Episodes.ShouldBeEmpty();
await _radarrClient.DidNotReceiveWithAnyArgs().GetRecentlyGrabbedMovieIdsAsync(default!, default, default);
}
[Fact]
public async Task ReadRecentGrabsAsync_Radarr_ReadsMovieIdsSinceTheWindowStart()
{
// Arrange
DateTimeOffset expectedSince = _timeProvider.GetUtcNow().AddHours(-12);
_radarrClient.GetRecentlyGrabbedMovieIdsAsync(_radarr, expectedSince, Arg.Any<CancellationToken>())
.Returns(new HashSet<long> { 4, 9 });
// Act
SeekerRecentGrabs grabs = await _sut.ReadRecentGrabsAsync(
InstanceConfig(_radarr, c => c.RecentGrabWindowHours = 12), _radarr, CancellationToken.None);
// Assert
grabs.MovieIds.ShouldBe([4L, 9L], ignoreOrder: true);
}
[Fact]
public async Task ReadRecentGrabsAsync_Sonarr_ReadsEpisodes()
{
// Arrange
_sonarrClient.GetRecentlyGrabbedEpisodesAsync(_sonarr, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(new HashSet<(long SeriesId, long SeasonNumber, long EpisodeId)> { (1, 2, 30) });
// Act
SeekerRecentGrabs grabs = await _sut.ReadRecentGrabsAsync(InstanceConfig(_sonarr), _sonarr, CancellationToken.None);
// Assert
grabs.Episodes.ShouldHaveSingleItem().ShouldBe((1L, 2L, 30L));
grabs.MovieIds.ShouldBeEmpty();
}
#endregion
}