From becb58b1470e72736d20c95f2cce1a826e5a43fc Mon Sep 17 00:00:00 2001 From: Flaminel Date: Wed, 21 Oct 2026 17:25:02 +0300 Subject: [PATCH] added seeker funnel and preview tests --- .../Seeker/SearchStatsControllerTests.cs | 67 +- .../Seeker/SeekerPreviewControllerTests.cs | 333 +++++++ .../Features/Jobs/SeekerTests.cs | 218 +++++ .../Seeker/SeekerUnitEvaluatorTests.cs | 924 ++++++++++++++++++ 4 files changed, 1541 insertions(+), 1 deletion(-) create mode 100644 code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerPreviewControllerTests.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs index d7a16319..07b0a9cc 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs @@ -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(); } diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerPreviewControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerPreviewControllerTests.cs new file mode 100644 index 00000000..843a36ab --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerPreviewControllerTests.cs @@ -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(); + _sonarrClient = Substitute.For(); + _arrClientFactory = Substitute.For(); + _arrQueueReader = Substitute.For(); + FakeTimeProvider timeProvider = new(Now); + + _arrClientFactory.GetClient(Arg.Any(), Arg.Any()).Returns(Substitute.For()); + _arrQueueReader.ReadAllAsync(Arg.Any(), Arg.Any()).Returns([]); + _radarrClient.GetAllTagsAsync(Arg.Any()).Returns([]); + _radarrClient.GetQualityProfilesAsync(Arg.Any()).Returns([]); + _radarrClient + .GetRecentlyGrabbedMovieIdsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new HashSet()); + _radarrClient + .GetCutoffUnmetMoviesAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([]); + + SeekerUnitEvaluator evaluator = new( + NullLogger.Instance, + _eventsContext, + _radarrClient, + _sonarrClient, + timeProvider); + + _controller = new SeekerPreviewController( + NullLogger.Instance, + _dataContext, + _eventsContext, + _arrClientFactory, + _arrQueueReader, + evaluator, + timeProvider); + ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller); + } + + public void Dispose() + { + _dataContext.Dispose(); + _eventsContext.Dispose(); + GC.SuppressFinalize(this); + } + + private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable 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 library = movies.ToList(); + _radarrClient.StreamAllMoviesAsync(Arg.Any(), Arg.Any()) + .Returns(_ => ToAsyncEnumerable(library)); + _radarrClient.GetMissingMoviesAsync(Arg.Any(), true, Arg.Any()) + .Returns(library.Where(m => !m.HasFile).ToList()); + _radarrClient.GetMissingMoviesAsync(Arg.Any(), false, Arg.Any()) + .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().Value.ShouldBeOfType(); + + private static int StatusOf(IActionResult result) => + result.ShouldBeOfType().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(), Arg.Any()).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(), Arg.Any()) + .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(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new HttpRequestException("Not found", null, System.Net.HttpStatusCode.NotFound)); + + IActionResult result = await _controller.GetPreview(radarr.Id, CancellationToken.None); + + StatusOf(result).ShouldBe(StatusCodes.Status502BadGateway); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs index 7bb63e8e..847907a7 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs @@ -2889,6 +2889,224 @@ public class SeekerTests : IDisposable #endregion + #region Funnel Counts + + private async Task<(ArrInstance Instance, IArrClient ArrClient)> SetupFunnelInstanceAsync( + InstanceType type, + Action? 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(); + arrClient.SearchItemAsync(Arg.Any(), Arg.Any()).Returns(100L); + _fixture.ArrClientFactory.GetClient(type, Arg.Any()).Returns(arrClient); + _fixture.ArrQueueReader.ReadAllAsync(arrClient, Arg.Any()).Returns([]); + + return (instance, arrClient); + } + + private void StubFunnelMovies(ArrInstance instance, List movies) + { + _radarrClient + .StreamAllMoviesAsync(instance, Arg.Any()) + .Returns(_ => ToAsyncEnumerable(movies)); + StubRadarrWanted(instance, movies); + } + + private void StubQueue(IArrClient arrClient, params QueueRecord[] records) + { + _fixture.ArrQueueReader + .ReadAllAsync(arrClient, Arg.Any()) + .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 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(), Arg.Any(), Arg.Any()) + .Returns(new HashSet { 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(), Arg.Any()) + .Returns(_ => ToAsyncEnumerable( + [ + 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(), Arg.Any(), Arg.Any()) + .Returns(new HashSet { 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] diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs new file mode 100644 index 00000000..02d744fb --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/SeekerUnitEvaluatorTests.cs @@ -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(); + _sonarrClient = Substitute.For(); + _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()).Returns([]); + _sonarrClient.GetAllTagsAsync(Arg.Any()).Returns([]); + _radarrClient.GetQualityProfilesAsync(Arg.Any()).Returns([]); + _sonarrClient.GetQualityProfilesAsync(Arg.Any()).Returns([]); + _radarrClient.GetCutoffUnmetMoviesAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns([]); + _sonarrClient.GetCutoffUnmetEpisodesAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns([]); + + _sut = new SeekerUnitEvaluator( + Substitute.For>(), + _eventsContext, + _radarrClient, + _sonarrClient, + _timeProvider); + } + + public void Dispose() + { + _dataContext.Dispose(); + _eventsContext.Dispose(); + GC.SuppressFinalize(this); + } + + private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable 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? 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? 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? 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 library, List? missing = null) + { + List wanted = missing ?? library.Where(m => !m.HasFile).ToList(); + + _radarrClient.StreamAllMoviesAsync(Arg.Any(), Arg.Any()) + .Returns(_ => ToAsyncEnumerable(library)); + _radarrClient.GetMissingMoviesAsync(Arg.Any(), true, Arg.Any()) + .Returns(wanted.Where(m => m.Monitored).ToList()); + _radarrClient.GetMissingMoviesAsync(Arg.Any(), false, Arg.Any()) + .Returns(wanted.Where(m => !m.Monitored).ToList()); + } + + private void SetupSonarr(List library, List missing) + { + _sonarrClient.StreamAllSeriesAsync(Arg.Any(), Arg.Any()) + .Returns(_ => ToAsyncEnumerable(library)); + _sonarrClient.GetMissingEpisodesAsync(Arg.Any(), true, Arg.Any()) + .Returns(missing.Where(e => e.Monitored).ToList()); + _sonarrClient.GetMissingEpisodesAsync(Arg.Any(), false, Arg.Any()) + .Returns(missing.Where(e => !e.Monitored).ToList()); + } + + private Task EvaluateRadarrAsync( + SeekerInstanceConfig? instanceConfig = null, + List? queue = null, + SeekerRecentGrabs? recentGrabs = null, + List? history = null) + { + return _sut.EvaluateAsync( + _config, + instanceConfig ?? InstanceConfig(_radarr), + _radarr, + queue ?? [], + recentGrabs ?? new SeekerRecentGrabs(), + history ?? [], + CancellationToken.None); + } + + private Task EvaluateSonarrAsync( + SeekerInstanceConfig? instanceConfig = null, + List? queue = null, + List? 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()).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()).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()).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()) + .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()) + .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()).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()).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()).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()).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 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 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 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 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 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 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(), Arg.Any(), Arg.Any()) + .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(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new HttpRequestException("Boom", null, HttpStatusCode.InternalServerError)); + + // Act & Assert + await Should.ThrowAsync(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()) + .Returns(new HashSet { 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(), Arg.Any()) + .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 +}