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; + } +}