From 6512230b0ea4baa98b23d712f2c719b4bc8f20bb Mon Sep 17 00:00:00 2001 From: Flaminel Date: Tue, 24 Mar 2026 14:49:55 +0200 Subject: [PATCH] added more tests --- .../Events/EventPublisherTests.cs | 217 +++++++++ .../Jobs/CustomFormatScoreSyncerTests.cs | 263 +++++++++++ .../Features/Jobs/SeekerTests.cs | 367 +++++++++++++++ .../Features/Seeker/ItemSelectorTests.cs | 420 ++++++++++++++++++ .../Configuration/Seeker/SeekerConfigTests.cs | 92 ++++ e2e/tests/10-seeker-config-api.spec.ts | 125 ++++++ e2e/tests/helpers/app-api.ts | 56 +++ 7 files changed, 1540 insertions(+) create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/ItemSelectorTests.cs create mode 100644 code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Seeker/SeekerConfigTests.cs create mode 100644 e2e/tests/10-seeker-config-api.spec.ts diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventPublisherTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventPublisherTests.cs index da3f4006..b4f1465a 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventPublisherTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventPublisherTests.cs @@ -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(args => args.Length == 1 && args[0] is AppEvent), + It.IsAny()), 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>()), + 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(args => args.Length == 1 && args[0] is AppEvent), + It.IsAny()), Times.Once); + } + + #endregion } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/CustomFormatScoreSyncerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/CustomFormatScoreSyncerTests.cs index c0d88179..ee5c3a95 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/CustomFormatScoreSyncerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/CustomFormatScoreSyncerTests.cs @@ -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>(ids => ids.Contains(100)))) + .ReturnsAsync(new Dictionary { { 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>())) + .ReturnsAsync(new Dictionary { { 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 } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs index ee90634b..de5642ae 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs @@ -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(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, 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? capturedSearchItems = null; + mockArrClient + .Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny>())) + .Callback>((_, items) => capturedSearchItems = items) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .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(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, 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? capturedSearchItems = null; + mockArrClient + .Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny>())) + .Callback>((_, items) => capturedSearchItems = items) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .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(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, 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? capturedSearchItems = null; + mockArrClient + .Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny>())) + .Callback>((_, items) => capturedSearchItems = items) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .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(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, 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>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .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>()), + 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(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns(Task.CompletedTask); + + // Return movies for both instances — only instance 2 should be called + _radarrClient + .Setup(x => x.GetAllMoviesAsync(It.Is(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(), It.IsAny>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .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(a => a.Id == radarrInstance2.Id)), + Times.Once); + _radarrClient.Verify( + x => x.GetAllMoviesAsync(It.Is(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(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + #endregion } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/ItemSelectorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/ItemSelectorTests.cs new file mode 100644 index 00000000..d2a23c8c --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/ItemSelectorTests.cs @@ -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(() => 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 +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Seeker/SeekerConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Seeker/SeekerConfigTests.cs new file mode 100644 index 00000000..93ffa23c --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Seeker/SeekerConfigTests.cs @@ -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(() => config.Validate()); + exception.Message.ShouldContain("at least"); + } + + [Fact] + public void Validate_WithIntervalAboveMaximum_ThrowsValidationException() + { + var config = new SeekerConfig { SearchInterval = 31 }; + + var exception = Should.Throw(() => 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(() => 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 +} diff --git a/e2e/tests/10-seeker-config-api.spec.ts b/e2e/tests/10-seeker-config-api.spec.ts new file mode 100644 index 00000000..53cb6e68 --- /dev/null +++ b/e2e/tests/10-seeker-config-api.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/helpers/app-api.ts b/e2e/tests/helpers/app-api.ts index a7f2709f..02c81dba 100644 --- a/e2e/tests/helpers/app-api.ts +++ b/e2e/tests/helpers/app-api.ts @@ -102,6 +102,62 @@ export async function updateOidcConfig( } } +// --- Seeker API helpers --- + +export async function getSeekerConfig(accessToken: string): Promise { + return fetch(`${API}/api/configuration/seeker`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); +} + +export async function updateSeekerConfig( + accessToken: string, + config: Record, +): Promise { + 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 { + return fetch(`${API}/api/seeker/search-stats/summary`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); +} + +export async function getSearchHistory(accessToken: string): Promise { + return fetch(`${API}/api/seeker/search-stats/history`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); +} + +export async function getSearchEvents(accessToken: string): Promise { + return fetch(`${API}/api/seeker/search-stats/events`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); +} + +export async function getCfScores( + accessToken: string, + params?: Record, +): Promise { + 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 { + return fetch(`${API}/api/seeker/cf-scores/stats`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); +} + export async function configureOidc(accessToken: string): Promise { const putRes = await fetch(`${API}/api/account/oidc`, { method: 'PUT',