Files
Cleanuparr/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs
2026-03-25 22:48:57 +02:00

1686 lines
66 KiB
C#

using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Cleanuparr.Persistence.Models.State;
using Data.Models.Arr;
using Cleanuparr.Infrastructure.Hubs;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
using SeekerJob = Cleanuparr.Infrastructure.Features.Jobs.Seeker;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs;
[Collection(JobHandlerCollection.Name)]
public class SeekerTests : IDisposable
{
private readonly JobHandlerFixture _fixture;
private readonly Mock<ILogger<SeekerJob>> _logger;
private readonly Mock<IRadarrClient> _radarrClient;
private readonly Mock<ISonarrClient> _sonarrClient;
private readonly Mock<IDryRunInterceptor> _dryRunInterceptor;
private readonly Mock<IHostingEnvironment> _hostingEnvironment;
private readonly Mock<IHubContext<AppHub>> _hubContext;
public SeekerTests(JobHandlerFixture fixture)
{
_fixture = fixture;
_fixture.RecreateDataContext();
_fixture.ResetMocks();
_logger = new Mock<ILogger<SeekerJob>>();
_radarrClient = new Mock<IRadarrClient>();
_sonarrClient = new Mock<ISonarrClient>();
_dryRunInterceptor = new Mock<IDryRunInterceptor>();
_hostingEnvironment = new Mock<IHostingEnvironment>();
_hubContext = new Mock<IHubContext<AppHub>>();
// Default: hub context setup
var mockClients = new Mock<IHubClients>();
var mockClientProxy = new Mock<IClientProxy>();
mockClients.Setup(c => c.All).Returns(mockClientProxy.Object);
_hubContext.Setup(h => h.Clients).Returns(mockClients.Object);
// Default: development mode (skips jitter)
_hostingEnvironment.Setup(x => x.EnvironmentName).Returns("Development");
// Default: dry run disabled
_dryRunInterceptor.Setup(x => x.IsDryRunEnabled()).ReturnsAsync(false);
// Default: PublishSearchTriggered returns a Guid
_fixture.EventPublisher
.Setup(x => x.PublishSearchTriggered(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<IEnumerable<string>>(),
It.IsAny<SeekerSearchType>(),
It.IsAny<Guid?>()))
.ReturnsAsync(Guid.NewGuid());
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
private SeekerJob CreateSut()
{
return new SeekerJob(
_logger.Object,
_fixture.DataContext,
_radarrClient.Object,
_sonarrClient.Object,
_fixture.ArrClientFactory.Object,
_fixture.ArrQueueIterator.Object,
_fixture.EventPublisher.Object,
_dryRunInterceptor.Object,
_hostingEnvironment.Object,
_fixture.TimeProvider,
_hubContext.Object
);
}
#region ExecuteAsync Tests
[Fact]
public async Task ExecuteAsync_WhenSearchDisabled_ReturnsEarly()
{
// Arrange — disable search in the seeded config
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = false;
await _fixture.DataContext.SaveChangesAsync();
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — no search triggered, no arr client interaction
_fixture.ArrClientFactory.Verify(
x => x.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()),
Times.Never);
_fixture.EventPublisher.Verify(
x => x.PublishSearchTriggered(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<IEnumerable<string>>(),
It.IsAny<SeekerSearchType>(),
It.IsAny<Guid?>()),
Times.Never);
}
[Fact]
public async Task ExecuteAsync_WhenProactiveSearchDisabled_SkipsProactiveSearch()
{
// Arrange — search enabled but proactive disabled, no queue items
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = false;
await _fixture.DataContext.SaveChangesAsync();
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — no arr client interaction (no replacement items, proactive disabled)
_fixture.ArrClientFactory.Verify(
x => x.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()),
Times.Never);
_fixture.EventPublisher.Verify(
x => x.PublishSearchTriggered(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<IEnumerable<string>>(),
It.IsAny<SeekerSearchType>(),
It.IsAny<Guid?>()),
Times.Never);
}
[Fact]
public async Task ExecuteAsync_WhenReplacementItemExists_ProcessesReplacementFirst()
{
// Arrange
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SearchQueue.Add(new SearchQueueItem
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
ItemId = 42,
Title = "Test Movie",
CreatedAt = DateTime.UtcNow
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
mockArrClient
.Setup(x => x.SearchItemsAsync(It.IsAny<ArrInstance>(), It.IsAny<HashSet<SearchItem>>()))
.ReturnsAsync([100L]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — search was triggered for the replacement item
mockArrClient.Verify(
x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()),
Times.Once);
_fixture.EventPublisher.Verify(
x => x.PublishSearchTriggered(
radarrInstance.Name,
1,
It.Is<IEnumerable<string>>(items => items.Contains("Test Movie")),
SeekerSearchType.Replacement,
It.IsAny<Guid?>()),
Times.Once);
// Replacement item should be removed from the queue
var remaining = await _fixture.DataContext.SearchQueue.CountAsync();
Assert.Equal(0, remaining);
}
[Fact]
public async Task ExecuteAsync_WhenDryRunEnabled_DoesNotRemoveFromSearchQueue()
{
// Arrange
_dryRunInterceptor.Setup(x => x.IsDryRunEnabled()).ReturnsAsync(true);
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SearchQueue.Add(new SearchQueueItem
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
ItemId = 42,
Title = "Test Movie",
CreatedAt = DateTime.UtcNow
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
mockArrClient
.Setup(x => x.SearchItemsAsync(It.IsAny<ArrInstance>(), It.IsAny<HashSet<SearchItem>>()))
.ReturnsAsync([100L]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — search was triggered but item stays in queue
mockArrClient.Verify(
x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()),
Times.Once);
var remaining = await _fixture.DataContext.SearchQueue.CountAsync();
Assert.Equal(1, remaining);
}
[Fact]
public async Task ExecuteAsync_WhenActiveDownloadLimitReached_SkipsInstance()
{
// Arrange — enable proactive search with a Radarr instance
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
// Add a SeekerInstanceConfig with ActiveDownloadLimit = 2
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true,
ActiveDownloadLimit = 2
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
// Return 2 queue items with SizeLeft > 0 (actively downloading), which meets the limit
QueueRecord[] activeDownloads =
[
new() { Id = 1, Title = "Download 1", DownloadId = "hash1", Protocol = "torrent", SizeLeft = 1000, MovieId = 10, TrackedDownloadState = "downloading" },
new() { Id = 2, Title = "Download 2", DownloadId = "hash2", Protocol = "torrent", SizeLeft = 2000, MovieId = 20, TrackedDownloadState = "downloading" }
];
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.Returns<IArrClient, ArrInstance, Func<IReadOnlyList<QueueRecord>, Task>>((_, _, action) => action(activeDownloads));
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — no search triggered because active downloads >= limit
mockArrClient.Verify(
x => x.SearchItemsAsync(It.IsAny<ArrInstance>(), It.IsAny<HashSet<SearchItem>>()),
Times.Never);
_fixture.EventPublisher.Verify(
x => x.PublishSearchTriggered(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<IEnumerable<string>>(),
SeekerSearchType.Proactive,
It.IsAny<Guid?>()),
Times.Never);
}
[Fact]
public async Task ExecuteAsync_Radarr_ExcludesMoviesAlreadyInQueue()
{
// Arrange — proactive search enabled with 3 movies, one already in queue
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
// Movie 2 is already in the download queue
QueueRecord[] queuedRecords =
[
new() { Id = 1, Title = "Movie 2 Download", DownloadId = "hash1", Protocol = "torrent", SizeLeft = 1000, MovieId = 2, TrackedDownloadState = "downloading" }
];
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.Returns<IArrClient, ArrInstance, Func<IReadOnlyList<QueueRecord>, Task>>((_, _, action) => action(queuedRecords));
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync(
[
new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] },
new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] },
new SearchableMovie { Id = 3, Title = "Movie 3", Status = "released", Monitored = true, Tags = [] }
]);
HashSet<SearchItem>? capturedSearchItems = null;
mockArrClient
.Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()))
.Callback<ArrInstance, HashSet<SearchItem>>((_, items) => capturedSearchItems = items)
.ReturnsAsync([100L]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — search was triggered, but NOT for movie 2
mockArrClient.Verify(
x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()),
Times.Once);
Assert.NotNull(capturedSearchItems);
Assert.DoesNotContain(capturedSearchItems, item => item.Id == 2);
}
[Fact]
public async Task ExecuteAsync_Radarr_DoesNotExcludeImportFailedItems()
{
// Arrange — movie in queue with importFailed state should still be searchable
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
// Movie 1 is in queue but with importFailed state — should NOT be excluded
QueueRecord[] queuedRecords =
[
new() { Id = 1, Title = "Movie 1 Download", DownloadId = "hash1", Protocol = "torrent", SizeLeft = 0, MovieId = 1, TrackedDownloadState = "importFailed" }
];
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.Returns<IArrClient, ArrInstance, Func<IReadOnlyList<QueueRecord>, Task>>((_, _, action) => action(queuedRecords));
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync(
[
new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }
]);
mockArrClient
.Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()))
.ReturnsAsync([100L]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — search was triggered for movie 1 (importFailed does not exclude)
mockArrClient.Verify(
x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()),
Times.Once);
}
[Fact]
public async Task ExecuteAsync_Sonarr_ExcludesSeasonsAlreadyInQueue()
{
// Arrange — series with 2 seasons, season 1 in queue
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
await _fixture.DataContext.SaveChangesAsync();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = sonarrInstance.Id,
ArrInstance = sonarrInstance,
Enabled = true
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
// Season 1 of series 10 is in the queue
QueueRecord[] queuedRecords =
[
new() { Id = 1, Title = "Series Episode", DownloadId = "hash1", Protocol = "torrent", SizeLeft = 1000, SeriesId = 10, SeasonNumber = 1, TrackedDownloadState = "downloading" }
];
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.Returns<IArrClient, ArrInstance, Func<IReadOnlyList<QueueRecord>, Task>>((_, _, action) => action(queuedRecords));
_sonarrClient
.Setup(x => x.GetAllSeriesAsync(It.IsAny<ArrInstance>()))
.ReturnsAsync(
[
new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 20, EpisodeFileCount = 10 } }
]);
// Use dates relative to FakeTimeProvider (defaults to Jan 1, 2000)
var pastDate = _fixture.TimeProvider.GetUtcNow().UtcDateTime.AddDays(-30);
_sonarrClient
.Setup(x => x.GetEpisodesAsync(It.IsAny<ArrInstance>(), 10))
.ReturnsAsync(
[
new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false },
new SearchableEpisode { Id = 101, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false }
]);
SeriesSearchItem? capturedSearchItem = null;
mockArrClient
.Setup(x => x.SearchItemsAsync(It.IsAny<ArrInstance>(), It.IsAny<HashSet<SearchItem>>()))
.Callback<ArrInstance, HashSet<SearchItem>>((_, items) => capturedSearchItem = items.OfType<SeriesSearchItem>().FirstOrDefault())
.ReturnsAsync([100L]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — season 2 was searched (season 1 excluded because it's in queue)
mockArrClient.Verify(
x => x.SearchItemsAsync(It.IsAny<ArrInstance>(), It.IsAny<HashSet<SearchItem>>()),
Times.Once);
Assert.NotNull(capturedSearchItem);
Assert.Equal(2, capturedSearchItem.Id); // Season 2
Assert.Equal(10, capturedSearchItem.SeriesId);
}
[Fact]
public async Task ExecuteAsync_QueueFetchFails_ProceedsWithoutFiltering()
{
// Arrange — queue fetch throws, but search should still proceed
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
// Queue fetch fails
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.ThrowsAsync(new HttpRequestException("Connection refused"));
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync(
[
new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }
]);
mockArrClient
.Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()))
.ReturnsAsync([100L]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — search still proceeded despite queue fetch failure
mockArrClient.Verify(
x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()),
Times.Once);
}
#endregion
#region Radarr Proactive Search Filters
[Fact]
public async Task ExecuteAsync_Radarr_MonitoredOnlyTrue_ExcludesUnmonitoredMovies()
{
// Arrange — MonitoredOnly is true by default in seed data
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
config.MonitoredOnly = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.Returns(Task.CompletedTask);
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync(
[
new SearchableMovie { Id = 1, Title = "Monitored Movie", Status = "released", Monitored = true, Tags = [] },
new SearchableMovie { Id = 2, Title = "Unmonitored Movie", Status = "released", Monitored = false, Tags = [] }
]);
HashSet<SearchItem>? capturedSearchItems = null;
mockArrClient
.Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()))
.Callback<ArrInstance, HashSet<SearchItem>>((_, items) => capturedSearchItems = items)
.ReturnsAsync([100L]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — only monitored movie searched
Assert.NotNull(capturedSearchItems);
Assert.DoesNotContain(capturedSearchItems, item => item.Id == 2);
}
[Fact]
public async Task ExecuteAsync_Radarr_SkipTags_ExcludesMoviesWithMatchingTags()
{
// Arrange
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
config.MonitoredOnly = false;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true,
SkipTags = ["no-search"]
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.Returns(Task.CompletedTask);
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync(
[
new SearchableMovie { Id = 1, Title = "Normal Movie", Status = "released", Monitored = true, Tags = ["movies"] },
new SearchableMovie { Id = 2, Title = "Skipped Movie", Status = "released", Monitored = true, Tags = ["no-search", "movies"] }
]);
HashSet<SearchItem>? capturedSearchItems = null;
mockArrClient
.Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()))
.Callback<ArrInstance, HashSet<SearchItem>>((_, items) => capturedSearchItems = items)
.ReturnsAsync([100L]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — movie with skip tag excluded
Assert.NotNull(capturedSearchItems);
Assert.DoesNotContain(capturedSearchItems, item => item.Id == 2);
Assert.Contains(capturedSearchItems, item => item.Id == 1);
}
[Fact]
public async Task ExecuteAsync_Radarr_UseCutoff_SkipsCutoffMetMovies()
{
// Arrange — enable cutoff filtering: only movies with QualityCutoffNotMet should be searched
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
config.MonitoredOnly = false;
config.UseCutoff = true;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.Returns(Task.CompletedTask);
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync(
[
new SearchableMovie { Id = 1, Title = "Missing Movie", Status = "released", Monitored = true, HasFile = false, Tags = [] },
new SearchableMovie { Id = 2, Title = "Cutoff Met", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 200, QualityCutoffNotMet = false }, Tags = [] },
new SearchableMovie { Id = 3, Title = "Cutoff Not Met", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 300, QualityCutoffNotMet = true }, Tags = [] }
]);
HashSet<SearchItem>? capturedSearchItems = null;
mockArrClient
.Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()))
.Callback<ArrInstance, HashSet<SearchItem>>((_, items) => capturedSearchItems = items)
.ReturnsAsync([100L]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — movie with cutoff met should be excluded; missing + cutoff not met should be eligible
Assert.NotNull(capturedSearchItems);
Assert.DoesNotContain(capturedSearchItems, item => item.Id == 2);
}
[Fact]
public async Task ExecuteAsync_Radarr_CycleComplete_StartsNewCycle()
{
// Arrange — all candidate movies are already in search history for current cycle
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
config.MonitoredOnly = false;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
var currentCycleId = Guid.NewGuid();
var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime;
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true,
CurrentCycleId = currentCycleId
});
// Add history entries for both movies in the current cycle
// Use dates relative to FakeTimeProvider and far enough back to exceed default MinCycleTimeDays
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 1,
ItemType = InstanceType.Radarr,
CycleId = currentCycleId,
LastSearchedAt = now.AddDays(-10),
ItemTitle = "Movie 1"
});
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 2,
ItemType = InstanceType.Radarr,
CycleId = currentCycleId,
LastSearchedAt = now.AddDays(-10),
ItemTitle = "Movie 2"
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.Returns(Task.CompletedTask);
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync(
[
new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] },
new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] }
]);
mockArrClient
.Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()))
.ReturnsAsync([100L]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — search was triggered (new cycle started) and the CycleId changed
mockArrClient.Verify(
x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()),
Times.Once);
var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs
.FirstAsync(s => s.ArrInstanceId == radarrInstance.Id);
Assert.NotEqual(currentCycleId, instanceConfig.CurrentCycleId);
}
#endregion
#region Round-Robin
[Fact]
public async Task ExecuteAsync_RoundRobin_SelectsOldestProcessedInstance()
{
// Arrange — two Radarr instances, round-robin should pick the oldest processed one
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
config.UseRoundRobin = true;
config.MonitoredOnly = false;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance1 = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext, "http://radarr1:7878");
var radarrInstance2 = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext, "http://radarr2:7878");
// Instance 1 was processed recently, instance 2 was processed long ago
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance1.Id,
ArrInstance = radarrInstance1,
Enabled = true,
LastProcessedAt = DateTime.UtcNow
});
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance2.Id,
ArrInstance = radarrInstance2,
Enabled = true,
LastProcessedAt = DateTime.UtcNow.AddHours(-24)
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.Returns(Task.CompletedTask);
// Return movies for both instances — only instance 2 should be called
_radarrClient
.Setup(x => x.GetAllMoviesAsync(It.Is<ArrInstance>(a => a.Id == radarrInstance2.Id)))
.ReturnsAsync([new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }]);
mockArrClient
.Setup(x => x.SearchItemsAsync(It.IsAny<ArrInstance>(), It.IsAny<HashSet<SearchItem>>()))
.ReturnsAsync([100L]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — instance 2 (oldest) was processed, verified by GetAllMoviesAsync being called for it
_radarrClient.Verify(
x => x.GetAllMoviesAsync(It.Is<ArrInstance>(a => a.Id == radarrInstance2.Id)),
Times.Once);
_radarrClient.Verify(
x => x.GetAllMoviesAsync(It.Is<ArrInstance>(a => a.Id == radarrInstance1.Id)),
Times.Never);
}
#endregion
#region Replacement Edge Cases
[Fact]
public async Task ExecuteAsync_ReplacementItem_MissingArrInstance_RemovesFromQueue()
{
// Arrange — replacement item references an instance that no longer exists
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
await _fixture.DataContext.SaveChangesAsync();
// Add a valid instance just so we can create the queue item with its ID, then detach it
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
var instanceId = radarrInstance.Id;
_fixture.DataContext.SearchQueue.Add(new SearchQueueItem
{
ArrInstanceId = instanceId,
ArrInstance = radarrInstance,
ItemId = 42,
Title = "Orphaned Movie",
CreatedAt = DateTime.UtcNow
});
await _fixture.DataContext.SaveChangesAsync();
// Now remove the arr instance to simulate deletion
_fixture.DataContext.ArrInstances.Remove(radarrInstance);
await _fixture.DataContext.SaveChangesAsync();
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — queue item should be cleaned up
var remaining = await _fixture.DataContext.SearchQueue.CountAsync();
Assert.Equal(0, remaining);
// No search should have been triggered
_fixture.EventPublisher.Verify(
x => x.PublishSearchTriggered(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<IEnumerable<string>>(),
It.IsAny<SeekerSearchType>(),
It.IsAny<Guid?>()),
Times.Never);
}
#endregion
#region MinCycleTimeDays
[Fact]
public async Task ExecuteAsync_Radarr_CycleComplete_WaitsForMinCycleTime()
{
// Arrange — all items searched but MinCycleTimeDays has not elapsed
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
config.MonitoredOnly = false;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
var currentCycleId = Guid.NewGuid();
var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime;
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true,
CurrentCycleId = currentCycleId,
MinCycleTimeDays = 7,
TotalEligibleItems = 2
});
// Cycle started 2 days ago — within the 7-day minimum
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 1,
ItemType = InstanceType.Radarr,
CycleId = currentCycleId,
LastSearchedAt = now.AddDays(-2),
ItemTitle = "Movie 1"
});
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 2,
ItemType = InstanceType.Radarr,
CycleId = currentCycleId,
LastSearchedAt = now.AddDays(-1),
ItemTitle = "Movie 2"
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.Returns(Task.CompletedTask);
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync(
[
new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] },
new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] }
]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — no search triggered, cycle not reset
mockArrClient.Verify(
x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()),
Times.Never);
var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs
.FirstAsync(s => s.ArrInstanceId == radarrInstance.Id);
Assert.Equal(currentCycleId, instanceConfig.CurrentCycleId);
}
[Fact]
public async Task ExecuteAsync_Radarr_CycleComplete_RestartsAfterMinCycleTimeElapsed()
{
// Arrange — all items searched and MinCycleTimeDays has elapsed
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
config.MonitoredOnly = false;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
var currentCycleId = Guid.NewGuid();
var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime;
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true,
CurrentCycleId = currentCycleId,
MinCycleTimeDays = 7,
TotalEligibleItems = 2
});
// Cycle started 10 days ago — beyond the 7-day minimum
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 1,
ItemType = InstanceType.Radarr,
CycleId = currentCycleId,
LastSearchedAt = now.AddDays(-10),
ItemTitle = "Movie 1"
});
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 2,
ItemType = InstanceType.Radarr,
CycleId = currentCycleId,
LastSearchedAt = now.AddDays(-8),
ItemTitle = "Movie 2"
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.Returns(Task.CompletedTask);
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync(
[
new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] },
new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] }
]);
mockArrClient
.Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()))
.ReturnsAsync([100L]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — search was triggered, cycle was reset
mockArrClient.Verify(
x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()),
Times.Once);
var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs
.FirstAsync(s => s.ArrInstanceId == radarrInstance.Id);
Assert.NotEqual(currentCycleId, instanceConfig.CurrentCycleId);
}
[Fact]
public async Task ExecuteAsync_Radarr_CycleComplete_NoCycleHistory_StartsNewCycle()
{
// Arrange — cycle complete but no history (cycleStartedAt is null), should not block
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
config.MonitoredOnly = false;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
var currentCycleId = Guid.NewGuid();
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true,
CurrentCycleId = currentCycleId,
MinCycleTimeDays = 30
});
// History uses a DIFFERENT CycleId — current cycle has no history entries
var oldCycleId = Guid.NewGuid();
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 1,
ItemType = InstanceType.Radarr,
CycleId = oldCycleId,
LastSearchedAt = DateTime.UtcNow.AddDays(-60),
ItemTitle = "Movie 1"
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.Returns(Task.CompletedTask);
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync(
[
new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }
]);
mockArrClient
.Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()))
.ReturnsAsync([100L]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — search was triggered (item not in current cycle, so it's selected directly)
mockArrClient.Verify(
x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()),
Times.Once);
}
[Fact]
public async Task ExecuteAsync_Sonarr_CycleComplete_WaitsForMinCycleTime()
{
// Arrange — all series seasons searched but MinCycleTimeDays not elapsed
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
config.MonitoredOnly = false;
await _fixture.DataContext.SaveChangesAsync();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var currentCycleId = Guid.NewGuid();
var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime;
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = sonarrInstance.Id,
ArrInstance = sonarrInstance,
Enabled = true,
CurrentCycleId = currentCycleId,
MinCycleTimeDays = 7,
TotalEligibleItems = 1
});
// Series history — season already searched in current cycle (started 2 days ago)
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
{
ArrInstanceId = sonarrInstance.Id,
ExternalItemId = 10,
ItemType = InstanceType.Sonarr,
SeasonNumber = 1,
CycleId = currentCycleId,
LastSearchedAt = now.AddDays(-2),
ItemTitle = "Test Series"
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.Returns(Task.CompletedTask);
_sonarrClient
.Setup(x => x.GetAllSeriesAsync(sonarrInstance))
.ReturnsAsync(
[
new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 5 } }
]);
var pastDate = now.AddDays(-30);
_sonarrClient
.Setup(x => x.GetEpisodesAsync(It.IsAny<ArrInstance>(), 10))
.ReturnsAsync(
[
new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, HasFile = false, AirDateUtc = pastDate }
]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — no search triggered, cycle not reset
mockArrClient.Verify(
x => x.SearchItemsAsync(sonarrInstance, It.IsAny<HashSet<SearchItem>>()),
Times.Never);
var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs
.FirstAsync(s => s.ArrInstanceId == sonarrInstance.Id);
Assert.Equal(currentCycleId, instanceConfig.CurrentCycleId);
}
[Fact]
public async Task ExecuteAsync_Sonarr_CycleComplete_RestartsAfterMinCycleTimeElapsed()
{
// Arrange — all series seasons searched and MinCycleTimeDays has elapsed
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
config.MonitoredOnly = false;
await _fixture.DataContext.SaveChangesAsync();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var currentCycleId = Guid.NewGuid();
var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime;
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = sonarrInstance.Id,
ArrInstance = sonarrInstance,
Enabled = true,
CurrentCycleId = currentCycleId,
MinCycleTimeDays = 7,
TotalEligibleItems = 1
});
// Series history — season already searched in current cycle (started 10 days ago)
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
{
ArrInstanceId = sonarrInstance.Id,
ExternalItemId = 10,
ItemType = InstanceType.Sonarr,
SeasonNumber = 1,
CycleId = currentCycleId,
LastSearchedAt = now.AddDays(-10),
ItemTitle = "Test Series"
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.Returns(Task.CompletedTask);
_sonarrClient
.Setup(x => x.GetAllSeriesAsync(sonarrInstance))
.ReturnsAsync(
[
new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 5 } }
]);
var pastDate = now.AddDays(-30);
_sonarrClient
.Setup(x => x.GetEpisodesAsync(It.IsAny<ArrInstance>(), 10))
.ReturnsAsync(
[
new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, HasFile = false, AirDateUtc = pastDate }
]);
mockArrClient
.Setup(x => x.SearchItemsAsync(sonarrInstance, It.IsAny<HashSet<SearchItem>>()))
.ReturnsAsync([100L]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — search was triggered, cycle was reset
mockArrClient.Verify(
x => x.SearchItemsAsync(sonarrInstance, It.IsAny<HashSet<SearchItem>>()),
Times.Once);
var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs
.FirstAsync(s => s.ArrInstanceId == sonarrInstance.Id);
Assert.NotEqual(currentCycleId, instanceConfig.CurrentCycleId);
}
[Fact]
public async Task ExecuteAsync_RoundRobin_SkipsInstanceWaitingForMinCycleTime()
{
// Arrange — two Radarr instances: one waiting for MinCycleTimeDays, the other has work to do
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
config.UseRoundRobin = true;
config.MonitoredOnly = false;
await _fixture.DataContext.SaveChangesAsync();
var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime;
// Instance A: cycle complete, waiting for MinCycleTimeDays (oldest LastProcessedAt — would be picked first)
var instanceA = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext, "http://radarr-a:7878");
var cycleIdA = Guid.NewGuid();
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = instanceA.Id,
ArrInstance = instanceA,
Enabled = true,
CurrentCycleId = cycleIdA,
MinCycleTimeDays = 30,
TotalEligibleItems = 1,
LastProcessedAt = now.AddDays(-5) // Oldest — round-robin would pick this first
});
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
{
ArrInstanceId = instanceA.Id,
ExternalItemId = 1,
ItemType = InstanceType.Radarr,
CycleId = cycleIdA,
LastSearchedAt = now.AddDays(-2), // Cycle started 2 days ago, MinCycleTimeDays=30
ItemTitle = "Movie A"
});
// Instance B: has work to do (newer LastProcessedAt)
var instanceB = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext, "http://radarr-b:7878");
var cycleIdB = Guid.NewGuid();
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = instanceB.Id,
ArrInstance = instanceB,
Enabled = true,
CurrentCycleId = cycleIdB,
MinCycleTimeDays = 5,
TotalEligibleItems = 1,
LastProcessedAt = now.AddDays(-1)
});
// No history for instance B — it hasn't searched anything yet
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.Returns(Task.CompletedTask);
_radarrClient
.Setup(x => x.GetAllMoviesAsync(instanceB))
.ReturnsAsync(
[
new SearchableMovie { Id = 10, Title = "Movie B", Status = "released", Monitored = true, Tags = [] }
]);
mockArrClient
.Setup(x => x.SearchItemsAsync(instanceB, It.IsAny<HashSet<SearchItem>>()))
.ReturnsAsync([100L]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — Instance B was processed (not A which was waiting)
_radarrClient.Verify(
x => x.GetAllMoviesAsync(instanceB),
Times.Once);
_radarrClient.Verify(
x => x.GetAllMoviesAsync(instanceA),
Times.Never);
}
#endregion
#region Post-Release Grace Period Tests
[Fact]
public async Task ExecuteAsync_Radarr_GracePeriod_ExcludesRecentlyReleasedMovies()
{
// Arrange — grace period of 6 hours, one movie released 2 hours ago (within grace), one released 10 hours ago (past grace)
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
config.PostReleaseGraceHours = 6;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.Returns<IArrClient, ArrInstance, Func<IReadOnlyList<QueueRecord>, Task>>((_, _, action) => action([]));
var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime;
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync(
[
new SearchableMovie { Id = 1, Title = "Recent Movie", Status = "released", Monitored = true, Tags = [], DigitalRelease = now.AddHours(-2) },
new SearchableMovie { Id = 2, Title = "Old Movie", Status = "released", Monitored = true, Tags = [], DigitalRelease = now.AddHours(-10) }
]);
HashSet<SearchItem>? capturedSearchItems = null;
mockArrClient
.Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()))
.Callback<ArrInstance, HashSet<SearchItem>>((_, items) => capturedSearchItems = items)
.ReturnsAsync([100L]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — only movie 2 (past grace) should be searched
mockArrClient.Verify(
x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()),
Times.Once);
Assert.NotNull(capturedSearchItems);
Assert.Single(capturedSearchItems);
Assert.Contains(capturedSearchItems, item => item.Id == 2);
Assert.DoesNotContain(capturedSearchItems, item => item.Id == 1);
}
[Fact]
public async Task ExecuteAsync_Radarr_GracePeriodZero_DoesNotFilterMovies()
{
// Arrange — grace period of 0 (disabled)
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
config.PostReleaseGraceHours = 0;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.Returns<IArrClient, ArrInstance, Func<IReadOnlyList<QueueRecord>, Task>>((_, _, action) => action([]));
var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime;
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync(
[
new SearchableMovie { Id = 1, Title = "Just Released", Status = "released", Monitored = true, Tags = [], DigitalRelease = now.AddMinutes(-5) }
]);
mockArrClient
.Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()))
.ReturnsAsync([100L]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — movie should be searched (grace period disabled)
mockArrClient.Verify(
x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()),
Times.Once);
}
[Fact]
public async Task ExecuteAsync_Radarr_GracePeriod_NoReleaseDates_TreatsAsReleased()
{
// Arrange — movie with no release date info should not be filtered by grace period
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
config.PostReleaseGraceHours = 6;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.Returns<IArrClient, ArrInstance, Func<IReadOnlyList<QueueRecord>, Task>>((_, _, action) => action([]));
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync(
[
new SearchableMovie { Id = 1, Title = "No Dates Movie", Status = "released", Monitored = true, Tags = [] }
]);
mockArrClient
.Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()))
.ReturnsAsync([100L]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — movie should be searched (no dates = treated as released)
mockArrClient.Verify(
x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()),
Times.Once);
}
[Fact]
public async Task ExecuteAsync_Sonarr_GracePeriod_ExcludesRecentlyAiredEpisodes()
{
// Arrange — grace period of 6 hours, one episode aired 2 hours ago (within grace), one aired 10 hours ago (past grace)
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
config.PostReleaseGraceHours = 6;
await _fixture.DataContext.SaveChangesAsync();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = sonarrInstance.Id,
ArrInstance = sonarrInstance,
Enabled = true
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.Returns<IArrClient, ArrInstance, Func<IReadOnlyList<QueueRecord>, Task>>((_, _, action) => action([]));
_sonarrClient
.Setup(x => x.GetAllSeriesAsync(It.IsAny<ArrInstance>()))
.ReturnsAsync(
[
new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 8 } }
]);
var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime;
_sonarrClient
.Setup(x => x.GetEpisodesAsync(It.IsAny<ArrInstance>(), 10))
.ReturnsAsync(
[
new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = now.AddHours(-2), HasFile = false },
new SearchableEpisode { Id = 101, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, AirDateUtc = now.AddHours(-10), HasFile = false }
]);
SeriesSearchItem? capturedSearchItem = null;
mockArrClient
.Setup(x => x.SearchItemsAsync(It.IsAny<ArrInstance>(), It.IsAny<HashSet<SearchItem>>()))
.Callback<ArrInstance, HashSet<SearchItem>>((_, items) => capturedSearchItem = items.OfType<SeriesSearchItem>().FirstOrDefault())
.ReturnsAsync([100L]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — only season 2 should be searched (season 1's episode is within grace period)
mockArrClient.Verify(
x => x.SearchItemsAsync(It.IsAny<ArrInstance>(), It.IsAny<HashSet<SearchItem>>()),
Times.Once);
Assert.NotNull(capturedSearchItem);
Assert.Equal(2, capturedSearchItem.Id); // Season 2
}
[Fact]
public async Task ExecuteAsync_Radarr_GracePeriod_UsesReleaseDateFallbackOrder()
{
// Arrange — movie with only PhysicalRelease (no DigitalRelease), within grace period
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
config.PostReleaseGraceHours = 6;
await _fixture.DataContext.SaveChangesAsync();
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true
});
await _fixture.DataContext.SaveChangesAsync();
var mockArrClient = new Mock<IArrClient>();
_fixture.ArrQueueIterator
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
.Returns<IArrClient, ArrInstance, Func<IReadOnlyList<QueueRecord>, Task>>((_, _, action) => action([]));
var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime;
_radarrClient
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
.ReturnsAsync(
[
// DigitalRelease is null, PhysicalRelease is 2h ago (within grace)
new SearchableMovie { Id = 1, Title = "Physical Only", Status = "released", Monitored = true, Tags = [], PhysicalRelease = now.AddHours(-2) },
// DigitalRelease is 10h ago (past grace), PhysicalRelease is 2h ago — DigitalRelease takes precedence
new SearchableMovie { Id = 2, Title = "Digital First", Status = "released", Monitored = true, Tags = [], DigitalRelease = now.AddHours(-10), PhysicalRelease = now.AddHours(-2) }
]);
HashSet<SearchItem>? capturedSearchItems = null;
mockArrClient
.Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()))
.Callback<ArrInstance, HashSet<SearchItem>>((_, items) => capturedSearchItems = items)
.ReturnsAsync([100L]);
_fixture.ArrClientFactory
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
.Returns(mockArrClient.Object);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert — movie 1 excluded (PhysicalRelease within grace), movie 2 included (DigitalRelease past grace)
mockArrClient.Verify(
x => x.SearchItemsAsync(radarrInstance, It.IsAny<HashSet<SearchItem>>()),
Times.Once);
Assert.NotNull(capturedSearchItems);
Assert.Single(capturedSearchItems);
Assert.Contains(capturedSearchItems, item => item.Id == 2);
Assert.DoesNotContain(capturedSearchItems, item => item.Id == 1);
}
#endregion
}