diff --git a/code/backend/Cleanuparr.Api.Tests/Cleanuparr.Api.Tests.csproj b/code/backend/Cleanuparr.Api.Tests/Cleanuparr.Api.Tests.csproj
index c8e2aa6c..ac19d037 100644
--- a/code/backend/Cleanuparr.Api.Tests/Cleanuparr.Api.Tests.csproj
+++ b/code/backend/Cleanuparr.Api.Tests/Cleanuparr.Api.Tests.csproj
@@ -11,6 +11,7 @@
+
diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/CustomFormatScoreControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/CustomFormatScoreControllerTests.cs
new file mode 100644
index 00000000..cb2904f6
--- /dev/null
+++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/CustomFormatScoreControllerTests.cs
@@ -0,0 +1,361 @@
+using System.Text.Json;
+using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
+using Cleanuparr.Api.Features.Seeker.Controllers;
+using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
+using Cleanuparr.Domain.Enums;
+using Cleanuparr.Persistence;
+using Cleanuparr.Persistence.Models.State;
+using Microsoft.AspNetCore.Mvc;
+using Shouldly;
+
+namespace Cleanuparr.Api.Tests.Features.Seeker;
+
+public class CustomFormatScoreControllerTests : IDisposable
+{
+ private readonly DataContext _dataContext;
+ private readonly CustomFormatScoreController _controller;
+
+ public CustomFormatScoreControllerTests()
+ {
+ _dataContext = SeekerTestDataFactory.CreateDataContext();
+ _controller = new CustomFormatScoreController(_dataContext);
+ }
+
+ public void Dispose()
+ {
+ _dataContext.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ private static JsonElement GetResponseBody(IActionResult result)
+ {
+ var okResult = result.ShouldBeOfType();
+ var json = JsonSerializer.Serialize(okResult.Value);
+ return JsonDocument.Parse(json).RootElement;
+ }
+
+ #region GetCustomFormatScores Tests
+
+ [Fact]
+ public async Task GetCustomFormatScores_WithPageBelowMinimum_ClampsToOne()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ AddScoreEntry(radarr.Id, 1, "Movie A", currentScore: 100, cutoffScore: 500);
+ AddScoreEntry(radarr.Id, 2, "Movie B", currentScore: 200, cutoffScore: 500);
+
+ var result = await _controller.GetCustomFormatScores(page: -5, pageSize: 50);
+ var body = GetResponseBody(result);
+
+ body.GetProperty("Page").GetInt32().ShouldBe(1);
+ body.GetProperty("Items").GetArrayLength().ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task GetCustomFormatScores_WithPageSizeAboveMaximum_ClampsToHundred()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ AddScoreEntry(radarr.Id, 1, "Movie A", currentScore: 100, cutoffScore: 500);
+
+ var result = await _controller.GetCustomFormatScores(page: 1, pageSize: 999);
+ var body = GetResponseBody(result);
+
+ body.GetProperty("PageSize").GetInt32().ShouldBe(100);
+ }
+
+ [Fact]
+ public async Task GetCustomFormatScores_WithHideMetTrue_ExcludesItemsAtOrAboveCutoff()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ AddScoreEntry(radarr.Id, 1, "Below Cutoff", currentScore: 100, cutoffScore: 500);
+ AddScoreEntry(radarr.Id, 2, "At Cutoff", currentScore: 500, cutoffScore: 500);
+ AddScoreEntry(radarr.Id, 3, "Above Cutoff", currentScore: 600, cutoffScore: 500);
+
+ var result = await _controller.GetCustomFormatScores(hideMet: true);
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
+ body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Below Cutoff");
+ }
+
+ [Fact]
+ public async Task GetCustomFormatScores_WithSearchFilter_ReturnsMatchingTitlesOnly()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ AddScoreEntry(radarr.Id, 1, "The Matrix", currentScore: 100, cutoffScore: 500);
+ AddScoreEntry(radarr.Id, 2, "Inception", currentScore: 200, cutoffScore: 500);
+ AddScoreEntry(radarr.Id, 3, "The Matrix Reloaded", currentScore: 300, cutoffScore: 500);
+
+ var result = await _controller.GetCustomFormatScores(search: "matrix");
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task GetCustomFormatScores_WithSortByDate_OrdersByLastSyncedDescending()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ AddScoreEntry(radarr.Id, 1, "Older", currentScore: 100, cutoffScore: 500,
+ lastSynced: DateTime.UtcNow.AddHours(-2));
+ AddScoreEntry(radarr.Id, 2, "Newer", currentScore: 200, cutoffScore: 500,
+ lastSynced: DateTime.UtcNow.AddHours(-1));
+
+ var result = await _controller.GetCustomFormatScores(sortBy: "date");
+ var body = GetResponseBody(result);
+
+ body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Newer");
+ body.GetProperty("Items")[1].GetProperty("Title").GetString().ShouldBe("Older");
+ }
+
+ [Fact]
+ public async Task GetCustomFormatScores_WithInstanceIdFilter_ReturnsOnlyThatInstance()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
+ AddScoreEntry(radarr.Id, 1, "Movie", currentScore: 100, cutoffScore: 500);
+ AddScoreEntry(sonarr.Id, 2, "Series", currentScore: 200, cutoffScore: 500,
+ itemType: InstanceType.Sonarr);
+
+ var result = await _controller.GetCustomFormatScores(instanceId: radarr.Id);
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
+ body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Movie");
+ }
+
+ [Fact]
+ public async Task GetCustomFormatScores_ReturnsCorrectTotalPagesCalculation()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ for (int i = 1; i <= 7; i++)
+ {
+ AddScoreEntry(radarr.Id, i, $"Movie {i}", currentScore: 100, cutoffScore: 500);
+ }
+
+ var result = await _controller.GetCustomFormatScores(page: 1, pageSize: 3);
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(7);
+ body.GetProperty("TotalPages").GetInt32().ShouldBe(3); // ceil(7/3) = 3
+ body.GetProperty("Items").GetArrayLength().ShouldBe(3);
+ }
+
+ #endregion
+
+ #region GetRecentUpgrades Tests
+
+ [Fact]
+ public async Task GetRecentUpgrades_WithNoHistory_ReturnsEmptyList()
+ {
+ var result = await _controller.GetRecentUpgrades();
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
+ body.GetProperty("Items").GetArrayLength().ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task GetRecentUpgrades_WithSingleEntryPerItem_ReturnsNoUpgrades()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-1));
+
+ var result = await _controller.GetRecentUpgrades();
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task GetRecentUpgrades_WithScoreIncrease_DetectsUpgrade()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-2));
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 250, recordedAt: DateTime.UtcNow.AddDays(-1));
+
+ var result = await _controller.GetRecentUpgrades();
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
+ var upgrade = body.GetProperty("Items")[0];
+ upgrade.GetProperty("PreviousScore").GetInt32().ShouldBe(100);
+ upgrade.GetProperty("NewScore").GetInt32().ShouldBe(250);
+ }
+
+ [Fact]
+ public async Task GetRecentUpgrades_WithScoreDecrease_DoesNotCountAsUpgrade()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-2));
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 150, recordedAt: DateTime.UtcNow.AddDays(-1));
+
+ var result = await _controller.GetRecentUpgrades();
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task GetRecentUpgrades_WithMultipleUpgradesInSameGroup_CountsEach()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ // 100 -> 200 -> 300 = two upgrades for the same item
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
+
+ var result = await _controller.GetRecentUpgrades();
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task GetRecentUpgrades_WithDaysFilter_ExcludesOlderHistory()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ // Old upgrade (outside 7-day window)
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-20));
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 250, recordedAt: DateTime.UtcNow.AddDays(-15));
+ // Recent upgrade (inside 7-day window)
+ AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
+ AddHistoryEntry(radarr.Id, externalItemId: 2, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
+
+ var result = await _controller.GetRecentUpgrades(days: 7);
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task GetRecentUpgrades_ReturnsSortedByMostRecentFirst()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ // Item 1: upgrade happened 5 days ago
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-6));
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-5));
+ // Item 2: upgrade happened 1 day ago
+ AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-2));
+ AddHistoryEntry(radarr.Id, externalItemId: 2, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
+
+ var result = await _controller.GetRecentUpgrades();
+ var body = GetResponseBody(result);
+
+ var items = body.GetProperty("Items");
+ items.GetArrayLength().ShouldBe(2);
+ // Most recent upgrade (item 2) should be first
+ items[0].GetProperty("NewScore").GetInt32().ShouldBe(300);
+ items[1].GetProperty("NewScore").GetInt32().ShouldBe(200);
+ }
+
+ #endregion
+
+ #region GetStats Tests
+
+ [Fact]
+ public async Task GetStats_WithNoEntries_ReturnsZeroes()
+ {
+ var result = await _controller.GetStats();
+ var okResult = result.ShouldBeOfType();
+ var stats = okResult.Value.ShouldBeOfType();
+
+ stats.TotalTracked.ShouldBe(0);
+ stats.BelowCutoff.ShouldBe(0);
+ stats.AtOrAboveCutoff.ShouldBe(0);
+ stats.RecentUpgrades.ShouldBe(0);
+ stats.AverageScore.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task GetStats_CorrectlyCategorizesBelowAndAboveCutoff()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ AddScoreEntry(radarr.Id, 1, "Below", currentScore: 100, cutoffScore: 500);
+ AddScoreEntry(radarr.Id, 2, "At", currentScore: 500, cutoffScore: 500);
+ AddScoreEntry(radarr.Id, 3, "Above", currentScore: 600, cutoffScore: 500);
+
+ var result = await _controller.GetStats();
+ var okResult = result.ShouldBeOfType();
+ var stats = okResult.Value.ShouldBeOfType();
+
+ stats.TotalTracked.ShouldBe(3);
+ stats.BelowCutoff.ShouldBe(1);
+ stats.AtOrAboveCutoff.ShouldBe(2);
+ stats.AverageScore.ShouldBe(400); // (100+500+600)/3 = 400
+ }
+
+ [Fact]
+ public async Task GetStats_CountsRecentUpgradesFromLast7Days()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ AddScoreEntry(radarr.Id, 1, "Movie", currentScore: 300, cutoffScore: 500);
+
+ // Upgrade within 7 days
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
+ AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
+
+ // Upgrade outside 7 days (should not be counted)
+ AddHistoryEntry(radarr.Id, externalItemId: 2, score: 50, recordedAt: DateTime.UtcNow.AddDays(-20));
+ AddHistoryEntry(radarr.Id, externalItemId: 2, score: 200, recordedAt: DateTime.UtcNow.AddDays(-15));
+
+ var result = await _controller.GetStats();
+ var okResult = result.ShouldBeOfType();
+ var stats = okResult.Value.ShouldBeOfType();
+
+ stats.RecentUpgrades.ShouldBe(1);
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private void AddScoreEntry(
+ Guid arrInstanceId,
+ long externalItemId,
+ string title,
+ int currentScore,
+ int cutoffScore,
+ InstanceType itemType = InstanceType.Radarr,
+ DateTime? lastSynced = null)
+ {
+ _dataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
+ {
+ ArrInstanceId = arrInstanceId,
+ ExternalItemId = externalItemId,
+ EpisodeId = 0,
+ ItemType = itemType,
+ Title = title,
+ FileId = externalItemId * 10,
+ CurrentScore = currentScore,
+ CutoffScore = cutoffScore,
+ QualityProfileName = "HD",
+ LastSyncedAt = lastSynced ?? DateTime.UtcNow
+ });
+ _dataContext.SaveChanges();
+ }
+
+ private void AddHistoryEntry(
+ Guid arrInstanceId,
+ long externalItemId,
+ int score,
+ DateTime recordedAt,
+ long episodeId = 0,
+ int cutoffScore = 500,
+ InstanceType itemType = InstanceType.Radarr)
+ {
+ _dataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
+ {
+ ArrInstanceId = arrInstanceId,
+ ExternalItemId = externalItemId,
+ EpisodeId = episodeId,
+ ItemType = itemType,
+ Title = $"Item {externalItemId}",
+ Score = score,
+ CutoffScore = cutoffScore,
+ RecordedAt = recordedAt
+ });
+ _dataContext.SaveChanges();
+ }
+
+ #endregion
+}
diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs
new file mode 100644
index 00000000..4369afc3
--- /dev/null
+++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs
@@ -0,0 +1,221 @@
+using System.Text.Json;
+using Cleanuparr.Api.Features.Seeker.Controllers;
+using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
+using Cleanuparr.Domain.Enums;
+using Cleanuparr.Persistence;
+using Cleanuparr.Persistence.Models.Events;
+using Microsoft.AspNetCore.Mvc;
+using Shouldly;
+
+namespace Cleanuparr.Api.Tests.Features.Seeker;
+
+public class SearchStatsControllerTests : IDisposable
+{
+ private readonly DataContext _dataContext;
+ private readonly EventsContext _eventsContext;
+ private readonly SearchStatsController _controller;
+
+ public SearchStatsControllerTests()
+ {
+ _dataContext = SeekerTestDataFactory.CreateDataContext();
+ _eventsContext = SeekerTestDataFactory.CreateEventsContext();
+ _controller = new SearchStatsController(_dataContext, _eventsContext);
+ }
+
+ public void Dispose()
+ {
+ _dataContext.Dispose();
+ _eventsContext.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ private static JsonElement GetResponseBody(IActionResult result)
+ {
+ var okResult = result.ShouldBeOfType();
+ var json = JsonSerializer.Serialize(okResult.Value);
+ return JsonDocument.Parse(json).RootElement;
+ }
+
+ #region ParseEventData (tested via GetEvents)
+
+ [Fact]
+ public async Task GetEvents_WithNullEventData_ReturnsUnknownDefaults()
+ {
+ AddSearchEvent(data: null);
+
+ var result = await _controller.GetEvents();
+ var body = GetResponseBody(result);
+
+ var item = body.GetProperty("Items")[0];
+ item.GetProperty("InstanceName").GetString().ShouldBe("Unknown");
+ item.GetProperty("ItemCount").GetInt32().ShouldBe(0);
+ item.GetProperty("Items").GetArrayLength().ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task GetEvents_WithValidFullJson_ParsesAllFields()
+ {
+ var data = JsonSerializer.Serialize(new
+ {
+ InstanceName = "My Radarr",
+ ItemCount = 3,
+ Items = new[] { "Movie A", "Movie B", "Movie C" },
+ SearchType = "Proactive",
+ GrabbedItems = new[] { new { Title = "Movie A", Quality = "Bluray-1080p" } }
+ });
+ AddSearchEvent(data: data);
+
+ var result = await _controller.GetEvents();
+ var body = GetResponseBody(result);
+
+ var item = body.GetProperty("Items")[0];
+ item.GetProperty("InstanceName").GetString().ShouldBe("My Radarr");
+ item.GetProperty("ItemCount").GetInt32().ShouldBe(3);
+ item.GetProperty("Items").GetArrayLength().ShouldBe(3);
+ item.GetProperty("Items")[0].GetString().ShouldBe("Movie A");
+ item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Proactive));
+ }
+
+ [Fact]
+ public async Task GetEvents_WithPartialJson_ReturnsDefaultsForMissingFields()
+ {
+ // Only InstanceName is present, other fields missing
+ var data = JsonSerializer.Serialize(new { InstanceName = "Partial Instance" });
+ AddSearchEvent(data: data);
+
+ var result = await _controller.GetEvents();
+ var body = GetResponseBody(result);
+
+ var item = body.GetProperty("Items")[0];
+ item.GetProperty("InstanceName").GetString().ShouldBe("Partial Instance");
+ item.GetProperty("ItemCount").GetInt32().ShouldBe(0);
+ item.GetProperty("Items").GetArrayLength().ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task GetEvents_WithMalformedJson_ReturnsUnknownDefaults()
+ {
+ AddSearchEvent(data: "not valid json {{{");
+
+ var result = await _controller.GetEvents();
+ var body = GetResponseBody(result);
+
+ var item = body.GetProperty("Items")[0];
+ item.GetProperty("InstanceName").GetString().ShouldBe("Unknown");
+ item.GetProperty("ItemCount").GetInt32().ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task GetEvents_WithSearchTypeReplacement_ParsesCorrectEnum()
+ {
+ var data = JsonSerializer.Serialize(new
+ {
+ InstanceName = "Sonarr",
+ SearchType = "Replacement"
+ });
+ AddSearchEvent(data: data);
+
+ var result = await _controller.GetEvents();
+ var body = GetResponseBody(result);
+
+ var item = body.GetProperty("Items")[0];
+ item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Replacement));
+ }
+
+ #endregion
+
+ #region GetEvents Filtering
+
+ [Fact]
+ public async Task GetEvents_WithInstanceIdFilter_FiltersViaInstanceUrl()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
+
+ // Event matching radarr's URL
+ AddSearchEvent(instanceUrl: radarr.Url.ToString(), instanceType: InstanceType.Radarr,
+ data: JsonSerializer.Serialize(new { InstanceName = "Radarr Event" }));
+ // Event matching sonarr's URL
+ AddSearchEvent(instanceUrl: sonarr.Url.ToString(), instanceType: InstanceType.Sonarr,
+ data: JsonSerializer.Serialize(new { InstanceName = "Sonarr Event" }));
+
+ var result = await _controller.GetEvents(instanceId: radarr.Id);
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
+ body.GetProperty("Items")[0].GetProperty("InstanceName").GetString().ShouldBe("Radarr Event");
+ }
+
+ [Fact]
+ public async Task GetEvents_WithCycleIdFilter_ReturnsOnlyMatchingCycle()
+ {
+ var cycleA = Guid.NewGuid();
+ var cycleB = Guid.NewGuid();
+
+ AddSearchEvent(cycleId: cycleA, data: JsonSerializer.Serialize(new { InstanceName = "Cycle A" }));
+ AddSearchEvent(cycleId: cycleB, data: JsonSerializer.Serialize(new { InstanceName = "Cycle B" }));
+
+ var result = await _controller.GetEvents(cycleId: cycleA);
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
+ body.GetProperty("Items")[0].GetProperty("InstanceName").GetString().ShouldBe("Cycle A");
+ }
+
+ [Fact]
+ public async Task GetEvents_WithSearchFilter_FiltersOnDataField()
+ {
+ AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = "Radarr", Items = new[] { "The Matrix" } }));
+ AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = "Sonarr", Items = new[] { "Breaking Bad" } }));
+
+ var result = await _controller.GetEvents(search: "matrix");
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task GetEvents_WithPagination_ReturnsCorrectPageAndCount()
+ {
+ for (int i = 0; i < 5; i++)
+ {
+ AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = $"Event {i}" }));
+ }
+
+ var result = await _controller.GetEvents(page: 2, pageSize: 2);
+ var body = GetResponseBody(result);
+
+ body.GetProperty("TotalCount").GetInt32().ShouldBe(5);
+ body.GetProperty("TotalPages").GetInt32().ShouldBe(3); // ceil(5/2) = 3
+ body.GetProperty("Page").GetInt32().ShouldBe(2);
+ body.GetProperty("Items").GetArrayLength().ShouldBe(2);
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private void AddSearchEvent(
+ string? data = null,
+ string? instanceUrl = null,
+ InstanceType? instanceType = null,
+ Guid? cycleId = null,
+ SearchCommandStatus? searchStatus = null)
+ {
+ _eventsContext.Events.Add(new AppEvent
+ {
+ EventType = EventType.SearchTriggered,
+ Message = "Search triggered",
+ Severity = EventSeverity.Information,
+ Data = data,
+ InstanceUrl = instanceUrl,
+ InstanceType = instanceType,
+ CycleId = cycleId,
+ SearchStatus = searchStatus,
+ Timestamp = DateTime.UtcNow
+ });
+ _eventsContext.SaveChanges();
+ }
+
+ #endregion
+}
diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerConfigControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerConfigControllerTests.cs
new file mode 100644
index 00000000..6f1a4893
--- /dev/null
+++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerConfigControllerTests.cs
@@ -0,0 +1,324 @@
+using Cleanuparr.Api.Features.Seeker.Contracts.Requests;
+using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
+using Cleanuparr.Api.Features.Seeker.Controllers;
+using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
+using Cleanuparr.Domain.Enums;
+using Cleanuparr.Infrastructure.Services.Interfaces;
+using Cleanuparr.Persistence;
+using Cleanuparr.Persistence.Models.Configuration.Seeker;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Shouldly;
+using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
+
+namespace Cleanuparr.Api.Tests.Features.Seeker;
+
+public class SeekerConfigControllerTests : IDisposable
+{
+ private readonly DataContext _dataContext;
+ private readonly ILogger _logger;
+ private readonly IJobManagementService _jobManagementService;
+ private readonly SeekerConfigController _controller;
+
+ public SeekerConfigControllerTests()
+ {
+ _dataContext = SeekerTestDataFactory.CreateDataContext();
+ _logger = Substitute.For>();
+ _jobManagementService = Substitute.For();
+ _controller = new SeekerConfigController(_logger, _dataContext, _jobManagementService);
+ }
+
+ public void Dispose()
+ {
+ _dataContext.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ #region GetSeekerConfig Tests
+
+ [Fact]
+ public async Task GetSeekerConfig_WithNoSeekerInstanceConfigs_ReturnsDefaults()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+
+ var result = await _controller.GetSeekerConfig();
+ var okResult = result.ShouldBeOfType();
+ var response = okResult.Value.ShouldBeOfType();
+
+ var instance = response.Instances.ShouldHaveSingleItem();
+ instance.ArrInstanceId.ShouldBe(radarr.Id);
+ instance.Enabled.ShouldBeFalse();
+ instance.SkipTags.ShouldBeEmpty();
+ instance.ActiveDownloadLimit.ShouldBe(3);
+ instance.MinCycleTimeDays.ShouldBe(7);
+ }
+
+ [Fact]
+ public async Task GetSeekerConfig_OnlyReturnsSonarrAndRadarrInstances()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
+ var lidarr = SeekerTestDataFactory.AddLidarrInstance(_dataContext);
+
+ var result = await _controller.GetSeekerConfig();
+ var okResult = result.ShouldBeOfType();
+ var response = okResult.Value.ShouldBeOfType();
+
+ response.Instances.Count.ShouldBe(2);
+ response.Instances.ShouldContain(i => i.ArrInstanceId == radarr.Id);
+ response.Instances.ShouldContain(i => i.ArrInstanceId == sonarr.Id);
+ response.Instances.ShouldNotContain(i => i.ArrInstanceId == lidarr.Id);
+ }
+
+ #endregion
+
+ #region UpdateSeekerConfig Tests
+
+ [Fact]
+ public async Task UpdateSeekerConfig_WithProactiveEnabledAndNoInstancesEnabled_ThrowsValidationException()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ var request = new UpdateSeekerConfigRequest
+ {
+ SearchEnabled = true,
+ SearchInterval = 5,
+ ProactiveSearchEnabled = true,
+ Instances =
+ [
+ new UpdateSeekerInstanceConfigRequest
+ {
+ ArrInstanceId = radarr.Id,
+ Enabled = false // No instances enabled
+ }
+ ]
+ };
+
+ await Should.ThrowAsync(() => _controller.UpdateSeekerConfig(request));
+ }
+
+ [Fact]
+ public async Task UpdateSeekerConfig_WhenIntervalChanges_ReschedulesSeeker()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ _dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
+ {
+ ArrInstanceId = radarr.Id,
+ Enabled = true
+ });
+ await _dataContext.SaveChangesAsync();
+
+ // Default interval is 3, change to 5
+ var request = new UpdateSeekerConfigRequest
+ {
+ SearchEnabled = true,
+ SearchInterval = 5,
+ ProactiveSearchEnabled = true,
+ Instances =
+ [
+ new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
+ ]
+ };
+
+ await _controller.UpdateSeekerConfig(request);
+
+ await _jobManagementService.Received(1)
+ .StartJob(JobType.Seeker, null, Arg.Any());
+ }
+
+ [Fact]
+ public async Task UpdateSeekerConfig_WhenIntervalUnchanged_DoesNotReschedule()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ _dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
+ {
+ ArrInstanceId = radarr.Id,
+ Enabled = true
+ });
+ await _dataContext.SaveChangesAsync();
+
+ // Keep interval at default (3)
+ var request = new UpdateSeekerConfigRequest
+ {
+ SearchEnabled = true,
+ SearchInterval = 3,
+ ProactiveSearchEnabled = true,
+ Instances =
+ [
+ new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
+ ]
+ };
+
+ await _controller.UpdateSeekerConfig(request);
+
+ await _jobManagementService.DidNotReceive()
+ .StartJob(Arg.Any(), null, Arg.Any());
+ }
+
+ [Fact]
+ public async Task UpdateSeekerConfig_WhenCustomFormatScoreEnabled_StartsAndTriggersSyncerJob()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ _dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
+ {
+ ArrInstanceId = radarr.Id,
+ Enabled = true
+ });
+ await _dataContext.SaveChangesAsync();
+
+ // UseCustomFormatScore was false (default), now enable it
+ var request = new UpdateSeekerConfigRequest
+ {
+ SearchEnabled = true,
+ SearchInterval = 3,
+ ProactiveSearchEnabled = true,
+ UseCustomFormatScore = true,
+ Instances =
+ [
+ new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
+ ]
+ };
+
+ await _controller.UpdateSeekerConfig(request);
+
+ await _jobManagementService.Received(1)
+ .StartJob(JobType.CustomFormatScoreSyncer, null, Arg.Any());
+ await _jobManagementService.Received(1)
+ .TriggerJobOnce(JobType.CustomFormatScoreSyncer);
+ }
+
+ [Fact]
+ public async Task UpdateSeekerConfig_WhenCustomFormatScoreDisabled_StopsSyncerJob()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ _dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
+ {
+ ArrInstanceId = radarr.Id,
+ Enabled = true
+ });
+ await _dataContext.SaveChangesAsync();
+
+ // First enable CF score
+ var config = await _dataContext.SeekerConfigs.FirstAsync();
+ config.UseCustomFormatScore = true;
+ await _dataContext.SaveChangesAsync();
+
+ // Now disable it
+ var request = new UpdateSeekerConfigRequest
+ {
+ SearchEnabled = true,
+ SearchInterval = 3,
+ ProactiveSearchEnabled = true,
+ UseCustomFormatScore = false,
+ Instances =
+ [
+ new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
+ ]
+ };
+
+ await _controller.UpdateSeekerConfig(request);
+
+ await _jobManagementService.Received(1)
+ .StopJob(JobType.CustomFormatScoreSyncer);
+ }
+
+ [Fact]
+ public async Task UpdateSeekerConfig_WhenSearchReenabledWithCustomFormatActive_TriggersSyncerOnce()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ _dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
+ {
+ ArrInstanceId = radarr.Id,
+ Enabled = true
+ });
+ await _dataContext.SaveChangesAsync();
+
+ // Set up state: CF score already enabled, search currently disabled
+ var config = await _dataContext.SeekerConfigs.FirstAsync();
+ config.UseCustomFormatScore = true;
+ config.SearchEnabled = false;
+ await _dataContext.SaveChangesAsync();
+
+ // Re-enable search
+ var request = new UpdateSeekerConfigRequest
+ {
+ SearchEnabled = true,
+ SearchInterval = 3,
+ ProactiveSearchEnabled = false,
+ UseCustomFormatScore = true,
+ Instances =
+ [
+ new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
+ ]
+ };
+
+ await _controller.UpdateSeekerConfig(request);
+
+ await _jobManagementService.Received(1)
+ .TriggerJobOnce(JobType.CustomFormatScoreSyncer);
+ }
+
+ [Fact]
+ public async Task UpdateSeekerConfig_SyncsExistingAndCreatesNewInstanceConfigs()
+ {
+ var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
+ var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
+
+ // Radarr already has a config
+ _dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
+ {
+ ArrInstanceId = radarr.Id,
+ Enabled = false,
+ SkipTags = ["old-tag"],
+ ActiveDownloadLimit = 2,
+ MinCycleTimeDays = 5
+ });
+ await _dataContext.SaveChangesAsync();
+
+ var request = new UpdateSeekerConfigRequest
+ {
+ SearchEnabled = true,
+ SearchInterval = 3,
+ ProactiveSearchEnabled = true,
+ Instances =
+ [
+ // Update existing radarr config
+ new UpdateSeekerInstanceConfigRequest
+ {
+ ArrInstanceId = radarr.Id,
+ Enabled = true,
+ SkipTags = ["new-tag"],
+ ActiveDownloadLimit = 5,
+ MinCycleTimeDays = 14
+ },
+ // Create new sonarr config
+ new UpdateSeekerInstanceConfigRequest
+ {
+ ArrInstanceId = sonarr.Id,
+ Enabled = true,
+ SkipTags = ["sonarr-tag"],
+ ActiveDownloadLimit = 3,
+ MinCycleTimeDays = 7
+ }
+ ]
+ };
+
+ await _controller.UpdateSeekerConfig(request);
+
+ var configs = await _dataContext.SeekerInstanceConfigs.ToListAsync();
+ configs.Count.ShouldBe(2);
+
+ var radarrConfig = configs.First(c => c.ArrInstanceId == radarr.Id);
+ radarrConfig.Enabled.ShouldBeTrue();
+ radarrConfig.SkipTags.ShouldContain("new-tag");
+ radarrConfig.ActiveDownloadLimit.ShouldBe(5);
+ radarrConfig.MinCycleTimeDays.ShouldBe(14);
+
+ var sonarrConfig = configs.First(c => c.ArrInstanceId == sonarr.Id);
+ sonarrConfig.Enabled.ShouldBeTrue();
+ sonarrConfig.SkipTags.ShouldContain("sonarr-tag");
+ }
+
+ #endregion
+}
diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/TestHelpers/SeekerTestDataFactory.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/TestHelpers/SeekerTestDataFactory.cs
new file mode 100644
index 00000000..7379b2e8
--- /dev/null
+++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/TestHelpers/SeekerTestDataFactory.cs
@@ -0,0 +1,166 @@
+using Cleanuparr.Domain.Enums;
+using Cleanuparr.Persistence;
+using Cleanuparr.Persistence.Models.Configuration;
+using Cleanuparr.Persistence.Models.Configuration.Arr;
+using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
+using Cleanuparr.Persistence.Models.Configuration.General;
+using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
+using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
+using Cleanuparr.Persistence.Models.Configuration.Seeker;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+
+namespace Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
+
+///
+/// Factory for creating SQLite in-memory contexts for Seeker controller tests
+///
+public static class SeekerTestDataFactory
+{
+ public static DataContext CreateDataContext()
+ {
+ var connection = new SqliteConnection("DataSource=:memory:");
+ connection.Open();
+
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite(connection)
+ .Options;
+
+ var context = new DataContext(options);
+ context.Database.EnsureCreated();
+
+ SeedDefaultData(context);
+ return context;
+ }
+
+ public static EventsContext CreateEventsContext()
+ {
+ var connection = new SqliteConnection("DataSource=:memory:");
+ connection.Open();
+
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite(connection)
+ .Options;
+
+ var context = new EventsContext(options);
+ context.Database.EnsureCreated();
+ return context;
+ }
+
+ private static void SeedDefaultData(DataContext context)
+ {
+ context.GeneralConfigs.Add(new GeneralConfig
+ {
+ Id = Guid.NewGuid(),
+ DryRun = false,
+ IgnoredDownloads = [],
+ Log = new LoggingConfig()
+ });
+
+ context.ArrConfigs.AddRange(
+ new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr, Instances = [], FailedImportMaxStrikes = 3 },
+ new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
+ new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
+ new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
+ new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 }
+ );
+
+ context.QueueCleanerConfigs.Add(new QueueCleanerConfig
+ {
+ Id = Guid.NewGuid(),
+ IgnoredDownloads = [],
+ FailedImport = new FailedImportConfig()
+ });
+
+ context.ContentBlockerConfigs.Add(new ContentBlockerConfig
+ {
+ Id = Guid.NewGuid(),
+ IgnoredDownloads = [],
+ DeletePrivate = false,
+ Sonarr = new BlocklistSettings { Enabled = false },
+ Radarr = new BlocklistSettings { Enabled = false },
+ Lidarr = new BlocklistSettings { Enabled = false },
+ Readarr = new BlocklistSettings { Enabled = false },
+ Whisparr = new BlocklistSettings { Enabled = false }
+ });
+
+ context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
+ {
+ Id = Guid.NewGuid(),
+ IgnoredDownloads = [],
+ Categories = [],
+ UnlinkedEnabled = false,
+ UnlinkedTargetCategory = "",
+ UnlinkedCategories = []
+ });
+
+ context.SeekerConfigs.Add(new SeekerConfig
+ {
+ Id = Guid.NewGuid(),
+ SearchEnabled = true,
+ ProactiveSearchEnabled = false
+ });
+
+ context.SaveChanges();
+ }
+
+ public static ArrInstance AddSonarrInstance(DataContext context, bool enabled = true)
+ {
+ var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Sonarr);
+ var instance = new ArrInstance
+ {
+ Id = Guid.NewGuid(),
+ Name = "Test Sonarr",
+ Url = new Uri("http://sonarr:8989"),
+ ApiKey = "test-api-key",
+ Enabled = enabled,
+ ArrConfigId = arrConfig.Id,
+ ArrConfig = arrConfig
+ };
+
+ arrConfig.Instances.Add(instance);
+ context.ArrInstances.Add(instance);
+ context.SaveChanges();
+ return instance;
+ }
+
+ public static ArrInstance AddRadarrInstance(DataContext context, bool enabled = true)
+ {
+ var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Radarr);
+ var instance = new ArrInstance
+ {
+ Id = Guid.NewGuid(),
+ Name = "Test Radarr",
+ Url = new Uri("http://radarr:7878"),
+ ApiKey = "test-api-key",
+ Enabled = enabled,
+ ArrConfigId = arrConfig.Id,
+ ArrConfig = arrConfig
+ };
+
+ arrConfig.Instances.Add(instance);
+ context.ArrInstances.Add(instance);
+ context.SaveChanges();
+ return instance;
+ }
+
+ public static ArrInstance AddLidarrInstance(DataContext context, bool enabled = true)
+ {
+ var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Lidarr);
+ var instance = new ArrInstance
+ {
+ Id = Guid.NewGuid(),
+ Name = "Test Lidarr",
+ Url = new Uri("http://lidarr:8686"),
+ ApiKey = "test-api-key",
+ Enabled = enabled,
+ ArrConfigId = arrConfig.Id,
+ ArrConfig = arrConfig
+ };
+
+ arrConfig.Instances.Add(instance);
+ context.ArrInstances.Add(instance);
+ context.SaveChanges();
+ return instance;
+ }
+}