mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-03-30 03:53:00 -04:00
added more tests
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
@@ -578,4 +579,220 @@ public class EventPublisherTests : IDisposable
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PublishSearchTriggered Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_SavesEventWithCorrectType()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Radarr-1", 2, ["Movie A", "Movie B"], SeekerSearchType.Proactive);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(EventType.SearchTriggered, savedEvent.EventType);
|
||||
Assert.Equal(EventSeverity.Information, savedEvent.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_SetsSearchStatusToPending()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(SearchCommandStatus.Pending, savedEvent.SearchStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_SetsCycleRunId()
|
||||
{
|
||||
// Arrange
|
||||
var cycleRunId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive, cycleRunId);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(cycleRunId, savedEvent.CycleRunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_ReturnsEventId()
|
||||
{
|
||||
// Act
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(Guid.Empty, eventId);
|
||||
var savedEvent = await _context.Events.FindAsync(eventId);
|
||||
Assert.NotNull(savedEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_SerializesItemsAndSearchTypeToData()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Sonarr-1", 2, ["Series A", "Series B"], SeekerSearchType.Replacement);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("Series A", savedEvent.Data);
|
||||
Assert.Contains("Series B", savedEvent.Data);
|
||||
Assert.Contains("Replacement", savedEvent.Data);
|
||||
Assert.Contains("Sonarr-1", savedEvent.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_NotifiesSignalRClients()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
|
||||
// Assert
|
||||
_clientProxyMock.Verify(c => c.SendCoreAsync(
|
||||
"EventReceived",
|
||||
It.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_SendsNotification()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Radarr-1", 2, ["Movie A", "Movie B"], SeekerSearchType.Proactive);
|
||||
|
||||
// Assert
|
||||
_notificationPublisherMock.Verify(
|
||||
n => n.NotifySearchTriggered("Radarr-1", 2, It.IsAny<IEnumerable<string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_TruncatesDisplayForMoreThan5Items()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[] { "Item 1", "Item 2", "Item 3", "Item 4", "Item 5", "Item 6", "Item 7" };
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Radarr-1", 7, items, SeekerSearchType.Proactive);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Contains("+2 more", savedEvent.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PublishSearchCompleted Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_UpdatesEventStatus()
|
||||
{
|
||||
// Arrange — create a search event first
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed);
|
||||
|
||||
// Assert
|
||||
var updatedEvent = await _context.Events.FindAsync(eventId);
|
||||
Assert.NotNull(updatedEvent);
|
||||
Assert.Equal(SearchCommandStatus.Completed, updatedEvent.SearchStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_SetsCompletedAt()
|
||||
{
|
||||
// Arrange
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed);
|
||||
|
||||
// Assert
|
||||
var updatedEvent = await _context.Events.FindAsync(eventId);
|
||||
Assert.NotNull(updatedEvent);
|
||||
Assert.NotNull(updatedEvent.CompletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_MergesResultDataIntoExistingData()
|
||||
{
|
||||
// Arrange
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
|
||||
var resultData = new { GrabbedItems = new[] { new { Title = "Movie A (2024)", Status = "downloading" } } };
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, resultData);
|
||||
|
||||
// Assert
|
||||
var updatedEvent = await _context.Events.FindAsync(eventId);
|
||||
Assert.NotNull(updatedEvent);
|
||||
Assert.NotNull(updatedEvent.Data);
|
||||
// Original data should still be present
|
||||
Assert.Contains("Movie A", updatedEvent.Data);
|
||||
// Merged result data should be present
|
||||
Assert.Contains("GrabbedItems", updatedEvent.Data);
|
||||
Assert.Contains("Movie A (2024)", updatedEvent.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_WithNullResultData_DoesNotModifyData()
|
||||
{
|
||||
// Arrange
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
var originalEvent = await _context.Events.FindAsync(eventId);
|
||||
string? originalData = originalEvent!.Data;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed);
|
||||
|
||||
// Assert
|
||||
var updatedEvent = await _context.Events.FindAsync(eventId);
|
||||
Assert.NotNull(updatedEvent);
|
||||
Assert.Equal(originalData, updatedEvent.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_EventNotFound_LogsWarningAndReturns()
|
||||
{
|
||||
// Act — use a non-existent event ID
|
||||
await _publisher.PublishSearchCompleted(Guid.NewGuid(), SearchCommandStatus.Completed);
|
||||
|
||||
// Assert — should not throw, and the log warning is the important behavior
|
||||
// (no exception thrown is the assertion)
|
||||
var eventCount = await _context.Events.CountAsync();
|
||||
Assert.Equal(0, eventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_NotifiesSignalRClients()
|
||||
{
|
||||
// Arrange
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
|
||||
// Reset mock to only capture the completion call
|
||||
_clientProxyMock.Invocations.Clear();
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed);
|
||||
|
||||
// Assert
|
||||
_clientProxyMock.Verify(c => c.SendCoreAsync(
|
||||
"EventReceived",
|
||||
It.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -246,4 +246,267 @@ public class CustomFormatScoreSyncerTests : IDisposable
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Sonarr Sync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_SyncsSonarrEpisodeScores()
|
||||
{
|
||||
// Arrange
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.UseCustomFormatScore = 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();
|
||||
|
||||
// Mock quality profiles
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetQualityProfilesAsync(sonarrInstance))
|
||||
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
|
||||
|
||||
// Mock series
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetAllSeriesAsync(sonarrInstance))
|
||||
.ReturnsAsync([
|
||||
new SearchableSeries { Id = 10, Title = "Test Series", QualityProfileId = 1, Monitored = true }
|
||||
]);
|
||||
|
||||
// Mock episodes — one with a file, one without
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetEpisodesAsync(sonarrInstance, 10))
|
||||
.ReturnsAsync([
|
||||
new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, EpisodeFileId = 500, HasFile = true },
|
||||
new SearchableEpisode { Id = 101, SeasonNumber = 1, EpisodeNumber = 2, EpisodeFileId = 0, HasFile = false }
|
||||
]);
|
||||
|
||||
// Mock episode files with CF scores
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetEpisodeFilesAsync(sonarrInstance, 10))
|
||||
.ReturnsAsync([
|
||||
new ArrEpisodeFile { Id = 500, CustomFormatScore = 300, QualityCutoffNotMet = false }
|
||||
]);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — only the episode with a file should have an entry
|
||||
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
|
||||
Assert.Single(entries);
|
||||
|
||||
var entry = entries[0];
|
||||
Assert.Equal(sonarrInstance.Id, entry.ArrInstanceId);
|
||||
Assert.Equal(10, entry.ExternalItemId);
|
||||
Assert.Equal(100, entry.EpisodeId);
|
||||
Assert.Equal(300, entry.CurrentScore);
|
||||
Assert.Equal(500, entry.CutoffScore);
|
||||
Assert.Equal(InstanceType.Sonarr, entry.ItemType);
|
||||
Assert.Contains("S01E01", entry.Title);
|
||||
|
||||
// Initial history should be created
|
||||
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
|
||||
Assert.Single(history);
|
||||
Assert.Equal(300, history[0].Score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_SonarrSync_SkipsEpisodesWithoutFiles()
|
||||
{
|
||||
// Arrange
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.UseCustomFormatScore = 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();
|
||||
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetQualityProfilesAsync(sonarrInstance))
|
||||
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
|
||||
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetAllSeriesAsync(sonarrInstance))
|
||||
.ReturnsAsync([
|
||||
new SearchableSeries { Id = 10, Title = "Test Series", QualityProfileId = 1, Monitored = true }
|
||||
]);
|
||||
|
||||
// All episodes have EpisodeFileId = 0 (no file)
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetEpisodesAsync(sonarrInstance, 10))
|
||||
.ReturnsAsync([
|
||||
new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, EpisodeFileId = 0, HasFile = false }
|
||||
]);
|
||||
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetEpisodeFilesAsync(sonarrInstance, 10))
|
||||
.ReturnsAsync([]);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — no entries created
|
||||
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
|
||||
Assert.Empty(entries);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Score Unchanged Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ScoreUnchanged_DoesNotRecordHistory()
|
||||
{
|
||||
// Arrange
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.UseCustomFormatScore = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
// Pre-existing entry with score = 250 (same as what will be returned)
|
||||
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 10,
|
||||
EpisodeId = 0,
|
||||
ItemType = InstanceType.Radarr,
|
||||
Title = "Test Movie",
|
||||
FileId = 100,
|
||||
CurrentScore = 250,
|
||||
CutoffScore = 500,
|
||||
QualityProfileName = "HD",
|
||||
LastSyncedAt = DateTime.UtcNow.AddHours(-1)
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
_radarrClient
|
||||
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
|
||||
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
|
||||
|
||||
_radarrClient
|
||||
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
|
||||
.ReturnsAsync([
|
||||
new SearchableMovie
|
||||
{
|
||||
Id = 10, Title = "Test Movie", HasFile = true,
|
||||
MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false },
|
||||
QualityProfileId = 1, Status = "released", Monitored = true
|
||||
}
|
||||
]);
|
||||
|
||||
// Score unchanged: still 250
|
||||
_radarrClient
|
||||
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.Is<List<long>>(ids => ids.Contains(100))))
|
||||
.ReturnsAsync(new Dictionary<long, int> { { 100, 250 } });
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — no history entries (score didn't change)
|
||||
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
|
||||
Assert.Empty(history);
|
||||
|
||||
// Entry should still be updated (LastSyncedAt changes)
|
||||
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
|
||||
Assert.Single(entries);
|
||||
Assert.Equal(250, entries[0].CurrentScore);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Stale Entry Cleanup Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_CleansUpStaleEntries()
|
||||
{
|
||||
// Arrange
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.UseCustomFormatScore = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
// Pre-existing entry for a movie that no longer exists in library
|
||||
// Use a timestamp before FakeTimeProvider's default (2000-01-01) so it's treated as stale
|
||||
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 999,
|
||||
EpisodeId = 0,
|
||||
ItemType = InstanceType.Radarr,
|
||||
Title = "Deleted Movie",
|
||||
FileId = 999,
|
||||
CurrentScore = 100,
|
||||
CutoffScore = 500,
|
||||
QualityProfileName = "HD",
|
||||
LastSyncedAt = new DateTime(1999, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
_radarrClient
|
||||
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
|
||||
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
|
||||
|
||||
// Library now only has movie 10 (not 999)
|
||||
_radarrClient
|
||||
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
|
||||
.ReturnsAsync([
|
||||
new SearchableMovie
|
||||
{
|
||||
Id = 10, Title = "Current Movie", HasFile = true,
|
||||
MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false },
|
||||
QualityProfileId = 1, Status = "released", Monitored = true
|
||||
}
|
||||
]);
|
||||
|
||||
_radarrClient
|
||||
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.IsAny<List<long>>()))
|
||||
.ReturnsAsync(new Dictionary<long, int> { { 100, 250 } });
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — stale entry for movie 999 should be removed
|
||||
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
|
||||
Assert.Single(entries);
|
||||
Assert.Equal(10, entries[0].ExternalItemId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -537,4 +537,371 @@ public class SeekerTests : IDisposable
|
||||
}
|
||||
|
||||
#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 currentRunId = Guid.NewGuid();
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true,
|
||||
CurrentRunId = currentRunId
|
||||
});
|
||||
|
||||
// Add history entries for both movies in the current cycle
|
||||
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 1,
|
||||
ItemType = InstanceType.Radarr,
|
||||
RunId = currentRunId,
|
||||
LastSearchedAt = DateTime.UtcNow.AddHours(-1),
|
||||
ItemTitle = "Movie 1"
|
||||
});
|
||||
_fixture.DataContext.SeekerHistory.Add(new SeekerHistory
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 2,
|
||||
ItemType = InstanceType.Radarr,
|
||||
RunId = currentRunId,
|
||||
LastSearchedAt = DateTime.UtcNow.AddHours(-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 = [] }
|
||||
]);
|
||||
|
||||
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 RunId 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(currentRunId, instanceConfig.CurrentRunId);
|
||||
}
|
||||
|
||||
#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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Seeker;
|
||||
using Cleanuparr.Infrastructure.Features.Seeker.Selectors;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Seeker;
|
||||
|
||||
public sealed class ItemSelectorTests
|
||||
{
|
||||
private static readonly List<(long Id, DateTime? Added, DateTime? LastSearched)> SampleCandidates =
|
||||
[
|
||||
(1, new DateTime(2024, 1, 1), new DateTime(2024, 6, 1)),
|
||||
(2, new DateTime(2024, 3, 1), new DateTime(2024, 5, 1)),
|
||||
(3, new DateTime(2024, 5, 1), null),
|
||||
(4, new DateTime(2024, 2, 1), new DateTime(2024, 7, 1)),
|
||||
(5, null, new DateTime(2024, 4, 1)),
|
||||
];
|
||||
|
||||
#region ItemSelectorFactory Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(SelectionStrategy.OldestSearchFirst, typeof(OldestSearchFirstSelector))]
|
||||
[InlineData(SelectionStrategy.OldestSearchWeighted, typeof(OldestSearchWeightedSelector))]
|
||||
[InlineData(SelectionStrategy.NewestFirst, typeof(NewestFirstSelector))]
|
||||
[InlineData(SelectionStrategy.NewestWeighted, typeof(NewestWeightedSelector))]
|
||||
[InlineData(SelectionStrategy.BalancedWeighted, typeof(BalancedWeightedSelector))]
|
||||
[InlineData(SelectionStrategy.Random, typeof(RandomSelector))]
|
||||
public void Factory_Create_ReturnsCorrectSelectorType(SelectionStrategy strategy, Type expectedType)
|
||||
{
|
||||
var selector = ItemSelectorFactory.Create(strategy);
|
||||
|
||||
Assert.IsType(expectedType, selector);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_Create_InvalidStrategy_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => ItemSelectorFactory.Create((SelectionStrategy)999));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NewestFirstSelector Tests
|
||||
|
||||
[Fact]
|
||||
public void NewestFirst_Select_OrdersByAddedDescending()
|
||||
{
|
||||
var selector = new NewestFirstSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 3);
|
||||
|
||||
// Newest added: 3 (May), 2 (Mar), 4 (Feb)
|
||||
Assert.Equal([3, 2, 4], result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewestFirst_Select_NullAddedDates_TreatedAsOldest()
|
||||
{
|
||||
var selector = new NewestFirstSelector();
|
||||
|
||||
// Select all — item 5 (null Added) should be last
|
||||
var result = selector.Select(SampleCandidates, 5);
|
||||
|
||||
Assert.Equal(5, result.Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewestFirst_Select_ReturnsRequestedCount()
|
||||
{
|
||||
var selector = new NewestFirstSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 2);
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewestFirst_Select_EmptyInput_ReturnsEmptyList()
|
||||
{
|
||||
var selector = new NewestFirstSelector();
|
||||
|
||||
var result = selector.Select([], 5);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewestFirst_Select_CountExceedsCandidates_ReturnsAll()
|
||||
{
|
||||
var selector = new NewestFirstSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 100);
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region OldestSearchFirstSelector Tests
|
||||
|
||||
[Fact]
|
||||
public void OldestSearchFirst_Select_OrdersByLastSearchedAscending()
|
||||
{
|
||||
var selector = new OldestSearchFirstSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 3);
|
||||
|
||||
// Never searched first (null → MinValue), then oldest: 3 (null), 5 (Apr), 2 (May)
|
||||
Assert.Equal([3, 5, 2], result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldestSearchFirst_Select_NullLastSearched_PrioritizedFirst()
|
||||
{
|
||||
var selector = new OldestSearchFirstSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 1);
|
||||
|
||||
// Item 3 has LastSearched = null, should be first
|
||||
Assert.Equal(3, result[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldestSearchFirst_Select_ReturnsRequestedCount()
|
||||
{
|
||||
var selector = new OldestSearchFirstSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 2);
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldestSearchFirst_Select_EmptyInput_ReturnsEmptyList()
|
||||
{
|
||||
var selector = new OldestSearchFirstSelector();
|
||||
|
||||
var result = selector.Select([], 5);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RandomSelector Tests
|
||||
|
||||
[Fact]
|
||||
public void Random_Select_ReturnsRequestedCount()
|
||||
{
|
||||
var selector = new RandomSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 3);
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Random_Select_CountExceedsCandidates_ReturnsAll()
|
||||
{
|
||||
var selector = new RandomSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 100);
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Random_Select_EmptyInput_ReturnsEmptyList()
|
||||
{
|
||||
var selector = new RandomSelector();
|
||||
|
||||
var result = selector.Select([], 5);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Random_Select_NoDuplicateIds()
|
||||
{
|
||||
var selector = new RandomSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 5);
|
||||
|
||||
Assert.Equal(result.Count, result.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Random_Select_ResultsAreSubsetOfInput()
|
||||
{
|
||||
var selector = new RandomSelector();
|
||||
var inputIds = SampleCandidates.Select(c => c.Id).ToHashSet();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 3);
|
||||
|
||||
Assert.All(result, id => Assert.Contains(id, inputIds));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NewestWeightedSelector Tests
|
||||
|
||||
[Fact]
|
||||
public void NewestWeighted_Select_ReturnsRequestedCount()
|
||||
{
|
||||
var selector = new NewestWeightedSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 3);
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewestWeighted_Select_EmptyInput_ReturnsEmptyList()
|
||||
{
|
||||
var selector = new NewestWeightedSelector();
|
||||
|
||||
var result = selector.Select([], 5);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewestWeighted_Select_CountExceedsCandidates_ReturnsAll()
|
||||
{
|
||||
var selector = new NewestWeightedSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 100);
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewestWeighted_Select_NoDuplicateIds()
|
||||
{
|
||||
var selector = new NewestWeightedSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 5);
|
||||
|
||||
Assert.Equal(result.Count, result.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewestWeighted_Select_SingleCandidate_ReturnsThatCandidate()
|
||||
{
|
||||
var selector = new NewestWeightedSelector();
|
||||
List<(long Id, DateTime? Added, DateTime? LastSearched)> single = [(42, DateTime.UtcNow, null)];
|
||||
|
||||
var result = selector.Select(single, 1);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal(42, result[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region OldestSearchWeightedSelector Tests
|
||||
|
||||
[Fact]
|
||||
public void OldestSearchWeighted_Select_ReturnsRequestedCount()
|
||||
{
|
||||
var selector = new OldestSearchWeightedSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 3);
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldestSearchWeighted_Select_EmptyInput_ReturnsEmptyList()
|
||||
{
|
||||
var selector = new OldestSearchWeightedSelector();
|
||||
|
||||
var result = selector.Select([], 5);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldestSearchWeighted_Select_CountExceedsCandidates_ReturnsAll()
|
||||
{
|
||||
var selector = new OldestSearchWeightedSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 100);
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldestSearchWeighted_Select_NoDuplicateIds()
|
||||
{
|
||||
var selector = new OldestSearchWeightedSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 5);
|
||||
|
||||
Assert.Equal(result.Count, result.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldestSearchWeighted_Select_SingleCandidate_ReturnsThatCandidate()
|
||||
{
|
||||
var selector = new OldestSearchWeightedSelector();
|
||||
List<(long Id, DateTime? Added, DateTime? LastSearched)> single = [(42, DateTime.UtcNow, null)];
|
||||
|
||||
var result = selector.Select(single, 1);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal(42, result[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BalancedWeightedSelector Tests
|
||||
|
||||
[Fact]
|
||||
public void BalancedWeighted_Select_ReturnsRequestedCount()
|
||||
{
|
||||
var selector = new BalancedWeightedSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 3);
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BalancedWeighted_Select_EmptyInput_ReturnsEmptyList()
|
||||
{
|
||||
var selector = new BalancedWeightedSelector();
|
||||
|
||||
var result = selector.Select([], 5);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BalancedWeighted_Select_CountExceedsCandidates_ReturnsAll()
|
||||
{
|
||||
var selector = new BalancedWeightedSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 100);
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BalancedWeighted_Select_NoDuplicateIds()
|
||||
{
|
||||
var selector = new BalancedWeightedSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 5);
|
||||
|
||||
Assert.Equal(result.Count, result.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BalancedWeighted_Select_SingleCandidate_ReturnsThatCandidate()
|
||||
{
|
||||
var selector = new BalancedWeightedSelector();
|
||||
List<(long Id, DateTime? Added, DateTime? LastSearched)> single = [(42, DateTime.UtcNow, null)];
|
||||
|
||||
var result = selector.Select(single, 1);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal(42, result[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BalancedWeighted_Select_ResultsAreSubsetOfInput()
|
||||
{
|
||||
var selector = new BalancedWeightedSelector();
|
||||
var inputIds = SampleCandidates.Select(c => c.Id).ToHashSet();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 3);
|
||||
|
||||
Assert.All(result, id => Assert.Contains(id, inputIds));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region WeightedRandomByRank Tests
|
||||
|
||||
[Fact]
|
||||
public void WeightedRandomByRank_ReturnsRequestedCount()
|
||||
{
|
||||
var ranked = SampleCandidates.OrderBy(c => c.LastSearched ?? DateTime.MinValue).ToList();
|
||||
|
||||
var result = OldestSearchWeightedSelector.WeightedRandomByRank(ranked, 3);
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeightedRandomByRank_CountExceedsCandidates_ReturnsAll()
|
||||
{
|
||||
var ranked = SampleCandidates.OrderBy(c => c.LastSearched ?? DateTime.MinValue).ToList();
|
||||
|
||||
var result = OldestSearchWeightedSelector.WeightedRandomByRank(ranked, 100);
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeightedRandomByRank_NoDuplicateIds()
|
||||
{
|
||||
var ranked = SampleCandidates.OrderBy(c => c.LastSearched ?? DateTime.MinValue).ToList();
|
||||
|
||||
var result = OldestSearchWeightedSelector.WeightedRandomByRank(ranked, 5);
|
||||
|
||||
Assert.Equal(result.Count, result.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeightedRandomByRank_EmptyInput_ReturnsEmptyList()
|
||||
{
|
||||
var result = OldestSearchWeightedSelector.WeightedRandomByRank([], 5);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Persistence.Tests.Models.Configuration.Seeker;
|
||||
|
||||
public sealed class SeekerConfigTests
|
||||
{
|
||||
#region Validate - Valid Configurations
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithDefaultConfig_DoesNotThrow()
|
||||
{
|
||||
var config = new SeekerConfig();
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((ushort)2)]
|
||||
[InlineData((ushort)3)]
|
||||
[InlineData((ushort)4)]
|
||||
[InlineData((ushort)5)]
|
||||
[InlineData((ushort)6)]
|
||||
[InlineData((ushort)10)]
|
||||
[InlineData((ushort)12)]
|
||||
[InlineData((ushort)15)]
|
||||
[InlineData((ushort)20)]
|
||||
[InlineData((ushort)30)]
|
||||
public void Validate_WithValidIntervals_DoesNotThrow(ushort interval)
|
||||
{
|
||||
var config = new SeekerConfig { SearchInterval = interval };
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - Invalid Configurations
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithIntervalBelowMinimum_ThrowsValidationException()
|
||||
{
|
||||
var config = new SeekerConfig { SearchInterval = 1 };
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldContain("at least");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithIntervalAboveMaximum_ThrowsValidationException()
|
||||
{
|
||||
var config = new SeekerConfig { SearchInterval = 31 };
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldContain("at most");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((ushort)7)]
|
||||
[InlineData((ushort)8)]
|
||||
[InlineData((ushort)9)]
|
||||
[InlineData((ushort)11)]
|
||||
[InlineData((ushort)13)]
|
||||
[InlineData((ushort)14)]
|
||||
public void Validate_WithNonDivisorInterval_ThrowsValidationException(ushort interval)
|
||||
{
|
||||
var config = new SeekerConfig { SearchInterval = interval };
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldContain("Invalid search interval");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ToCronExpression
|
||||
|
||||
[Theory]
|
||||
[InlineData((ushort)2, "0 */2 * * * ?")]
|
||||
[InlineData((ushort)5, "0 */5 * * * ?")]
|
||||
[InlineData((ushort)10, "0 */10 * * * ?")]
|
||||
[InlineData((ushort)30, "0 */30 * * * ?")]
|
||||
public void ToCronExpression_ReturnsCorrectCron(ushort interval, string expectedCron)
|
||||
{
|
||||
var config = new SeekerConfig { SearchInterval = interval };
|
||||
|
||||
config.ToCronExpression().ShouldBe(expectedCron);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user