mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-03-27 02:23:56 -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
|
||||
}
|
||||
125
e2e/tests/10-seeker-config-api.spec.ts
Normal file
125
e2e/tests/10-seeker-config-api.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
loginAndGetToken,
|
||||
getSeekerConfig,
|
||||
updateSeekerConfig,
|
||||
getSearchStatsSummary,
|
||||
getSearchHistory,
|
||||
getSearchEvents,
|
||||
getCfScores,
|
||||
getCfScoreStats,
|
||||
} from './helpers/app-api';
|
||||
|
||||
test.describe.serial('Seeker API', () => {
|
||||
let token: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
token = await loginAndGetToken();
|
||||
});
|
||||
|
||||
test('should return default seeker config', async () => {
|
||||
const res = await getSeekerConfig(token);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty('searchEnabled');
|
||||
expect(body).toHaveProperty('searchInterval');
|
||||
expect(body).toHaveProperty('proactiveSearchEnabled');
|
||||
expect(body).toHaveProperty('selectionStrategy');
|
||||
expect(body).toHaveProperty('monitoredOnly');
|
||||
expect(body).toHaveProperty('useCutoff');
|
||||
expect(body).toHaveProperty('useCustomFormatScore');
|
||||
expect(body).toHaveProperty('useRoundRobin');
|
||||
expect(body).toHaveProperty('instances');
|
||||
expect(Array.isArray(body.instances)).toBe(true);
|
||||
});
|
||||
|
||||
test('should update seeker config', async () => {
|
||||
// Get current config first
|
||||
const getRes = await getSeekerConfig(token);
|
||||
const current = await getRes.json();
|
||||
|
||||
// Update with modified values
|
||||
const updateRes = await updateSeekerConfig(token, {
|
||||
...current,
|
||||
searchEnabled: false,
|
||||
searchInterval: 5,
|
||||
});
|
||||
expect(updateRes.status).toBe(200);
|
||||
|
||||
// Verify the update persisted
|
||||
const verifyRes = await getSeekerConfig(token);
|
||||
const updated = await verifyRes.json();
|
||||
expect(updated.searchEnabled).toBe(false);
|
||||
expect(updated.searchInterval).toBe(5);
|
||||
|
||||
// Restore original values
|
||||
await updateSeekerConfig(token, {
|
||||
...updated,
|
||||
searchEnabled: current.searchEnabled,
|
||||
searchInterval: current.searchInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test('should reject invalid search interval', async () => {
|
||||
const getRes = await getSeekerConfig(token);
|
||||
const current = await getRes.json();
|
||||
|
||||
const res = await updateSeekerConfig(token, {
|
||||
...current,
|
||||
searchInterval: 7, // Not a valid divisor of 60
|
||||
});
|
||||
// Should fail validation (400 or 500)
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
test('should return search stats summary with zero counts', async () => {
|
||||
const res = await getSearchStatsSummary(token);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty('totalSearchesAllTime');
|
||||
expect(body).toHaveProperty('searchesLast7Days');
|
||||
expect(body).toHaveProperty('searchesLast30Days');
|
||||
expect(body).toHaveProperty('uniqueItemsSearched');
|
||||
expect(body.totalSearchesAllTime).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('should return empty search history', async () => {
|
||||
const res = await getSearchHistory(token);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty('items');
|
||||
expect(Array.isArray(body.items)).toBe(true);
|
||||
});
|
||||
|
||||
test('should return empty search events', async () => {
|
||||
const res = await getSearchEvents(token);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty('items');
|
||||
expect(Array.isArray(body.items)).toBe(true);
|
||||
});
|
||||
|
||||
test('should return empty CF scores list', async () => {
|
||||
const res = await getCfScores(token);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty('items');
|
||||
expect(Array.isArray(body.items)).toBe(true);
|
||||
expect(body).toHaveProperty('totalCount');
|
||||
});
|
||||
|
||||
test('should return CF score stats with zero values', async () => {
|
||||
const res = await getCfScoreStats(token);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty('totalTracked');
|
||||
expect(body).toHaveProperty('belowCutoff');
|
||||
expect(body.totalTracked).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,62 @@ export async function updateOidcConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Seeker API helpers ---
|
||||
|
||||
export async function getSeekerConfig(accessToken: string): Promise<Response> {
|
||||
return fetch(`${API}/api/configuration/seeker`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSeekerConfig(
|
||||
accessToken: string,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<Response> {
|
||||
return fetch(`${API}/api/configuration/seeker`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSearchStatsSummary(accessToken: string): Promise<Response> {
|
||||
return fetch(`${API}/api/seeker/search-stats/summary`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSearchHistory(accessToken: string): Promise<Response> {
|
||||
return fetch(`${API}/api/seeker/search-stats/history`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSearchEvents(accessToken: string): Promise<Response> {
|
||||
return fetch(`${API}/api/seeker/search-stats/events`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCfScores(
|
||||
accessToken: string,
|
||||
params?: Record<string, string>,
|
||||
): Promise<Response> {
|
||||
const query = params ? '?' + new URLSearchParams(params).toString() : '';
|
||||
return fetch(`${API}/api/seeker/cf-scores${query}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCfScoreStats(accessToken: string): Promise<Response> {
|
||||
return fetch(`${API}/api/seeker/cf-scores/stats`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
}
|
||||
|
||||
export async function configureOidc(accessToken: string): Promise<void> {
|
||||
const putRes = await fetch(`${API}/api/account/oidc`, {
|
||||
method: 'PUT',
|
||||
|
||||
Reference in New Issue
Block a user