mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 10:48:49 -04:00
added seeker funnel and preview tests
This commit is contained in:
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]
|
||||
|
||||
+924
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user