added more tests

This commit is contained in:
Flaminel
2026-03-24 14:49:55 +02:00
parent 6db81434e2
commit 6512230b0e
7 changed files with 1540 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View 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);
});
});

View File

@@ -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',