diff --git a/code/backend/Cleanuparr.Api.Tests/Features/General/GeneralConfigControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/General/GeneralConfigControllerTests.cs index 341a85e6..d167162c 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/General/GeneralConfigControllerTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/General/GeneralConfigControllerTests.cs @@ -2,6 +2,8 @@ using Cleanuparr.Api.Features.General.Contracts.Requests; using Cleanuparr.Api.Features.General.Contracts.Responses; using Cleanuparr.Api.Features.General.Controllers; using Cleanuparr.Api.Tests.TestHelpers; +using Cleanuparr.Infrastructure.Features.DryRun; +using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.General; @@ -21,6 +23,7 @@ public class GeneralConfigControllerTests : IDisposable private readonly DataContext _dataContext; private readonly EventsContext _eventsContext; private readonly IDynamicHttpClientFactory _dynamicHttpClientFactory; + private readonly DryRunActivity _dryRunActivity; private readonly GeneralConfigController _controller; public GeneralConfigControllerTests() @@ -28,9 +31,10 @@ public class GeneralConfigControllerTests : IDisposable _dataContext = ConfigControllerTestDataFactory.CreateDataContext(); _eventsContext = ConfigControllerTestDataFactory.CreateEventsContext(); _dynamicHttpClientFactory = Substitute.For(); + _dryRunActivity = new DryRunActivity(); var logger = Substitute.For>(); - _controller = new GeneralConfigController(logger, _dataContext); + _controller = new GeneralConfigController(logger, _dataContext, _dryRunActivity); // Mount a DefaultHttpContext with a ServiceProvider that resolves IDynamicHttpClientFactory var services = new ServiceCollection(); @@ -39,12 +43,15 @@ public class GeneralConfigControllerTests : IDisposable { HttpContext = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }, }; + + Striker.RecurringHashes.Clear(); } public void Dispose() { _dataContext.Dispose(); _eventsContext.Dispose(); + Striker.RecurringHashes.Clear(); GC.SuppressFinalize(this); } @@ -79,7 +86,7 @@ public class GeneralConfigControllerTests : IDisposable }; // Act - var result = await _controller.UpdateGeneralConfig(request, _eventsContext); + var result = await _controller.UpdateGeneralConfig(request); // Assert result.ShouldBeOfType(); @@ -108,7 +115,7 @@ public class GeneralConfigControllerTests : IDisposable }; // Act / Assert - await Should.ThrowAsync(() => _controller.UpdateGeneralConfig(request, _eventsContext)); + await Should.ThrowAsync(() => _controller.UpdateGeneralConfig(request)); } [Fact] @@ -125,7 +132,7 @@ public class GeneralConfigControllerTests : IDisposable }; // Act / Assert - await Should.ThrowAsync(() => _controller.UpdateGeneralConfig(request, _eventsContext)); + await Should.ThrowAsync(() => _controller.UpdateGeneralConfig(request)); } [Fact] @@ -144,7 +151,60 @@ public class GeneralConfigControllerTests : IDisposable }; // Act / Assert - await Should.ThrowAsync(() => _controller.UpdateGeneralConfig(request, _eventsContext)); + await Should.ThrowAsync(() => _controller.UpdateGeneralConfig(request)); + } + + [Fact] + public async Task UpdateGeneralConfig_DryRunDisabled_RequestsAPurgeAfterSaving() + { + // Arrange + var config = await _dataContext.GeneralConfigs.FirstAsync(); + config.DryRun = true; + await _dataContext.SaveChangesAsync(); + + var request = new UpdateGeneralConfigRequest + { + DryRun = false, + HttpTimeout = 60, + StrikeInactivityWindowHours = 24, + Log = MatchingLogRequest(config.Log), + Auth = new UpdateAuthConfigRequest(), + }; + + // Act + await _controller.UpdateGeneralConfig(request); + + // Assert + (await _dryRunActivity.WaitForPurgeRequestAsync(CancellationToken.None)).ShouldBeTrue(); + + var saved = await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync(); + saved.DryRun.ShouldBeFalse(); + } + + [Fact] + public async Task UpdateGeneralConfig_DryRunStaysOnOrStaysOff_RequestsNoPurge() + { + // Arrange - dry run stays on + var config = await _dataContext.GeneralConfigs.FirstAsync(); + config.DryRun = true; + await _dataContext.SaveChangesAsync(); + + var request = new UpdateGeneralConfigRequest + { + DryRun = true, + HttpTimeout = 60, + StrikeInactivityWindowHours = 24, + Log = MatchingLogRequest(config.Log), + Auth = new UpdateAuthConfigRequest(), + }; + + // Act + await _controller.UpdateGeneralConfig(request); + + // Assert + Task waitTask = _dryRunActivity.WaitForPurgeRequestAsync(CancellationToken.None).AsTask(); + await Task.Yield(); + waitTask.IsCompleted.ShouldBeFalse(); } [Fact] diff --git a/code/backend/Cleanuparr.Api.Tests/Features/General/GeneralConfigResponseContractTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/General/GeneralConfigResponseContractTests.cs index dcab3b71..82784208 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/General/GeneralConfigResponseContractTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/General/GeneralConfigResponseContractTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Cleanuparr.Api.Features.General.Controllers; using Cleanuparr.Api.Tests.TestHelpers; +using Cleanuparr.Infrastructure.Features.DryRun; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.General; using Microsoft.AspNetCore.Mvc; @@ -26,7 +27,8 @@ public class GeneralConfigResponseContractTests : IDisposable _controller = new GeneralConfigController( Substitute.For>(), - _dataContext); + _dataContext, + new DryRunActivity()); ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller); } diff --git a/code/backend/Cleanuparr.Api.Tests/Jobs/GenericJobTests.cs b/code/backend/Cleanuparr.Api.Tests/Jobs/GenericJobTests.cs index e0953a54..09de3870 100644 --- a/code/backend/Cleanuparr.Api.Tests/Jobs/GenericJobTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Jobs/GenericJobTests.cs @@ -2,10 +2,14 @@ using Cleanuparr.Api.Hubs; using Cleanuparr.Api.Jobs; using Cleanuparr.Api.Tests.TestHelpers; using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Infrastructure.Features.DryRun; using Cleanuparr.Infrastructure.Features.Jobs; +using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Infrastructure.Models; using Cleanuparr.Infrastructure.Services.Interfaces; using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.General; using Cleanuparr.Persistence.Models.State; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -22,12 +26,16 @@ namespace Cleanuparr.Api.Tests.Jobs; public sealed class GenericJobTests : IDisposable { private readonly EventsContext _eventsContext; + private readonly DataContext _dataContext; + private readonly DryRunActivity _dryRunActivity; private readonly ServiceProvider _serviceProvider; private readonly Seeker _handler = new(); public GenericJobTests() { _eventsContext = ConfigControllerTestDataFactory.CreateEventsContext(); + _dataContext = ConfigControllerTestDataFactory.CreateDataContext(); + _dryRunActivity = new DryRunActivity(); IJobManagementService jobManagementService = Substitute.For(); jobManagementService.GetJob(Arg.Any()).Returns(new JobInfo { JobType = nameof(JobType.Seeker) }); @@ -37,6 +45,8 @@ public sealed class GenericJobTests : IDisposable services.AddSingleton(Substitute.For>()); services.AddSingleton(jobManagementService); services.AddSingleton(_handler); + services.AddScoped(_ => + new DryRunInterceptor(Substitute.For>(), _dataContext)); _serviceProvider = services.BuildServiceProvider(); } @@ -44,6 +54,7 @@ public sealed class GenericJobTests : IDisposable { _serviceProvider.Dispose(); _eventsContext.Dispose(); + _dataContext.Dispose(); } private static readonly DateTimeOffset Now = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); @@ -51,7 +62,8 @@ public sealed class GenericJobTests : IDisposable private GenericJob BuildJob() => new( Substitute.For>>(), _serviceProvider.GetRequiredService(), - new FakeTimeProvider(Now)); + new FakeTimeProvider(Now), + _dryRunActivity); [Fact] public async Task Execute_StampsTheRunAsCompleted() @@ -76,6 +88,85 @@ public sealed class GenericJobTests : IDisposable run.CompletedAt!.Value.ShouldBe(Now); } + [Fact] + public async Task Execute_CapturesDryRunOnceForTheWholeRun() + { + GeneralConfig config = await _dataContext.GeneralConfigs.SingleAsync(); + config.DryRun = true; + await _dataContext.SaveChangesAsync(); + + await BuildJob().Execute(Substitute.For()); + + _handler.ObservedDryRun.ShouldBe(true); + } + + [Fact] + public async Task Execute_DoesNotStickWhenTheRunStartedLive() + { + GeneralConfig config = await _dataContext.GeneralConfigs.SingleAsync(); + config.DryRun = false; + await _dataContext.SaveChangesAsync(); + + await BuildJob().Execute(Substitute.For()); + + _handler.ObservedDryRun.ShouldBe(false); + } + + [Fact] + public async Task Execute_WhenRunStartedDry_RequestsAPurgeOnceFinished() + { + GeneralConfig config = await _dataContext.GeneralConfigs.SingleAsync(); + config.DryRun = true; + await _dataContext.SaveChangesAsync(); + + await BuildJob().Execute(Substitute.For()); + + (await _dryRunActivity.WaitForPurgeRequestAsync(CancellationToken.None)).ShouldBeTrue(); + } + + [Fact] + public async Task Execute_WhenRunStartedLive_RequestsNoPurge() + { + GeneralConfig config = await _dataContext.GeneralConfigs.SingleAsync(); + config.DryRun = false; + await _dataContext.SaveChangesAsync(); + + await BuildJob().Execute(Substitute.For()); + + Task waitTask = _dryRunActivity.WaitForPurgeRequestAsync(CancellationToken.None).AsTask(); + await Task.Yield(); + + waitTask.IsCompleted.ShouldBeFalse(); + } + + [Fact] + public async Task Execute_WhenRunStartedDryAndHandlerThrows_ExitsActivity() + { + GeneralConfig config = await _dataContext.GeneralConfigs.SingleAsync(); + config.DryRun = true; + await _dataContext.SaveChangesAsync(); + _handler.Throw = new InvalidOperationException("handler exploded"); + + await BuildJob().Execute(Substitute.For()); + + _dryRunActivity.IsActive.ShouldBeFalse(); + } + + [Fact] + public async Task Execute_WhenRunStartedLive_NeverEntersActivity() + { + GeneralConfig config = await _dataContext.GeneralConfigs.SingleAsync(); + config.DryRun = false; + await _dataContext.SaveChangesAsync(); + bool activeDuringExecution = false; + _handler.OnExecute = () => activeDuringExecution = _dryRunActivity.IsActive; + + await BuildJob().Execute(Substitute.For()); + + activeDuringExecution.ShouldBeFalse(); + _dryRunActivity.IsActive.ShouldBeFalse(); + } + /// /// Named after a member because the job parses its type name. /// @@ -83,7 +174,15 @@ public sealed class GenericJobTests : IDisposable { public Exception? Throw { get; set; } - public Task ExecuteAsync(CancellationToken cancellationToken = default) => - Throw is null ? Task.CompletedTask : Task.FromException(Throw); + public bool ObservedDryRun { get; private set; } + + public Action? OnExecute { get; set; } + + public Task ExecuteAsync(CancellationToken cancellationToken = default) + { + ObservedDryRun = ContextProvider.IsDryRunSticky(); + OnExecute?.Invoke(); + return Throw is null ? Task.CompletedTask : Task.FromException(Throw); + } } } diff --git a/code/backend/Cleanuparr.Api.Tests/Json/RemovalRequestSerializationTests.cs b/code/backend/Cleanuparr.Api.Tests/Json/RemovalRequestSerializationTests.cs index 81564b00..25295628 100644 --- a/code/backend/Cleanuparr.Api.Tests/Json/RemovalRequestSerializationTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Json/RemovalRequestSerializationTests.cs @@ -51,6 +51,7 @@ public class RemovalRequestSerializationTests }, DeleteReason = DeleteReason.Stalled, JobRunId = Guid.NewGuid(), + IsDryRun = false, }; } diff --git a/code/backend/Cleanuparr.Api/Auth/ApiKeyAuthenticationDefaults.cs b/code/backend/Cleanuparr.Api/Auth/ApiKeyAuthenticationDefaults.cs new file mode 100644 index 00000000..a05b7c84 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Auth/ApiKeyAuthenticationDefaults.cs @@ -0,0 +1,22 @@ +namespace Cleanuparr.Api.Auth; + +/// +/// Names used by the API key authentication scheme. +/// +public static class ApiKeyAuthenticationDefaults +{ + /// + /// Scheme name registered for API key authentication. + /// + public const string AuthenticationScheme = "ApiKey"; + + /// + /// Request header that carries the API key. + /// + public const string HeaderName = "X-Api-Key"; + + /// + /// Query string parameter that carries the API key. + /// + public const string QueryParameterName = "apikey"; +} diff --git a/code/backend/Cleanuparr.Api/Auth/ApiKeyAuthenticationHandler.cs b/code/backend/Cleanuparr.Api/Auth/ApiKeyAuthenticationHandler.cs index 3a137a27..ab439856 100644 --- a/code/backend/Cleanuparr.Api/Auth/ApiKeyAuthenticationHandler.cs +++ b/code/backend/Cleanuparr.Api/Auth/ApiKeyAuthenticationHandler.cs @@ -7,13 +7,6 @@ using Microsoft.Extensions.Options; namespace Cleanuparr.Api.Auth; -public static class ApiKeyAuthenticationDefaults -{ - public const string AuthenticationScheme = "ApiKey"; - public const string HeaderName = "X-Api-Key"; - public const string QueryParameterName = "apikey"; -} - public class ApiKeyAuthenticationHandler : AuthenticationHandler { public ApiKeyAuthenticationHandler( diff --git a/code/backend/Cleanuparr.Api/Auth/TrustedNetworkAuthenticationDefaults.cs b/code/backend/Cleanuparr.Api/Auth/TrustedNetworkAuthenticationDefaults.cs new file mode 100644 index 00000000..5386cadf --- /dev/null +++ b/code/backend/Cleanuparr.Api/Auth/TrustedNetworkAuthenticationDefaults.cs @@ -0,0 +1,12 @@ +namespace Cleanuparr.Api.Auth; + +/// +/// Names used by the trusted network authentication scheme. +/// +public static class TrustedNetworkAuthenticationDefaults +{ + /// + /// Scheme name registered for trusted network authentication. + /// + public const string AuthenticationScheme = "TrustedNetwork"; +} diff --git a/code/backend/Cleanuparr.Api/Auth/TrustedNetworkAuthenticationHandler.cs b/code/backend/Cleanuparr.Api/Auth/TrustedNetworkAuthenticationHandler.cs index 1ec69db0..e9c36a73 100644 --- a/code/backend/Cleanuparr.Api/Auth/TrustedNetworkAuthenticationHandler.cs +++ b/code/backend/Cleanuparr.Api/Auth/TrustedNetworkAuthenticationHandler.cs @@ -9,11 +9,6 @@ using Microsoft.Extensions.Options; namespace Cleanuparr.Api.Auth; -public static class TrustedNetworkAuthenticationDefaults -{ - public const string AuthenticationScheme = "TrustedNetwork"; -} - public class TrustedNetworkAuthenticationHandler : AuthenticationHandler { private readonly DataContext _dataContext; diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs index ec0edaed..80ec759e 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/ServicesDI.cs @@ -12,6 +12,7 @@ using Cleanuparr.Infrastructure.Features.DownloadCleaner.Services; using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Infrastructure.Features.DownloadRemover; using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces; +using Cleanuparr.Infrastructure.Features.DryRun; using Cleanuparr.Infrastructure.Features.Files; using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Features.Jobs; @@ -44,6 +45,8 @@ public static class ServicesDI .AddScoped() .AddHostedService() .AddScoped() + .AddScoped() + .AddSingleton() .AddScoped() .AddScoped() .AddScoped() diff --git a/code/backend/Cleanuparr.Api/Features/General/Controllers/GeneralConfigController.cs b/code/backend/Cleanuparr.Api/Features/General/Controllers/GeneralConfigController.cs index 7643f615..b1f6dfa9 100644 --- a/code/backend/Cleanuparr.Api/Features/General/Controllers/GeneralConfigController.cs +++ b/code/backend/Cleanuparr.Api/Features/General/Controllers/GeneralConfigController.cs @@ -1,9 +1,9 @@ -using System; using System.Linq; using System.Threading.Tasks; using Cleanuparr.Api.Features.General.Contracts.Requests; using Cleanuparr.Api.Features.General.Contracts.Responses; +using Cleanuparr.Infrastructure.Features.DryRun; using Cleanuparr.Persistence.Models.Configuration.General; using Cleanuparr.Persistence; using Microsoft.AspNetCore.Authorization; @@ -20,13 +20,16 @@ public sealed class GeneralConfigController : ControllerBase { private readonly ILogger _logger; private readonly DataContext _dataContext; + private readonly DryRunActivity _dryRunActivity; public GeneralConfigController( ILogger logger, - DataContext dataContext) + DataContext dataContext, + DryRunActivity dryRunActivity) { _logger = logger; _dataContext = dataContext; + _dryRunActivity = dryRunActivity; } [HttpGet("general")] @@ -48,8 +51,7 @@ public sealed class GeneralConfigController : ControllerBase [HttpPut("general")] public async Task UpdateGeneralConfig( - [FromBody] UpdateGeneralConfigRequest request, - [FromServices] EventsContext eventsContext) + [FromBody] UpdateGeneralConfigRequest request) { await DataContext.Lock.WaitAsync(); try @@ -61,42 +63,13 @@ public sealed class GeneralConfigController : ControllerBase request.ApplyTo(config, HttpContext.RequestServices, _logger); + bool dryRunTurnedOff = wasDryRun && !config.DryRun; + await _dataContext.SaveChangesAsync(); - if (wasDryRun && !config.DryRun) + if (dryRunTurnedOff) { - await using var transaction = await eventsContext.Database.BeginTransactionAsync(); - - try - { - var deletedStrikes = await eventsContext.Strikes - .Where(s => s.IsDryRun) - .ExecuteDeleteAsync(); - var deletedEvents = await eventsContext.Events - .Where(e => e.IsDryRun) - .ExecuteDeleteAsync(); - var deletedManualEvents = await eventsContext.ManualEvents - .Where(e => e.IsDryRun) - .ExecuteDeleteAsync(); - var deletedItems = await eventsContext.DownloadItems - .Where(d => !d.Strikes.Any()) - .ExecuteDeleteAsync(); - - var deletedHistory = await eventsContext.SeekerHistory - .Where(h => h.IsDryRun) - .ExecuteDeleteAsync(); - - _logger.LogWarning( - "Dry run disabled — purged dry-run data: {Strikes} strikes, {Events} events, {ManualEvents} manual events, {Items} orphaned download items, {History} search history entries removed", - deletedStrikes, deletedEvents, deletedManualEvents, deletedItems, deletedHistory); - - await transaction.CommitAsync(); - } - catch - { - await transaction.RollbackAsync(); - throw; - } + _dryRunActivity.RequestPurge(); } return Ok(new { Message = "General configuration updated successfully" }); diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/CustomFormatScoreStatsResponse.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/CustomFormatScoreStatsResponse.cs index 9d1362ba..f33d8bad 100644 --- a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/CustomFormatScoreStatsResponse.cs +++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/CustomFormatScoreStatsResponse.cs @@ -10,16 +10,3 @@ public sealed record CustomFormatScoreStatsResponse public int RecentUpgrades { get; init; } public List PerInstanceStats { get; init; } = []; } - -public sealed record InstanceCfScoreStat -{ - public Guid InstanceId { get; init; } - public string InstanceName { get; init; } = string.Empty; - public string InstanceType { get; init; } = string.Empty; - public int TotalTracked { get; init; } - public int BelowCutoff { get; init; } - public int AtOrAboveCutoff { get; init; } - public int Monitored { get; init; } - public int Unmonitored { get; init; } - public int RecentUpgrades { get; init; } -} diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/InstanceCfScoreStat.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/InstanceCfScoreStat.cs new file mode 100644 index 00000000..540c8df4 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/InstanceCfScoreStat.cs @@ -0,0 +1,52 @@ +namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses; + +/// +/// Custom format score statistics for one arr instance. +/// +public sealed record InstanceCfScoreStat +{ + /// + /// Unique identifier of the *arr instance. + /// + public Guid InstanceId { get; init; } + + /// + /// Display name of the *arr instance. + /// + public string InstanceName { get; init; } = string.Empty; + + /// + /// Arr type of the instance, such as Sonarr or Radarr. + /// + public string InstanceType { get; init; } = string.Empty; + + /// + /// Total number of items tracked by this instance. + /// + public int TotalTracked { get; init; } + + /// + /// Number of items with custom format score below cutoff. + /// + public int BelowCutoff { get; init; } + + /// + /// Number of items with custom format score at or above cutoff. + /// + public int AtOrAboveCutoff { get; init; } + + /// + /// Number of monitored items. + /// + public int Monitored { get; init; } + + /// + /// Number of unmonitored items. + /// + public int Unmonitored { get; init; } + + /// + /// Score increases found in the recorded score history. + /// + public int RecentUpgrades { get; init; } +} diff --git a/code/backend/Cleanuparr.Api/Jobs/GenericJob.cs b/code/backend/Cleanuparr.Api/Jobs/GenericJob.cs index f31e9a10..a473d945 100644 --- a/code/backend/Cleanuparr.Api/Jobs/GenericJob.cs +++ b/code/backend/Cleanuparr.Api/Jobs/GenericJob.cs @@ -1,8 +1,10 @@ using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Infrastructure.Features.DryRun; using Cleanuparr.Infrastructure.Features.Jobs; using Cleanuparr.Infrastructure.Helpers; using Cleanuparr.Api.Hubs; +using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Infrastructure.Models; using Cleanuparr.Infrastructure.Services.Interfaces; using Cleanuparr.Persistence; @@ -20,12 +22,18 @@ public sealed class GenericJob : IJob private readonly ILogger> _logger; private readonly IServiceScopeFactory _scopeFactory; private readonly TimeProvider _timeProvider; + private readonly DryRunActivity _dryRunActivity; - public GenericJob(ILogger> logger, IServiceScopeFactory scopeFactory, TimeProvider timeProvider) + public GenericJob( + ILogger> logger, + IServiceScopeFactory scopeFactory, + TimeProvider timeProvider, + DryRunActivity dryRunActivity) { _logger = logger; _scopeFactory = scopeFactory; _timeProvider = timeProvider; + _dryRunActivity = dryRunActivity; } public async Task Execute(IJobExecutionContext context) @@ -35,6 +43,7 @@ public sealed class GenericJob : IJob Guid jobRunId = Guid.CreateVersion7(); JobType jobType = Enum.Parse(typeof(T).Name); JobRunStatus? status = null; + bool trackedDryRun = false; try { @@ -42,12 +51,22 @@ public sealed class GenericJob : IJob var eventsContext = scope.ServiceProvider.GetRequiredService(); var hubContext = scope.ServiceProvider.GetRequiredService>(); var jobManagementService = scope.ServiceProvider.GetRequiredService(); + var dryRunInterceptor = scope.ServiceProvider.GetRequiredService(); var jobRun = new JobRun { Id = jobRunId, Type = jobType }; eventsContext.JobRuns.Add(jobRun); await eventsContext.SaveChangesAsync(); ContextProvider.SetJobRunId(jobRunId); + bool isDryRun = await dryRunInterceptor.IsDryRunEnabled(); + ContextProvider.SetDryRun(isDryRun); + + if (isDryRun) + { + await _dryRunActivity.EnterAsync(); + trackedDryRun = true; + } + using var __ = LogContext.PushProperty(LogProperties.JobRunId, jobRunId.ToString()); await BroadcastJobStatus(hubContext, jobManagementService, jobType, false); @@ -65,6 +84,12 @@ public sealed class GenericJob : IJob } finally { + // Exit first so a failed job-run save can't leave the count stuck. + if (trackedDryRun) + { + _dryRunActivity.Exit(); + } + await using var finalScope = _scopeFactory.CreateAsyncScope(); var eventsContext = finalScope.ServiceProvider.GetRequiredService(); var jobRun = await eventsContext.JobRuns.FindAsync(jobRunId); diff --git a/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DownloadStatus.cs b/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DownloadStatus.cs index 90897301..02d37ae7 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DownloadStatus.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/DownloadStatus.cs @@ -93,14 +93,3 @@ public sealed record DownloadStatus [JsonPropertyName("download_location")] public string DownloadLocation { get; init; } = string.Empty; } - -/// -/// A tracker of a torrent in Deluge. -/// -public sealed record Tracker -{ - /// - /// The address of the tracker. - /// - public string Url { get; init; } = string.Empty; -} diff --git a/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/Tracker.cs b/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/Tracker.cs new file mode 100644 index 00000000..fdf580be --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Deluge/Response/Tracker.cs @@ -0,0 +1,12 @@ +namespace Cleanuparr.Domain.Entities.Deluge.Response; + +/// +/// A tracker of a torrent in Deluge. +/// +public sealed record Tracker +{ + /// + /// The address of the tracker. + /// + public string Url { get; init; } = string.Empty; +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupLogicTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupLogicTests.cs index 43a09fe9..86c1d632 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupLogicTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupLogicTests.cs @@ -1,5 +1,6 @@ using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events; +using Cleanuparr.Infrastructure.Features.DryRun; using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Events; @@ -40,7 +41,8 @@ public class EventCleanupLogicTests : IDisposable _service = new EventCleanupService( Substitute.For>(), _serviceProvider.GetRequiredService(), - new FakeTimeProvider(Now)); + new FakeTimeProvider(Now), + new DryRunActivity()); } public void Dispose() diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceIntegrationTests.cs index e9263d2c..0a79d229 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceIntegrationTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceIntegrationTests.cs @@ -1,5 +1,6 @@ using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events; +using Cleanuparr.Infrastructure.Features.DryRun; using Cleanuparr.Infrastructure.Tests.TestHelpers; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Events; @@ -87,7 +88,7 @@ public class EventCleanupServiceIntegrationTests : IDisposable { // Arrange var scopeFactory = _serviceProvider.GetRequiredService(); - var service = new EventCleanupService(_logger, scopeFactory, TimeProvider.System); + EventCleanupService service = new EventCleanupService(_logger, scopeFactory, TimeProvider.System, new DryRunActivity()); var cts = new CancellationTokenSource(); // Act @@ -110,7 +111,7 @@ public class EventCleanupServiceIntegrationTests : IDisposable // Note: In-memory provider doesn't support ExecuteDeleteAsync, // so the cleanup will fail. This test verifies the service handles errors gracefully. var scopeFactory = _serviceProvider.GetRequiredService(); - var service = new EventCleanupService(_logger, scopeFactory, TimeProvider.System); + EventCleanupService service = new EventCleanupService(_logger, scopeFactory, TimeProvider.System, new DryRunActivity()); var cts = new CancellationTokenSource(); // Act diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceTests.cs index 920d9473..f5b945c6 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventCleanupServiceTests.cs @@ -1,11 +1,15 @@ using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events; +using Cleanuparr.Infrastructure.Features.DryRun; using Cleanuparr.Infrastructure.Tests.TestHelpers; using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.General; using Cleanuparr.Persistence.Models.Events; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Time.Testing; using NSubstitute; using Shouldly; using Xunit; @@ -45,7 +49,7 @@ public class EventCleanupServiceTests : IDisposable { // Arrange var scopeFactory = _serviceProvider.GetRequiredService(); - var service = new EventCleanupService(_logger, scopeFactory, TimeProvider.System); + EventCleanupService service = new EventCleanupService(_logger, scopeFactory, TimeProvider.System, new DryRunActivity()); var cts = new CancellationTokenSource(); // Act - start and immediately cancel @@ -63,7 +67,7 @@ public class EventCleanupServiceTests : IDisposable { // Arrange var scopeFactory = _serviceProvider.GetRequiredService(); - var service = new EventCleanupService(_logger, scopeFactory, TimeProvider.System); + EventCleanupService service = new EventCleanupService(_logger, scopeFactory, TimeProvider.System, new DryRunActivity()); var cts = new CancellationTokenSource(); // Act @@ -83,7 +87,7 @@ public class EventCleanupServiceTests : IDisposable var scopeFactory = _serviceProvider.GetRequiredService(); // Act - var service = new EventCleanupService(_logger, scopeFactory, TimeProvider.System); + EventCleanupService service = new EventCleanupService(_logger, scopeFactory, TimeProvider.System, new DryRunActivity()); // Assert - service should be created without exception service.ShouldNotBeNull(); @@ -94,7 +98,7 @@ public class EventCleanupServiceTests : IDisposable { // Arrange var scopeFactory = _serviceProvider.GetRequiredService(); - var service = new EventCleanupService(_logger, scopeFactory, TimeProvider.System); + EventCleanupService service = new EventCleanupService(_logger, scopeFactory, TimeProvider.System, new DryRunActivity()); var cts = new CancellationTokenSource(); // Act - cancel immediately @@ -108,4 +112,174 @@ public class EventCleanupServiceTests : IDisposable // Assert - should have logged stopped message _logger.HasLogContaining(LogLevel.Information, "stopped").ShouldBeTrue(); } + + [Fact] + public async Task PerformCleanupAsync_DryRunOff_PurgesDryRunData() + { + // Arrange + IDryRunPurger dryRunPurger = Substitute.For(); + IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: false, dryRunPurger); + EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService(), TimeProvider.System, new DryRunActivity()); + + // Act + await service.PerformCleanupAsync(); + + // Assert + await dryRunPurger.Received(1).PurgeAsync(); + } + + [Fact] + public async Task PerformCleanupAsync_DryRunOn_DoesNotPurge() + { + // Arrange + IDryRunPurger dryRunPurger = Substitute.For(); + IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: true, dryRunPurger); + EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService(), TimeProvider.System, new DryRunActivity()); + + // Act + await service.PerformCleanupAsync(); + + // Assert + await dryRunPurger.DidNotReceive().PurgeAsync(); + } + + [Fact] + public async Task PerformCleanupAsync_PurgeThrows_ReturnsTrue() + { + // Arrange + IDryRunPurger dryRunPurger = Substitute.For(); + dryRunPurger.PurgeAsync().Returns(Task.FromException(new InvalidOperationException("boom"))); + IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: false, dryRunPurger); + EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService(), TimeProvider.System, new DryRunActivity()); + + // Act + bool purgeFailed = await service.PerformCleanupAsync(); + + // Assert + purgeFailed.ShouldBeTrue(); + } + + [Fact] + public async Task PerformCleanupAsync_PurgeSucceeds_ReturnsFalse() + { + // Arrange + IDryRunPurger dryRunPurger = Substitute.For(); + IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: false, dryRunPurger); + EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService(), TimeProvider.System, new DryRunActivity()); + + // Act + bool purgeFailed = await service.PerformCleanupAsync(); + + // Assert + purgeFailed.ShouldBeFalse(); + } + + [Theory] + [InlineData(0, 30)] + [InlineData(1, 30)] + [InlineData(2, 60)] + [InlineData(3, 300)] + [InlineData(10, 300)] + public void GetRetryDelay_ReturnsExpectedDelay(int failures, int expectedSeconds) + { + // Act + TimeSpan delay = EventCleanupService.GetRetryDelay(failures); + + // Assert + delay.ShouldBe(TimeSpan.FromSeconds(expectedSeconds)); + } + + [Fact] + public async Task WaitForNextRunAsync_DelayElapses_Completes() + { + // Arrange + IDryRunPurger dryRunPurger = Substitute.For(); + IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: false, dryRunPurger); + FakeTimeProvider timeProvider = new(); + EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService(), timeProvider, new DryRunActivity()); + + // Act + Task waitTask = service.WaitForNextRunAsync(TimeSpan.FromMinutes(5), CancellationToken.None); + await Task.Yield(); + bool completedBeforeAdvance = waitTask.IsCompleted; + + timeProvider.Advance(TimeSpan.FromMinutes(5)); + await waitTask; + + // Assert + completedBeforeAdvance.ShouldBeFalse(); + waitTask.IsCompleted.ShouldBeTrue(); + } + + [Fact] + public async Task WaitForNextRunAsync_SignalWithDryRunOff_CompletesImmediately() + { + // Arrange + IDryRunPurger dryRunPurger = Substitute.For(); + IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: false, dryRunPurger); + FakeTimeProvider timeProvider = new(); + DryRunActivity dryRunActivity = new(); + EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService(), timeProvider, dryRunActivity); + + // Act + Task waitTask = service.WaitForNextRunAsync(TimeSpan.FromMinutes(5), CancellationToken.None); + dryRunActivity.RequestPurge(); + await waitTask; + + // Assert + waitTask.IsCompleted.ShouldBeTrue(); + } + + [Fact] + public async Task WaitForNextRunAsync_SignalWithDryRunOn_StaysPendingUntilDelayElapses() + { + // Arrange + IDryRunPurger dryRunPurger = Substitute.For(); + IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: true, dryRunPurger); + FakeTimeProvider timeProvider = new(); + DryRunActivity dryRunActivity = new(); + EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService(), timeProvider, dryRunActivity); + + // Act + Task waitTask = service.WaitForNextRunAsync(TimeSpan.FromMinutes(5), CancellationToken.None); + dryRunActivity.RequestPurge(); + + // give the signal branch a chance to run and re-read the config + for (int i = 0; i < 10 && !waitTask.IsCompleted; i++) + { + await Task.Yield(); + } + bool completedAfterSignal = waitTask.IsCompleted; + + timeProvider.Advance(TimeSpan.FromMinutes(5)); + await waitTask; + + // Assert + completedAfterSignal.ShouldBeFalse(); + waitTask.IsCompleted.ShouldBeTrue(); + } + + private IServiceProvider BuildProviderWithGeneralConfig(bool dryRunOn, IDryRunPurger dryRunPurger) + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + _services.AddDbContext(options => options.UseSqlite(connection)); + _services.AddSingleton(dryRunPurger); + IServiceProvider provider = _services.BuildServiceProvider(); + + using IServiceScope scope = provider.CreateScope(); + DataContext dataContext = scope.ServiceProvider.GetRequiredService(); + dataContext.Database.EnsureCreated(); + dataContext.GeneralConfigs.Add(new GeneralConfig + { + Id = Guid.NewGuid(), + DryRun = dryRunOn, + IgnoredDownloads = [], + Log = new LoggingConfig(), + }); + dataContext.SaveChanges(); + + return provider; + } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ArrClientTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ArrClientTests.cs index e2bd5e25..ec3b7169 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ArrClientTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ArrClientTests.cs @@ -175,9 +175,10 @@ public class ArrClientTests _httpMessageHandler.SetupResponse(HttpStatusCode.OK); // Act - await _client.DeleteQueueItemAsync(_arrInstance, BuildRecord(42), removeFromClient: true, changeCategory: false, DeleteReason.FailedImport); + bool sent = await _client.DeleteQueueItemAsync(_arrInstance, BuildRecord(42), removeFromClient: true, changeCategory: false, DeleteReason.FailedImport); // Assert + sent.ShouldBeTrue(); var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); request.Method.ShouldBe(HttpMethod.Delete); request.RequestUri!.AbsolutePath.ShouldBe("/api/v1/queue/42"); @@ -227,7 +228,23 @@ public class ArrClientTests [Fact] public async Task DeleteQueueItemAsync_DryRunReturnsNull_DoesNotThrow() { - // Arrange — interceptor short-circuits and returns null; method should still log "removed" + // Arrange: interceptor skips the request and returns null + _dryRunInterceptor + .InterceptAsync(Arg.Any>>(), Arg.Any()) + .Returns((HttpResponseMessage?)null); + + // Act + bool sent = await _client.DeleteQueueItemAsync(_arrInstance, BuildRecord(1), removeFromClient: false, changeCategory: false, DeleteReason.Stalled); + + // Assert + sent.ShouldBeFalse(); + _httpMessageHandler.CapturedRequests.ShouldBeEmpty(); + } + + [Fact] + public async Task DeleteQueueItemAsync_DryRun_SaysSoInTheLog() + { + // Arrange _dryRunInterceptor .InterceptAsync(Arg.Any>>(), Arg.Any()) .Returns((HttpResponseMessage?)null); @@ -235,8 +252,13 @@ public class ArrClientTests // Act await _client.DeleteQueueItemAsync(_arrInstance, BuildRecord(1), removeFromClient: false, changeCategory: false, DeleteReason.Stalled); - // Assert — no HTTP call was actually made because the interceptor was substituted to return null - _httpMessageHandler.CapturedRequests.ShouldBeEmpty(); + // Assert: the log line marks this as a dry-run removal + _logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(state => state.ToString()!.Contains("[DRY RUN]")), + Arg.Any(), + Arg.Any>()); } #endregion diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ForceImportDryRunCollection.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ForceImportDryRunCollection.cs new file mode 100644 index 00000000..1c049220 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ForceImportDryRunCollection.cs @@ -0,0 +1,13 @@ +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.Arr; + +/// +/// ForceImportService keeps its dry-run eviction token in a static field. +/// Specs that cancel it run sequentially. +/// +[CollectionDefinition(Name)] +public class ForceImportDryRunCollection +{ + public const string Name = "ForceImportDryRun"; +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ForceImportServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ForceImportServiceTests.cs index 752a680e..f885a83f 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ForceImportServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ForceImportServiceTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Net; using Cleanuparr.Domain.Entities.Arr; using Cleanuparr.Domain.Entities.Arr.ManualImport; @@ -8,6 +9,8 @@ using Cleanuparr.Infrastructure.Features.Arr.ForceImport; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.ItemStriker; +using Cleanuparr.Infrastructure.Helpers; +using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using Microsoft.Extensions.Caching.Memory; @@ -20,7 +23,8 @@ using Xunit; namespace Cleanuparr.Infrastructure.Tests.Features.Arr; -public class ForceImportServiceTests +[Collection(ForceImportDryRunCollection.Name)] +public class ForceImportServiceTests : IDisposable { /// One of the reasons the service treats as safe to force past. private const string SafeReason = "Unable to determine if file is a sample"; @@ -30,6 +34,7 @@ public class ForceImportServiceTests private readonly IEventPublisher _eventPublisher; private readonly IMemoryCache _cache; private readonly FakeTimeProvider _timeProvider; + private readonly IDryRunInterceptor _dryRunInterceptor; private readonly ForceImportService _sut; private readonly ArrInstance _instance; @@ -43,8 +48,11 @@ public class ForceImportServiceTests _eventPublisher = Substitute.For(); _cache = new MemoryCache(new MemoryCacheOptions()); _timeProvider = new FakeTimeProvider(); + _dryRunInterceptor = Substitute.For(); + _dryRunInterceptor.IsDryRunEnabled().Returns(false); _sut = new ForceImportService( - Substitute.For>(), _cache, _striker, _eventPublisher, _timeProvider); + Substitute.For>(), _cache, _striker, _eventPublisher, _timeProvider, + _dryRunInterceptor); _instance = new ArrInstance { @@ -58,7 +66,7 @@ public class ForceImportServiceTests .Returns(_ => { _importedByTheArr++; - return Task.CompletedTask; + return Task.FromResult(true); }); _arrClient.SupportsForceImport.Returns(true); @@ -73,6 +81,9 @@ public class ForceImportServiceTests SetConfig(); } + // Resets the static dry-run eviction token between tests. + public void Dispose() => ForceImportService.ForgetDryRun(); + [Fact] public async Task TryImportAsync_AsksTheArrAndSaysNothingYet() { @@ -340,7 +351,7 @@ public class ForceImportServiceTests { // Arrange: an unreachable arr is transient, so the download waits instead of collecting a strike _arrClient.ForceImportAsync(Arg.Any(), Arg.Any>()) - .Returns(Task.FromException(new HttpRequestException("boom"))); + .Returns(Task.FromException(new HttpRequestException("boom"))); StubCandidates(BuildCandidate(SafeReason)); // Act @@ -358,7 +369,7 @@ public class ForceImportServiceTests // Arrange SetConfig(maxTries: 1); _arrClient.ForceImportAsync(Arg.Any(), Arg.Any>()) - .Returns(Task.FromException(new HttpRequestException("down"))); + .Returns(Task.FromException(new HttpRequestException("down"))); StubCandidates(BuildCandidate(SafeReason)); QueueRecord record = BuildRecord(state: "importBlocked"); @@ -378,7 +389,7 @@ public class ForceImportServiceTests // Arrange: an arr that keeps refusing must not hold the download out of the strike path forever SetConfig(maxTries: 1); _arrClient.ForceImportAsync(Arg.Any(), Arg.Any>()) - .Returns(Task.FromException(new HttpRequestException("bad request", null, HttpStatusCode.BadRequest))); + .Returns(Task.FromException(new HttpRequestException("bad request", null, HttpStatusCode.BadRequest))); StubCandidates(BuildCandidate(SafeReason)); QueueRecord record = BuildRecord(state: "importBlocked"); @@ -403,7 +414,7 @@ public class ForceImportServiceTests await _sut.TryImportAsync(_arrClient, _instance, record); _arrClient.ForceImportAsync(Arg.Any(), Arg.Any>()) - .Returns(Task.FromException(new HttpRequestException("bad request", null, HttpStatusCode.BadRequest))); + .Returns(Task.FromException(new HttpRequestException("bad request", null, HttpStatusCode.BadRequest))); await _sut.TryImportAsync(_arrClient, _instance, record); @@ -477,6 +488,175 @@ public class ForceImportServiceTests await _arrClient.Received(1).ForceImportAsync(_instance, Arg.Any>()); } + [Fact] + public async Task TryImportAsync_DryRunTurnsOnMidRun_RegistersNoPendingAndSpendsNoTry() + { + // Arrange: dry run turns on after the service reads it + QueueRecord record = BuildRecord(state: "importBlocked"); + StubCandidates(BuildCandidate(SafeReason)); + _arrClient.ForceImportAsync(Arg.Any(), Arg.Any>()).Returns(false); + + // Act + ForceImportOutcome outcome = await _sut.TryImportAsync(_arrClient, _instance, record); + + // Assert: no try spent, no pending import + outcome.ShouldBe(ForceImportOutcome.Deferred); + _cache.TryGetValue(CacheKeys.ForceImportTries(record.DownloadId, _instance.Url), out int _).ShouldBeFalse(); + _cache.TryGetValue(CacheKeys.ForceImportPending(_instance.Url), out ConcurrentDictionary? pending); + (pending is null || pending.IsEmpty).ShouldBeTrue(); + } + + [Fact] + public async Task TryImportAsync_DryRun_SpendsADryTry() + { + // Arrange + EnableDryRun(); + QueueRecord record = BuildRecord(state: "importBlocked"); + StubCandidates(BuildCandidate(SafeReason)); + + // Act + ForceImportOutcome outcome = await _sut.TryImportAsync(_arrClient, _instance, record); + + // Assert: dry run spends its own budget, live budget stays empty + outcome.ShouldBe(ForceImportOutcome.Deferred); + await _arrClient.Received(1).ForceImportAsync(_instance, Arg.Any>()); + _cache.TryGetValue(CacheKeys.ForceImportTries(record.DownloadId, _instance.Url, true), out int dryTries).ShouldBeTrue(); + dryTries.ShouldBe(1); + _cache.TryGetValue(CacheKeys.ForceImportTries(record.DownloadId, _instance.Url), out int _).ShouldBeFalse(); + } + + [Fact] + public async Task TryImportAsync_DryRun_LeavesTheFullBudgetForALiveRun() + { + // Arrange + SetConfig(maxTries: 2); + EnableDryRun(); + QueueRecord record = BuildRecord(state: "importBlocked"); + StubCandidates(BuildCandidate(SafeReason)); + + await _sut.TryImportAsync(_arrClient, _instance, record); + await _sut.TryImportAsync(_arrClient, _instance, record); + await _sut.TryImportAsync(_arrClient, _instance, record); + + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + _arrClient.ForceImportAsync(Arg.Any(), Arg.Any>()).Returns(true); + + // Act + ForceImportOutcome first = await _sut.TryImportAsync(_arrClient, _instance, record); + ForceImportOutcome second = await _sut.TryImportAsync(_arrClient, _instance, record); + ForceImportOutcome third = await _sut.TryImportAsync(_arrClient, _instance, record); + + // Assert: the dry run leaves the live try budget untouched + first.ShouldBe(ForceImportOutcome.Deferred); + second.ShouldBe(ForceImportOutcome.Deferred); + third.ShouldBe(ForceImportOutcome.NotApplicable); + } + + [Fact] + public async Task TryImportAsync_DryRun_AtTheTryLimit_SetsTheDryGiveUpMarker() + { + // Arrange: the dry run spends its only try + SetConfig(maxTries: 1); + EnableDryRun(); + QueueRecord record = BuildRecord(state: "importBlocked"); + StubCandidates(BuildCandidate(SafeReason)); + await _sut.TryImportAsync(_arrClient, _instance, record); + + // Act: a second dry run hits the try limit and gives up + ForceImportOutcome outcome = await _sut.TryImportAsync(_arrClient, _instance, record); + + // Assert: dry run gives up and sets its own give-up marker + outcome.ShouldBe(ForceImportOutcome.NotApplicable); + _cache.TryGetValue(CacheKeys.ForceImportGaveUp(record.DownloadId, _instance.Url, true), out DateTimeOffset _) + .ShouldBeTrue(); + + // Dry run leaves the live marker alone + _cache.TryGetValue(CacheKeys.ForceImportGaveUp(record.DownloadId, _instance.Url), out DateTimeOffset _) + .ShouldBeFalse(); + } + + [Fact] + public async Task TryImportAsync_DryRun_ForgetDryRun_ClearsGiveUpMarkerAndTriesAgain() + { + // Arrange: the dry run gives up + SetConfig(maxTries: 1); + EnableDryRun(); + QueueRecord record = BuildRecord(state: "importBlocked"); + StubCandidates(BuildCandidate(SafeReason)); + await _sut.TryImportAsync(_arrClient, _instance, record); + ForceImportOutcome gaveUp = await _sut.TryImportAsync(_arrClient, _instance, record); + gaveUp.ShouldBe(ForceImportOutcome.NotApplicable); + + // Act: turning dry run off drops the dry budget and its give-up marker + ForceImportService.ForgetDryRun(); + + // Assert: marker gone, a fresh dry run tries again + _cache.TryGetValue(CacheKeys.ForceImportGaveUp(record.DownloadId, _instance.Url, true), out DateTimeOffset _) + .ShouldBeFalse(); + + ForceImportOutcome outcome = await _sut.TryImportAsync(_arrClient, _instance, record); + outcome.ShouldBe(ForceImportOutcome.Deferred); + } + + [Fact] + public async Task TryImportAsync_DryRun_RegistersNoPendingImport() + { + // Arrange + EnableDryRun(); + QueueRecord record = BuildRecord(state: "importBlocked"); + StubCandidates(BuildCandidate(SafeReason)); + await _sut.TryImportAsync(_arrClient, _instance, record); + + // Act: the download leaves the queue and the arr's history has grown + await _sut.ReconcileAsync(_arrClient, _instance, new HashSet()); + + // Assert: no arr call means no import gets claimed + await _striker.DidNotReceive().ResetStrikeAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _eventPublisher.DidNotReceive().PublishForceImported(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task TryImportAsync_ForgetDryRunRacesTheAttempt_EvictsTheDryTryWrite() + { + // Arrange: a purge fires mid-attempt + EnableDryRun(); + QueueRecord record = BuildRecord(state: "importBlocked"); + StubCandidates(BuildCandidate(SafeReason)); + _arrClient.GetImportedCountAsync(Arg.Any(), Arg.Any()) + .Returns(_ => + { + ForceImportService.ForgetDryRun(); + return _importedByTheArr; + }); + + // Act + await _sut.TryImportAsync(_arrClient, _instance, record); + + // Assert: the purge evicted the dry try + _cache.TryGetValue(CacheKeys.ForceImportTries(record.DownloadId, _instance.Url, true), out int _).ShouldBeFalse(); + } + + [Fact] + public async Task TryImportAsync_ForgetDryRunRacesTheDryRunCheck_EvictsTheDryTryWrite() + { + // Arrange: a purge fires while awaiting the dry-run check itself + QueueRecord record = BuildRecord(state: "importBlocked"); + StubCandidates(BuildCandidate(SafeReason)); + _dryRunInterceptor.IsDryRunEnabled().Returns(_ => + { + ForceImportService.ForgetDryRun(); + return Task.FromResult(true); + }); + _arrClient.GetImportedCountAsync(Arg.Any(), Arg.Any()).Returns(_importedByTheArr); + + // Act + await _sut.TryImportAsync(_arrClient, _instance, record); + + // Assert: the purge evicted the dry try and give-up marker + _cache.TryGetValue(CacheKeys.ForceImportTries(record.DownloadId, _instance.Url, true), out int _).ShouldBeFalse(); + _cache.TryGetValue(CacheKeys.ForceImportGaveUp(record.DownloadId, _instance.Url, true), out DateTimeOffset _).ShouldBeFalse(); + } + [Fact] public async Task TryImportAsync_TheGaveUpWindowPassed_TriesAgain() { @@ -621,7 +801,7 @@ public class ForceImportServiceTests // The arr imports nothing more, because the first request already covered the files. _arrClient.ForceImportAsync(Arg.Any(), Arg.Any>()) - .Returns(Task.CompletedTask); + .Returns(Task.FromResult(true)); await _sut.TryImportAsync(_arrClient, _instance, record); @@ -795,6 +975,13 @@ public class ForceImportServiceTests await _arrClient.DidNotReceive().ForceImportAsync(Arg.Any(), Arg.Any>()); } + // The real ArrClient skips the send under dry run. + private void EnableDryRun() + { + _dryRunInterceptor.IsDryRunEnabled().Returns(true); + _arrClient.ForceImportAsync(Arg.Any(), Arg.Any>()).Returns(false); + } + private static void SetConfig(bool forceImport = true, ushort maxTries = 3) { ContextProvider.Set(new QueueCleanerConfig diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/SonarrClientTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/SonarrClientTests.cs index ebf869ac..2ac90013 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/SonarrClientTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/SonarrClientTests.cs @@ -577,9 +577,10 @@ public class SonarrClientTests ]; // Act - await _client.ForceImportAsync(_arrInstance, files); + bool sent = await _client.ForceImportAsync(_arrInstance, files); // Assert + sent.ShouldBeTrue(); HttpRequestMessage request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); request.Method.ShouldBe(HttpMethod.Post); request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/command"); @@ -606,9 +607,10 @@ public class SonarrClientTests .Returns(Task.FromResult(null)); // Act - await _client.ForceImportAsync(_arrInstance, [new ManualImportFile { Path = "/downloads/show.mkv" }]); + bool sent = await _client.ForceImportAsync(_arrInstance, [new ManualImportFile { Path = "/downloads/show.mkv" }]); // Assert + sent.ShouldBeFalse(); _httpMessageHandler.CapturedRequests.ShouldBeEmpty(); } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/Consumers/DownloadRemoverConsumerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/Consumers/DownloadRemoverConsumerTests.cs index 87ba91de..a3939f8e 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/Consumers/DownloadRemoverConsumerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/Consumers/DownloadRemoverConsumerTests.cs @@ -1,9 +1,11 @@ using Cleanuparr.Domain.Entities.Arr; using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers; using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces; using Cleanuparr.Infrastructure.Features.DownloadRemover.Models; +using Cleanuparr.Infrastructure.Features.DryRun; using Cleanuparr.Infrastructure.Tests.TestHelpers; using Cleanuparr.Persistence.Models.Configuration.Arr; using MassTransit; @@ -19,13 +21,15 @@ public class DownloadRemoverConsumerTests { private readonly ILogger _logger; private readonly IQueueItemRemover _queueItemRemover; + private readonly DryRunActivity _dryRunActivity; private readonly DownloadRemoverConsumer _consumer; public DownloadRemoverConsumerTests() { _logger = Substitute.For>(); _queueItemRemover = Substitute.For(); - _consumer = new DownloadRemoverConsumer(_logger, _queueItemRemover); + _dryRunActivity = new DryRunActivity(); + _consumer = new DownloadRemoverConsumer(_logger, _queueItemRemover, _dryRunActivity); } #region Consume Tests @@ -90,6 +94,103 @@ public class DownloadRemoverConsumerTests capturedRequest.DeleteReason.ShouldBe(request.DeleteReason); } + [Fact] + public async Task Consume_WithDryRunRequest_SetsStickyDryRunBeforeRemoving() + { + // Arrange + var request = CreateRemoveRequest() with { IsDryRun = true }; + var context = CreateConsumeContext(request); + bool observedDryRun = false; + + _queueItemRemover + .RemoveQueueItemAsync(Arg.Any()) + .Returns(Task.CompletedTask) + .AndDoes(_ => observedDryRun = ContextProvider.IsDryRunSticky()); + + // Act + await _consumer.Consume(context); + + // Assert + observedDryRun.ShouldBeTrue(); + } + + [Fact] + public async Task Consume_WithDryRunRequest_RequestsAPurgeAfterRemoving() + { + // Arrange + var request = CreateRemoveRequest() with { IsDryRun = true }; + var context = CreateConsumeContext(request); + + _queueItemRemover + .RemoveQueueItemAsync(Arg.Any()) + .Returns(Task.CompletedTask); + + // Act + await _consumer.Consume(context); + + // Assert + (await _dryRunActivity.WaitForPurgeRequestAsync(CancellationToken.None)).ShouldBeTrue(); + } + + [Fact] + public async Task Consume_WithLiveRequest_RequestsNoPurge() + { + // Arrange + var request = CreateRemoveRequest() with { IsDryRun = false }; + var context = CreateConsumeContext(request); + + _queueItemRemover + .RemoveQueueItemAsync(Arg.Any()) + .Returns(Task.CompletedTask); + + // Act + await _consumer.Consume(context); + + // Assert + Task waitTask = _dryRunActivity.WaitForPurgeRequestAsync(CancellationToken.None).AsTask(); + await Task.Yield(); + waitTask.IsCompleted.ShouldBeFalse(); + } + + [Fact] + public async Task Consume_WithDryRunRequest_WhenRemoverThrows_ExitsActivity() + { + // Arrange + QueueItemRemoveRequest request = CreateRemoveRequest() with { IsDryRun = true }; + ConsumeContext context = CreateConsumeContext(request); + + _queueItemRemover + .RemoveQueueItemAsync(Arg.Any()) + .ThrowsAsync(new Exception("Remove failed")); + + // Act + await _consumer.Consume(context); + + // Assert + _dryRunActivity.IsActive.ShouldBeFalse(); + } + + [Fact] + public async Task Consume_WithLiveRequest_NeverEntersActivity() + { + // Arrange + QueueItemRemoveRequest request = CreateRemoveRequest() with { IsDryRun = false }; + ConsumeContext context = CreateConsumeContext(request); + bool activeDuringRemoval = false; + + _queueItemRemover + .RemoveQueueItemAsync(Arg.Any()) + .Returns(Task.CompletedTask) + .AndDoes(_ => activeDuringRemoval = _dryRunActivity.IsActive); + + // Act + await _consumer.Consume(context); + + // Assert + activeDuringRemoval.ShouldBeFalse(); + _dryRunActivity.IsActive.ShouldBeFalse(); + } + [Fact] public async Task Consume_WithRemoveFromClientTrue_PassesCorrectly() { @@ -104,7 +205,8 @@ public class DownloadRemoverConsumerTests RemoveFromClient = true, }, DeleteReason = DeleteReason.Stalled, - JobRunId = Guid.NewGuid() + JobRunId = Guid.NewGuid(), + IsDryRun = false, }; var context = CreateConsumeContext(request); @@ -136,7 +238,8 @@ public class DownloadRemoverConsumerTests RemoveFromClient = false, }, DeleteReason = DeleteReason.FailedImport, - JobRunId = Guid.NewGuid() + JobRunId = Guid.NewGuid(), + IsDryRun = false, }; var context = CreateConsumeContext(request); @@ -167,7 +270,8 @@ public class DownloadRemoverConsumerTests RemoveFromClient = true, }, DeleteReason = DeleteReason.SlowSpeed, - JobRunId = Guid.NewGuid() + JobRunId = Guid.NewGuid(), + IsDryRun = false, }; var context = CreateConsumeContext(request); @@ -199,7 +303,8 @@ public class DownloadRemoverConsumerTests RemoveFromClient = true, }, DeleteReason = DeleteReason.Stalled, - JobRunId = Guid.NewGuid() + JobRunId = Guid.NewGuid(), + IsDryRun = false, }; } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs index d109c530..0a0a7a17 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs @@ -42,6 +42,7 @@ public class QueueItemRemoverTests : IDisposable private readonly EventsContext _eventsContext; private readonly DataContext _dataContext; private readonly ILazyLibrarianService _lazyLibrarianService = Substitute.For(); + private readonly IDryRunInterceptor _dryRunInterceptor; private readonly QueueItemRemover _queueItemRemover; private readonly Guid _jobRunId; @@ -67,9 +68,9 @@ public class QueueItemRemoverTests : IDisposable IEventNotifier eventNotifier = Substitute.For(); - var dryRunInterceptor = Substitute.For(); - dryRunInterceptor.IsDryRunEnabled().Returns(false); - dryRunInterceptor + _dryRunInterceptor = Substitute.For(); + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + _dryRunInterceptor .InterceptAsync(Arg.Any>(), Arg.Any()) .ReturnsForAnyArgs(Task.CompletedTask); @@ -78,7 +79,7 @@ public class QueueItemRemoverTests : IDisposable eventNotifier, Substitute.For>(), Substitute.For(), - dryRunInterceptor, + _dryRunInterceptor, new SqliteDatabaseProvider(), TimeProvider.System); @@ -120,6 +121,7 @@ public class QueueItemRemoverTests : IDisposable }, DeleteReason = DeleteReason.Stalled, JobRunId = _jobRunId, + IsDryRun = false, }; private void StubProgress(int progress) @@ -158,7 +160,7 @@ public class QueueItemRemoverTests : IDisposable StubProgress(-1); _lazyLibrarianService .ResetItemAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromException(new Exception("lazylibrarian is down"))); + .Returns(Task.FromException(new Exception("lazylibrarian is down"))); QueueItemRemoveRequest request = CreateLazyLibrarianRequest(removedFromClient: true); // Act @@ -600,7 +602,7 @@ public class QueueItemRemoverTests : IDisposable StubProgress(-1); _lazyLibrarianService .ResetItemAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromException(new Exception("lazylibrarian is down"))); + .Returns(Task.FromException(new Exception("lazylibrarian is down"))); // Act await Should.ThrowAsync( @@ -704,6 +706,170 @@ public class QueueItemRemoverTests : IDisposable #endregion + #region Dry Run + + [Fact] + public async Task RemoveQueueItemAsync_DeleteSent_MarksDownloadRemoved() + { + // Arrange: the delete was sent + _arrClient + .DeleteQueueItemAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + _eventsContext.DownloadItems.Add(new DownloadItem + { + DownloadId = "abc123def456", + Title = "Test Record", + IsMarkedForRemoval = true, + }); + await _eventsContext.SaveChangesAsync(); + + QueueItemRemoveRequest request = CreateRemoveRequest(); + + // Act + await _queueItemRemover.RemoveQueueItemAsync(request); + + // Assert + DownloadItem item = await _eventsContext.DownloadItems.AsNoTracking() + .FirstAsync(x => x.DownloadId == "abc123def456"); + item.IsRemoved.ShouldBeTrue(); + item.IsMarkedForRemoval.ShouldBeFalse(); + } + + [Fact] + public async Task RemoveQueueItemAsync_DeleteSkipped_LeavesTheDownloadUnmarked() + { + // Arrange: dry run skipped the delete + _arrClient + .DeleteQueueItemAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(false); + _eventsContext.DownloadItems.Add(new DownloadItem + { + DownloadId = "abc123def456", + Title = "Test Record", + IsMarkedForRemoval = true, + }); + await _eventsContext.SaveChangesAsync(); + + QueueItemRemoveRequest request = CreateRemoveRequest(); + + // Act + await _queueItemRemover.RemoveQueueItemAsync(request); + + // Assert: the removal flag stays set + DownloadItem item = await _eventsContext.DownloadItems.AsNoTracking() + .FirstAsync(x => x.DownloadId == "abc123def456"); + item.IsRemoved.ShouldBeFalse(); + item.IsMarkedForRemoval.ShouldBeTrue(); + } + + [Fact] + public async Task RemoveQueueItemAsync_DeleteSentWhileDryRunFlagIsSet_StillMarksDownloadRemoved() + { + // Arrange: dry run turned on after the delete was sent + _dryRunInterceptor.IsDryRunEnabled().Returns(true); + _arrClient + .DeleteQueueItemAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + _eventsContext.DownloadItems.Add(new DownloadItem + { + DownloadId = "abc123def456", + Title = "Test Record", + IsMarkedForRemoval = true, + }); + await _eventsContext.SaveChangesAsync(); + + QueueItemRemoveRequest request = CreateRemoveRequest(); + + // Act + await _queueItemRemover.RemoveQueueItemAsync(request); + + // Assert + DownloadItem item = await _eventsContext.DownloadItems.AsNoTracking() + .FirstAsync(x => x.DownloadId == "abc123def456"); + item.IsRemoved.ShouldBeTrue(); + item.IsMarkedForRemoval.ShouldBeFalse(); + } + + [Fact] + public async Task RemoveQueueItemAsync_LazyLibrarian_ResetSent_MarksDownloadRemoved() + { + // Arrange + _lazyLibrarianService + .ResetItemAsync(Arg.Any(), Arg.Any()) + .Returns(true); + _eventsContext.DownloadItems.Add(new DownloadItem + { + DownloadId = "hash1", + Title = "A Book", + IsMarkedForRemoval = true, + }); + await _eventsContext.SaveChangesAsync(); + + StubProgress(-1); + QueueItemRemoveRequest request = CreateLazyLibrarianRequest(removedFromClient: true); + + // Act + await _queueItemRemover.RemoveQueueItemAsync(request); + + // Assert + DownloadItem item = await _eventsContext.DownloadItems.AsNoTracking() + .FirstAsync(x => x.DownloadId == "hash1"); + item.IsRemoved.ShouldBeTrue(); + item.IsMarkedForRemoval.ShouldBeFalse(); + } + + [Fact] + public async Task RemoveQueueItemAsync_LazyLibrarian_ResetSkipped_LeavesTheDownloadUnmarked() + { + // Arrange: dry run skipped every reset + _lazyLibrarianService + .ResetItemAsync(Arg.Any(), Arg.Any()) + .Returns(false); + _eventsContext.DownloadItems.Add(new DownloadItem + { + DownloadId = "hash1", + Title = "A Book", + IsMarkedForRemoval = true, + }); + await _eventsContext.SaveChangesAsync(); + + StubProgress(-1); + QueueItemRemoveRequest request = CreateLazyLibrarianRequest(removedFromClient: true); + + // Act + await _queueItemRemover.RemoveQueueItemAsync(request); + + // Assert + DownloadItem item = await _eventsContext.DownloadItems.AsNoTracking() + .FirstAsync(x => x.DownloadId == "hash1"); + item.IsRemoved.ShouldBeFalse(); + item.IsMarkedForRemoval.ShouldBeTrue(); + } + + [Fact] + public async Task RemoveQueueItemAsync_DeleteSkipped_StillQueuesTheSearch() + { + // Arrange: dry run skipped the delete + _arrClient + .DeleteQueueItemAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(false); + QueueItemRemoveRequest request = CreateRemoveRequest(); + + // Act + await _queueItemRemover.RemoveQueueItemAsync(request); + + // Assert + await _arrClient.Received(1).DeleteQueueItemAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + _eventsContext.SearchQueue.Count().ShouldBe(1); + } + + #endregion + #region Helper Methods private QueueItemRemoveRequest CreateRemoveRequest( @@ -728,7 +894,8 @@ public class QueueItemRemoverTests : IDisposable }, DeleteReason = deleteReason, SkipSearch = skipSearch, - JobRunId = _jobRunId + JobRunId = _jobRunId, + IsDryRun = false, }; } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DryRun/DryRunActivityTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DryRun/DryRunActivityTests.cs new file mode 100644 index 00000000..e1a63c5d --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DryRun/DryRunActivityTests.cs @@ -0,0 +1,76 @@ +using Cleanuparr.Infrastructure.Features.DryRun; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.DryRun; + +public sealed class DryRunActivityTests +{ + [Fact] + public async Task EnterAsync_WhilePurgeHoldsTheGate_WaitsForThePurgeToFinish() + { + // Arrange + DryRunActivity activity = new(); + TaskCompletionSource purgeGate = new(); + + Task purgeTask = activity.RunIfIdleAsync(() => purgeGate.Task); + + // Act + Task enterTask = activity.EnterAsync(); + + // Assert - entry is blocked while the purge holds the gate + await Task.Delay(50); + enterTask.IsCompleted.ShouldBeFalse(); + + purgeGate.SetResult(true); + bool ran = await purgeTask; + await enterTask; + + ran.ShouldBeTrue(); + activity.IsActive.ShouldBeTrue(); + } + + [Fact] + public async Task RequestPurge_CalledTwice_BuffersOneRequest() + { + // Arrange + DryRunActivity activity = new(); + + // Act + activity.RequestPurge(); + activity.RequestPurge(); + + // Assert + await activity.WaitForPurgeRequestAsync(CancellationToken.None); + activity.WaitForPurgeRequestAsync(CancellationToken.None).IsCompleted.ShouldBeFalse(); + } + + [Fact] + public async Task Exit_LastDryRun_RequestsPurge() + { + // Arrange + DryRunActivity activity = new(); + await activity.EnterAsync(); + + // Act + activity.Exit(); + + // Assert + activity.WaitForPurgeRequestAsync(CancellationToken.None).IsCompleted.ShouldBeTrue(); + } + + [Fact] + public async Task Exit_OtherDryRunStillActive_RequestsNoPurge() + { + // Arrange + DryRunActivity activity = new(); + await activity.EnterAsync(); + await activity.EnterAsync(); + + // Act + activity.Exit(); + + // Assert + activity.WaitForPurgeRequestAsync(CancellationToken.None).IsCompleted.ShouldBeFalse(); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DryRun/DryRunPurgerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DryRun/DryRunPurgerTests.cs new file mode 100644 index 00000000..31f4ab9f --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DryRun/DryRunPurgerTests.cs @@ -0,0 +1,135 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.DryRun; +using Cleanuparr.Infrastructure.Tests.Features.Arr; +using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Events; +using Cleanuparr.Persistence.Models.State; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.DryRun; + +[Collection(ForceImportDryRunCollection.Name)] +public sealed class DryRunPurgerTests : IDisposable +{ + private readonly EventsContext _eventsContext; + private readonly DryRunActivity _dryRunActivity; + private readonly DryRunPurger _purger; + + public DryRunPurgerTests() + { + _eventsContext = TestDataContextFactory.CreateEvents(); + _dryRunActivity = new DryRunActivity(); + _purger = new DryRunPurger(Substitute.For>(), _eventsContext, _dryRunActivity); + } + + public void Dispose() + { + _eventsContext.Dispose(); + } + + private async Task AddDryRunStrikeAsync() + { + var jobRun = new JobRun { Id = Guid.NewGuid(), Type = JobType.QueueCleaner }; + var downloadItem = new DownloadItem { DownloadId = Guid.NewGuid().ToString(), Title = "dry run strike target" }; + _eventsContext.JobRuns.Add(jobRun); + _eventsContext.DownloadItems.Add(downloadItem); + _eventsContext.Strikes.Add(new Strike + { + DownloadItemId = downloadItem.Id, + JobRunId = jobRun.Id, + Type = StrikeType.FailedImport, + IsDryRun = true, + }); + await _eventsContext.SaveChangesAsync(); + } + + [Fact] + public async Task PurgeAsync_ClearsWhatTheDryRunLeftBehind() + { + // Arrange + var jobRun = new JobRun { Id = Guid.NewGuid(), Type = JobType.QueueCleaner }; + _eventsContext.JobRuns.Add(jobRun); + + // Real strike plus a dry-run strike: purge keeps the mark + var touchedByDryRun = new DownloadItem + { + DownloadId = "touched-by-dry-run", + Title = "Struck for real, then by a dry run", + IsMarkedForRemoval = true, + }; + + // Real strikes only: purge keeps its flags + var realOnly = new DownloadItem + { + DownloadId = "real-only", + Title = "Struck only by real runs", + IsMarkedForRemoval = true, + IsRemoved = true, + IsReturning = true, + }; + _eventsContext.DownloadItems.AddRange(touchedByDryRun, realOnly); + + _eventsContext.Strikes.AddRange( + new Strike { DownloadItemId = touchedByDryRun.Id, JobRunId = jobRun.Id, Type = StrikeType.FailedImport }, + new Strike { DownloadItemId = touchedByDryRun.Id, JobRunId = jobRun.Id, Type = StrikeType.FailedImport, IsDryRun = true }, + new Strike { DownloadItemId = realOnly.Id, JobRunId = jobRun.Id, Type = StrikeType.FailedImport }); + + _eventsContext.SeekerHistory.AddRange( + new SeekerHistory { ItemTitle = "dry run history", IsDryRun = true, CycleId = Guid.NewGuid() }, + new SeekerHistory { ItemTitle = "real history", IsDryRun = false, CycleId = Guid.NewGuid() }); + + await _eventsContext.SaveChangesAsync(); + + // Act + await _purger.PurgeAsync(); + + // Assert + _eventsContext.ChangeTracker.Clear(); + (await _eventsContext.Strikes.CountAsync(x => x.IsDryRun)).ShouldBe(0); + (await _eventsContext.SeekerHistory.CountAsync(x => x.IsDryRun)).ShouldBe(0); + (await _eventsContext.SeekerHistory.CountAsync()).ShouldBe(1); + + DownloadItem touched = await _eventsContext.DownloadItems.AsNoTracking().FirstAsync(x => x.DownloadId == "touched-by-dry-run"); + touched.IsMarkedForRemoval.ShouldBeTrue(); + + // Real-run flags survive the purge + DownloadItem real = await _eventsContext.DownloadItems.AsNoTracking().FirstAsync(x => x.DownloadId == "real-only"); + real.IsMarkedForRemoval.ShouldBeTrue(); + real.IsRemoved.ShouldBeTrue(); + real.IsReturning.ShouldBeTrue(); + } + + [Fact] + public async Task PurgeAsync_DryRunActive_KeepsDryData() + { + // Arrange + await AddDryRunStrikeAsync(); + await _dryRunActivity.EnterAsync(); + + // Act + await _purger.PurgeAsync(); + + // Assert + (await _eventsContext.Strikes.CountAsync(x => x.IsDryRun)).ShouldBe(1); + } + + [Fact] + public async Task PurgeAsync_AfterDryRunExits_Purges() + { + // Arrange + await AddDryRunStrikeAsync(); + await _dryRunActivity.EnterAsync(); + _dryRunActivity.Exit(); + + // Act + await _purger.PurgeAsync(); + + // Assert + (await _eventsContext.Strikes.CountAsync(x => x.IsDryRun)).ShouldBe(0); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/GenericHandlerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/GenericHandlerTests.cs index ad9bb1aa..32688e6c 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/GenericHandlerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/GenericHandlerTests.cs @@ -323,6 +323,31 @@ public class GenericHandlerTests : IClassFixture Arg.Any?>(), Arg.Any(), Arg.Any(), Arg.Any()); } + [Fact] + public async Task PublishQueueItemRemoveRequest_DryRunEnabled_StampsTheMessageAsDryRun() + { + // Arrange + var arrConfig = new ArrConfig { Type = InstanceType.Sonarr, Instances = [] }; + var instance = new ArrInstance + { + Name = "s", + Url = new Uri("http://s"), + ApiKey = "k", + ArrConfig = arrConfig, + Version = 4f, + }; + var record = NewRecord(seriesId: 1, episodeId: 2); + _fixture.DryRunInterceptor.IsDryRunEnabled().Returns(true); + + // Act + await _handler.PublicPublishQueueItemRemoveRequest( + instance, record, isPack: false, removeFromClient: true, DeleteReason.FailedImport); + + // Assert + await _fixture.MessageBus.Received(1) + .Publish(Arg.Is(r => r.IsDryRun), Arg.Any()); + } + [Fact] public async Task PublishQueueItemRemoveRequest_Radarr_PublishesGenericSearchItemMessage() { diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/MalwareBlockerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/MalwareBlockerIntegrationTests.cs index 0ccfe012..d08b80c4 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/MalwareBlockerIntegrationTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/MalwareBlockerIntegrationTests.cs @@ -92,7 +92,7 @@ public class MalwareBlockerIntegrationTests : IDisposable // Process through real QueueItemRemover _fixture.ArrClient.DeleteQueueItemAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.CompletedTask); + .Returns(true); await _fixture.ProcessCapturedRemoveRequestsAsync(); @@ -212,7 +212,7 @@ public class MalwareBlockerIntegrationTests : IDisposable _fixture.ArrClient.DeleteQueueItemAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.CompletedTask); + .Returns(true); await _fixture.ProcessCapturedRemoveRequestsAsync(); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/QueueCleanerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/QueueCleanerIntegrationTests.cs index ffa1f59c..8d21f016 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/QueueCleanerIntegrationTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/QueueCleanerIntegrationTests.cs @@ -91,7 +91,7 @@ public class QueueCleanerIntegrationTests : IDisposable // Process the captured messages through the real QueueItemRemover pipeline _fixture.ArrClient.DeleteQueueItemAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.CompletedTask); + .Returns(true); await _fixture.ProcessCapturedRemoveRequestsAsync(); @@ -188,7 +188,7 @@ public class QueueCleanerIntegrationTests : IDisposable _fixture.ArrClient.DeleteQueueItemAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.CompletedTask); + .Returns(true); await _fixture.ProcessCapturedRemoveRequestsAsync(); @@ -279,7 +279,7 @@ public class QueueCleanerIntegrationTests : IDisposable _fixture.ArrClient.DeleteQueueItemAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.CompletedTask); + .Returns(true); await _fixture.ProcessCapturedRemoveRequestsAsync(); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/StrikerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/StrikerIntegrationTests.cs index 95b3aa46..cc034727 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/StrikerIntegrationTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/StrikerIntegrationTests.cs @@ -414,9 +414,9 @@ public class StrikerIntegrationTests : IDisposable events.Count.ShouldBe(1); events[0].IsDryRun.ShouldBe(true); - // Assert: DownloadItem marked for removal (striker still marks regardless of dry run) + // Assert: DownloadItem stays unmarked var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync(); - downloadItems[0].IsMarkedForRemoval.ShouldBe(true); + downloadItems[0].IsMarkedForRemoval.ShouldBe(false); } [Fact] diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs index 3e6ae3c4..12403a02 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs @@ -1323,6 +1323,75 @@ public class SeekerTests : IDisposable instanceConfig.CurrentCycleId.ShouldNotBe(currentCycleId); } + [Fact] + public async Task ExecuteAsync_Radarr_LiveRun_IgnoresDryRunHistory_SearchesItemAgain() + { + // Arrange - a dry run searched this movie in the current cycle + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = currentCycleId + }); + + _fixture.EventsContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 1, + ItemType = InstanceType.Radarr, + CycleId = currentCycleId, + LastSearchedAt = now, + ItemTitle = "Movie 1", + IsDryRun = true + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueIterator + .Iterate(mockArrClient, Arg.Any(), Arg.Any, Task>>()) + .Returns(Task.CompletedTask); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns( + ToAsyncEnumerable([ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] } + ])); + + mockArrClient + .SearchItemAsync(radarrInstance, Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + // Live run + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert - live run ignores the dry-run row and searches the item + await mockArrClient.Received(1) + .SearchItemAsync(radarrInstance, Arg.Any()); + } + #endregion #region Round-Robin @@ -3240,6 +3309,136 @@ public class SeekerTests : IDisposable history.IsDryRun.ShouldBe(false); } + [Fact] + public async Task ExecuteAsync_Radarr_LiveRunAfterDryRun_ClaimsDryRunHistoryRow() + { + // Arrange - a dry run searches the item first + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var cycleId = Guid.NewGuid(); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = cycleId + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueIterator + .Iterate(mockArrClient, Arg.Any(), Arg.Any, Task>>()) + .Returns(Task.CompletedTask); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns( + ToAsyncEnumerable([ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ])); + + mockArrClient + .SearchItemAsync(radarrInstance, Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + // Act - dry run writes a dry history row for the item + _dryRunInterceptor.IsDryRunEnabled().Returns(true); + await CreateSut().ExecuteAsync(); + + // Act - a live run searches the same item + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + await CreateSut().ExecuteAsync(); + + // Assert - live run owns the row, count restarts at 1 + var history = await _fixture.EventsContext.SeekerHistory + .FirstOrDefaultAsync(h => h.ArrInstanceId == radarrInstance.Id && h.ExternalItemId == 1); + history.ShouldNotBeNull(); + history.IsDryRun.ShouldBeFalse(); + history.SearchCount.ShouldBe(1); + } + + [Fact] + public async Task ExecuteAsync_Radarr_DryRunAfterLiveRun_DoesNotTouchLiveHistoryRow() + { + // Arrange - a live run searches the item first + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var cycleId = Guid.NewGuid(); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = cycleId + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueIterator + .Iterate(mockArrClient, Arg.Any(), Arg.Any, Task>>()) + .Returns(Task.CompletedTask); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns( + ToAsyncEnumerable([ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ])); + + mockArrClient + .SearchItemAsync(radarrInstance, Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + // Act - the live run writes a real history row + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + await CreateSut().ExecuteAsync(); + + // Act - advance past MinCycleTimeDays so the dry run re-selects the item + // Dry run rolls its own cycle and leaves the live row alone + _fixture.TimeProvider.Advance(TimeSpan.FromDays(8)); + _dryRunInterceptor.IsDryRunEnabled().Returns(true); + await CreateSut().ExecuteAsync(); + + // Assert - both runs search, the live row stays unchanged + await mockArrClient.Received(2) + .SearchItemAsync(radarrInstance, Arg.Any()); + + var liveHistory = await _fixture.EventsContext.SeekerHistory + .FirstOrDefaultAsync(h => h.ArrInstanceId == radarrInstance.Id && h.ExternalItemId == 1 && !h.IsDryRun); + liveHistory.ShouldNotBeNull(); + liveHistory.CycleId.ShouldBe(cycleId); + liveHistory.SearchCount.ShouldBe(1); + + // Assert - the dry cycle recorded the second search + var dryHistory = await _fixture.EventsContext.SeekerHistory + .FirstOrDefaultAsync(h => h.ArrInstanceId == radarrInstance.Id && h.ExternalItemId == 1 && h.IsDryRun); + dryHistory.ShouldNotBeNull(); + dryHistory.CycleId.ShouldNotBe(cycleId); + dryHistory.SearchCount.ShouldBe(1); + } + [Fact] public async Task ExecuteAsync_Radarr_ProactiveSearch_SavesCommandTracker() { @@ -4314,4 +4513,391 @@ public class SeekerTests : IDisposable } #endregion + + #region Dry Run Cycle Simulation + + [Fact] + public async Task ExecuteAsync_Radarr_DryRun_CycleComplete_ProgressesAcrossRuns() + { + // Arrange - both movies searched live, min cycle time elapsed + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = currentCycleId + }); + + _fixture.EventsContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 1, + ItemType = InstanceType.Radarr, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-10), + ItemTitle = "Movie 1" + }); + _fixture.EventsContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 2, + ItemType = InstanceType.Radarr, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-10), + ItemTitle = "Movie 2" + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueIterator + .Iterate(mockArrClient, Arg.Any(), Arg.Any, Task>>()) + .Returns(Task.CompletedTask); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns( + ToAsyncEnumerable([ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ])); + + List searchedMovieIds = []; + mockArrClient + .SearchItemAsync(radarrInstance, Arg.Any()) + .Returns(ci => + { + searchedMovieIds.Add(ci.ArgAt(1).Id); + return 100L; + }); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + _dryRunInterceptor.IsDryRunEnabled().Returns(true); + + // Act - two dry runs + await CreateSut().ExecuteAsync(); + await CreateSut().ExecuteAsync(); + + // Assert - each run picked a different movie + searchedMovieIds.Count.ShouldBe(2); + searchedMovieIds[0].ShouldNotBe(searchedMovieIds[1]); + searchedMovieIds.OrderBy(id => id).ShouldBe(new List { 1, 2 }); + + // Assert - dry rows share one new cycle + List dryRows = await _fixture.EventsContext.SeekerHistory + .AsNoTracking() + .Where(h => h.ArrInstanceId == radarrInstance.Id && h.IsDryRun) + .ToListAsync(); + dryRows.Count.ShouldBe(2); + dryRows.Select(h => h.CycleId).Distinct().Count().ShouldBe(1); + dryRows[0].CycleId.ShouldNotBe(currentCycleId); + + // Assert - live rows untouched + List liveRows = await _fixture.EventsContext.SeekerHistory + .AsNoTracking() + .Where(h => h.ArrInstanceId == radarrInstance.Id && !h.IsDryRun) + .ToListAsync(); + liveRows.Count.ShouldBe(2); + liveRows.ShouldAllBe(h => h.CycleId == currentCycleId && h.SearchCount == 1); + + // Assert - live cycle id unchanged + _fixture.DataContext.ChangeTracker.Clear(); + var reloadedConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == radarrInstance.Id); + reloadedConfig.CurrentCycleId.ShouldBe(currentCycleId); + } + + [Fact] + public async Task ExecuteAsync_Radarr_DryRun_CompletedSimulatedCycle_RollsToAnotherNewCycle() + { + // Arrange - one movie searched live, min cycle time elapsed + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = currentCycleId + }); + + _fixture.EventsContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 1, + ItemType = InstanceType.Radarr, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-10), + ItemTitle = "Movie 1" + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueIterator + .Iterate(mockArrClient, Arg.Any(), Arg.Any, Task>>()) + .Returns(Task.CompletedTask); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns( + ToAsyncEnumerable([ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ])); + + mockArrClient + .SearchItemAsync(radarrInstance, Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + _dryRunInterceptor.IsDryRunEnabled().Returns(true); + + // Act - first dry run rolls a dry cycle + await CreateSut().ExecuteAsync(); + + SeekerHistory firstDryRow = await _fixture.EventsContext.SeekerHistory + .AsNoTracking() + .FirstAsync(h => h.ArrInstanceId == radarrInstance.Id && h.IsDryRun); + Guid firstDryCycleId = firstDryRow.CycleId; + + // Act - min cycle time passes, second dry run + _fixture.TimeProvider.Advance(TimeSpan.FromDays(8)); + await CreateSut().ExecuteAsync(); + + // Assert - second dry cycle, live cycle id unchanged + SeekerHistory latestDryRow = await _fixture.EventsContext.SeekerHistory + .AsNoTracking() + .Where(h => h.ArrInstanceId == radarrInstance.Id && h.IsDryRun) + .OrderByDescending(h => h.LastSearchedAt) + .FirstAsync(); + latestDryRow.CycleId.ShouldNotBe(firstDryCycleId); + latestDryRow.CycleId.ShouldNotBe(currentCycleId); + + _fixture.DataContext.ChangeTracker.Clear(); + var reloadedConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == radarrInstance.Id); + reloadedConfig.CurrentCycleId.ShouldBe(currentCycleId); + } + + [Fact] + public async Task ExecuteAsync_Radarr_LiveRun_IgnoresSimulatedDryCycle() + { + // Arrange - a dry cycle left by an earlier dry run + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + var dryCycleId = Guid.NewGuid(); + var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = currentCycleId + }); + + _fixture.EventsContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 1, + ItemType = InstanceType.Radarr, + CycleId = dryCycleId, + LastSearchedAt = now, + ItemTitle = "Movie 1", + IsDryRun = true + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueIterator + .Iterate(mockArrClient, Arg.Any(), Arg.Any, Task>>()) + .Returns(Task.CompletedTask); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns( + ToAsyncEnumerable([ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ])); + + mockArrClient + .SearchItemAsync(radarrInstance, Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + // Live run + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + + // Act + await CreateSut().ExecuteAsync(); + + // Assert - live run searches under the live cycle + await mockArrClient.Received(1) + .SearchItemAsync(radarrInstance, Arg.Any()); + + SeekerHistory? liveRow = await _fixture.EventsContext.SeekerHistory + .AsNoTracking() + .FirstOrDefaultAsync(h => h.ArrInstanceId == radarrInstance.Id && !h.IsDryRun); + liveRow.ShouldNotBeNull(); + liveRow.CycleId.ShouldBe(currentCycleId); + } + + [Fact] + public async Task ExecuteAsync_Sonarr_DryRun_CycleComplete_ProgressesAcrossRuns() + { + // Arrange - both seasons searched live, min cycle time elapsed + SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + ArrInstance sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + Guid currentCycleId = Guid.NewGuid(); + DateTime now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = sonarrInstance.Id, + ArrInstance = sonarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = currentCycleId + }); + + _fixture.EventsContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = sonarrInstance.Id, + ExternalItemId = 10, + ItemType = InstanceType.Sonarr, + SeasonNumber = 1, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-10), + ItemTitle = "Test Series" + }); + _fixture.EventsContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = sonarrInstance.Id, + ExternalItemId = 10, + ItemType = InstanceType.Sonarr, + SeasonNumber = 2, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-10), + ItemTitle = "Test Series" + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + IArrClient mockArrClient = Substitute.For(); + + _fixture.ArrQueueIterator + .Iterate(mockArrClient, Arg.Any(), Arg.Any, Task>>()) + .Returns(Task.CompletedTask); + + _sonarrClient + .StreamAllSeriesAsync(sonarrInstance, Arg.Any()) + .Returns( + ToAsyncEnumerable([ + new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 20, EpisodeFileCount = 10 } } + ])); + + DateTime pastDate = now.AddDays(-30); + _sonarrClient + .GetEpisodesAsync(Arg.Any(), 10) + .Returns( + [ + new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, HasFile = false, AirDateUtc = pastDate }, + new SearchableEpisode { Id = 101, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, HasFile = false, AirDateUtc = pastDate } + ]); + + List searchedSeasons = []; + mockArrClient + .SearchItemAsync(sonarrInstance, Arg.Any()) + .Returns(ci => + { + SeriesSearchItem searchItem = ci.ArgAt(1) as SeriesSearchItem; + searchedSeasons.Add(searchItem.Id); + return 100L; + }); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Sonarr, Arg.Any()) + .Returns(mockArrClient); + + _dryRunInterceptor.IsDryRunEnabled().Returns(true); + + // Act - two dry runs + await CreateSut().ExecuteAsync(); + await CreateSut().ExecuteAsync(); + + // Assert - each run picked a different season + searchedSeasons.Count.ShouldBe(2); + searchedSeasons[0].ShouldNotBe(searchedSeasons[1]); + searchedSeasons.OrderBy(id => id).ShouldBe(new List { 1L, 2L }); + + // Assert - dry rows share one new cycle + List dryRows = await _fixture.EventsContext.SeekerHistory + .AsNoTracking() + .Where(h => h.ArrInstanceId == sonarrInstance.Id && h.IsDryRun) + .ToListAsync(); + dryRows.Count.ShouldBe(2); + dryRows.Select(h => h.CycleId).Distinct().Count().ShouldBe(1); + dryRows[0].CycleId.ShouldNotBe(currentCycleId); + + // Assert - live rows untouched + List liveRows = await _fixture.EventsContext.SeekerHistory + .AsNoTracking() + .Where(h => h.ArrInstanceId == sonarrInstance.Id && !h.IsDryRun) + .ToListAsync(); + liveRows.Count.ShouldBe(2); + liveRows.ShouldAllBe(h => h.CycleId == currentCycleId && h.SearchCount == 1); + + // Assert - live cycle id unchanged + _fixture.DataContext.ChangeTracker.Clear(); + SeekerInstanceConfig reloadedConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == sonarrInstance.Id); + reloadedConfig.CurrentCycleId.ShouldBe(currentCycleId); + } + + #endregion } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/LazyLibrarian/LazyLibrarianServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/LazyLibrarian/LazyLibrarianServiceTests.cs index f505c890..da1d9328 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/LazyLibrarian/LazyLibrarianServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/LazyLibrarian/LazyLibrarianServiceTests.cs @@ -8,6 +8,7 @@ using Cleanuparr.Infrastructure.Tests.TestHelpers; using Cleanuparr.Persistence.Models.Configuration.Arr; using Microsoft.Extensions.Logging; using NSubstitute; +using NSubstitute.Core; using Shouldly; using Xunit; @@ -16,6 +17,7 @@ namespace Cleanuparr.Infrastructure.Tests.Features.LazyLibrarian; public class LazyLibrarianServiceTests { private readonly FakeHttpMessageHandler _httpMessageHandler; + private readonly IDryRunInterceptor _dryRunInterceptor; private readonly LazyLibrarianService _service; private readonly ArrInstance _instance; @@ -32,19 +34,19 @@ public class LazyLibrarianServiceTests Arg.Any(), Arg.Any>())) .Do(ci => _loggedMessages.Add(ci.ArgAt(2).ToString() ?? string.Empty)); - IDryRunInterceptor dryRunInterceptor = Substitute.For(); + _dryRunInterceptor = Substitute.For(); _httpMessageHandler = new FakeHttpMessageHandler(); HttpClient httpClient = new(_httpMessageHandler); IHttpClientFactory httpClientFactory = Substitute.For(); httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); - dryRunInterceptor.IsDryRunEnabled().Returns(false); - dryRunInterceptor + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + _dryRunInterceptor .InterceptAsync(Arg.Any>>(), Arg.Any()) .Returns(async ci => await ci.Arg>>()()); - _service = new LazyLibrarianService(logger, httpClientFactory, dryRunInterceptor); + _service = new LazyLibrarianService(logger, httpClientFactory, _dryRunInterceptor); _instance = new ArrInstance { Name = "lazylibrarian", @@ -414,19 +416,90 @@ public class LazyLibrarianServiceTests }); // Act - await _service.ResetItemAsync(_instance, CreateItem( + bool sent = await _service.ResetItemAsync(_instance, CreateItem( [ new LazyLibrarianBookRef { BookId = "OL1W", Library = BookLibrary.EBook }, new LazyLibrarianBookRef { BookId = "OL2W", Library = BookLibrary.AudioBook }, ])); // Assert: the audio status is separate, so the audiobook carries the type parameter. + sent.ShouldBeTrue(); requested.Select(uri => uri.Query).ShouldBe([ $"?apikey={ApiKey}&cmd=queueBook&id=OL1W", $"?apikey={ApiKey}&cmd=queueBook&id=OL2W&type=AudioBook", ]); } + [Fact] + public async Task ResetItemAsync_DryRunSkipsTheRequest_ReturnsFalse() + { + // Arrange: dry run skips every request + _dryRunInterceptor + .InterceptAsync(Arg.Any>>(), Arg.Any()) + .Returns((HttpResponseMessage?)null); + + // Act + bool sent = await _service.ResetItemAsync(_instance, CreateItem()); + + // Assert + sent.ShouldBeFalse(); + _httpMessageHandler.CapturedRequests.ShouldBeEmpty(); + } + + [Fact] + public async Task ResetItemAsync_DryRunSkipsOneBook_ReturnsFalse() + { + // Arrange: dry run skips the second book + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("OK", Encoding.UTF8, "text/plain"), + })); + + int callCount = 0; + _dryRunInterceptor + .InterceptAsync(Arg.Any>>(), Arg.Any()) + .ReturnsForAnyArgs(async ci => + { + callCount++; + if (callCount == 1) + { + return await ci.Arg>>()(); + } + return null; + }); + + // Act + bool sent = await _service.ResetItemAsync(_instance, CreateItem( + [ + new LazyLibrarianBookRef { BookId = "OL1W", Library = BookLibrary.EBook }, + new LazyLibrarianBookRef { BookId = "OL2W", Library = BookLibrary.AudioBook }, + ])); + + // Assert: one request sent + sent.ShouldBeFalse(); + _httpMessageHandler.CapturedRequests.Count.ShouldBe(1); + } + + [Fact] + public async Task ResetItemAsync_NoBooks_ReturnsFalse() + { + // Arrange: item with no books + LazyLibrarianQueueItem item = new() + { + DownloadId = "HASH1", + Title = "A Book", + Books = [], + Source = LazyLibrarianSource.QBittorrent, + Origin = LazyLibrarianOrigin.New, + }; + + // Act + bool sent = await _service.ResetItemAsync(_instance, item); + + // Assert + sent.ShouldBeFalse(); + } + [Fact] public async Task TriggerSearchAsync_SearchesEveryBookOnTheDownload() { diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Interceptors/DryRunInterceptorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Interceptors/DryRunInterceptorTests.cs new file mode 100644 index 00000000..a7cdce73 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Interceptors/DryRunInterceptorTests.cs @@ -0,0 +1,108 @@ +using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Infrastructure.Interceptors; +using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.General; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Interceptors; + +public sealed class DryRunInterceptorTests : IDisposable +{ + private readonly DataContext _dataContext; + private readonly DryRunInterceptor _interceptor; + + public DryRunInterceptorTests() + { + _dataContext = TestDataContextFactory.Create(); + _interceptor = new DryRunInterceptor(Substitute.For>(), _dataContext); + } + + public void Dispose() + { + _dataContext.Dispose(); + } + + private async Task SetDbDryRun(bool value) + { + GeneralConfig config = await _dataContext.GeneralConfigs.SingleAsync(); + config.DryRun = value; + await _dataContext.SaveChangesAsync(); + } + + [Fact] + public async Task IsDryRunEnabled_DbTrueNoStickyValue_ReturnsTrue() + { + await SetDbDryRun(true); + + bool result = await _interceptor.IsDryRunEnabled(); + + result.ShouldBeTrue(); + } + + [Fact] + public async Task IsDryRunEnabled_DbFalseNoStickyValue_ReturnsFalse() + { + await SetDbDryRun(false); + + bool result = await _interceptor.IsDryRunEnabled(); + + result.ShouldBeFalse(); + } + + [Fact] + public async Task IsDryRunEnabled_StickyTrueDbFalse_ReturnsTrue() + { + await SetDbDryRun(false); + ContextProvider.SetDryRun(true); + + bool result = await _interceptor.IsDryRunEnabled(); + + result.ShouldBeTrue(); + } + + [Fact] + public async Task InterceptAsync_StickyTrueDbFalse_SkipsTheAction() + { + await SetDbDryRun(false); + ContextProvider.SetDryRun(true); + bool ran = false; + + await _interceptor.InterceptAsync(() => + { + ran = true; + return Task.CompletedTask; + }); + + ran.ShouldBeFalse(); + } + + [Fact] + public void Intercept_StickyTrueDbFalse_SkipsTheAction() + { + _dataContext.GeneralConfigs.Single().DryRun = false; + _dataContext.SaveChanges(); + ContextProvider.SetDryRun(true); + bool ran = false; + + _interceptor.Intercept(() => ran = true); + + ran.ShouldBeFalse(); + } + + [Fact] + public void Intercept_StickyFalseDbFalse_RunsTheAction() + { + _dataContext.GeneralConfigs.Single().DryRun = false; + _dataContext.SaveChanges(); + bool ran = false; + + _interceptor.Intercept(() => ran = true); + + ran.ShouldBeTrue(); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Services/QueueRuleEvaluatorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Services/QueueRuleEvaluatorTests.cs index c43c6db0..adc79178 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Services/QueueRuleEvaluatorTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Services/QueueRuleEvaluatorTests.cs @@ -5,6 +5,7 @@ using Cleanuparr.Domain.Entities; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Infrastructure.Features.ItemStriker; +using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Infrastructure.Services; using Cleanuparr.Infrastructure.Services.Interfaces; using Cleanuparr.Persistence; @@ -48,8 +49,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var stallRule = new StallRule { @@ -115,8 +118,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); ruleManager .GetMatchingStallRule(Arg.Any()) @@ -137,8 +142,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var stallRule = CreateStallRule("Stall Apply", resetOnProgress: false, maxStrikes: 5); @@ -166,8 +173,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var stallRule = CreateStallRule("Stall Remove", resetOnProgress: false, maxStrikes: 6); @@ -194,8 +203,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var failingRule = CreateStallRule("Failing", resetOnProgress: false, maxStrikes: 4); @@ -221,8 +232,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); ruleManager .GetMatchingSlowRule(Arg.Any()) @@ -243,8 +256,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var slowRule = CreateSlowRule("Slow Apply", resetOnProgress: false, maxStrikes: 3); @@ -271,8 +286,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var slowRule = CreateSlowRule("Slow Remove", resetOnProgress: false, maxStrikes: 8); @@ -299,8 +316,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var slowRule = CreateSlowRule("Slow Progress", resetOnProgress: true, maxStrikes: 4); @@ -326,8 +345,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var failingRule = CreateSlowRule("Failing Slow", resetOnProgress: false, maxStrikes: 4); @@ -353,8 +374,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var slowRule = CreateSlowRule( name: "Speed Rule", @@ -387,8 +410,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var slowRule = CreateSlowRule( name: "Both Rule", @@ -420,8 +445,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); // Neither minSpeed nor maxTime set (maxTimeHours = 0, minSpeed = null) var slowRule = CreateSlowRule( @@ -450,8 +477,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var slowRule = CreateSlowRule( name: "Speed Reset", @@ -482,8 +511,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var slowRule = CreateSlowRule( name: "Speed No Reset", @@ -510,8 +541,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var slowRule = CreateSlowRule( name: "Time No Reset", @@ -538,8 +571,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var slowRule = CreateSlowRule( name: "Speed Strike", @@ -572,8 +607,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var slowRule = CreateSlowRule( name: "Time Strike", @@ -606,8 +643,10 @@ public class QueueRuleEvaluatorTests : IDisposable IStriker striker = Substitute.For(); ILogger logger = Substitute.For>(); EventsContext context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - QueueRuleEvaluator evaluator = new(ruleManager, striker, context, logger); + QueueRuleEvaluator evaluator = new(ruleManager, striker, context, logger, dryRunInterceptor); SlowRule slowRule = CreateSlowRule( name: "Ignore When Throttled", @@ -641,8 +680,10 @@ public class QueueRuleEvaluatorTests : IDisposable IStriker striker = Substitute.For(); ILogger logger = Substitute.For>(); EventsContext context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - QueueRuleEvaluator evaluator = new(ruleManager, striker, context, logger); + QueueRuleEvaluator evaluator = new(ruleManager, striker, context, logger, dryRunInterceptor); SlowRule slowRule = CreateSlowRule( name: "Strike When Throttled", @@ -677,8 +718,10 @@ public class QueueRuleEvaluatorTests : IDisposable IStriker striker = Substitute.For(); ILogger logger = Substitute.For>(); EventsContext context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - QueueRuleEvaluator evaluator = new(ruleManager, striker, context, logger); + QueueRuleEvaluator evaluator = new(ruleManager, striker, context, logger, dryRunInterceptor); SlowRule slowRule = CreateSlowRule( name: "Opt In But Not Throttled", @@ -713,8 +756,10 @@ public class QueueRuleEvaluatorTests : IDisposable IStriker striker = Substitute.For(); ILogger logger = Substitute.For>(); EventsContext context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - QueueRuleEvaluator evaluator = new(ruleManager, striker, context, logger); + QueueRuleEvaluator evaluator = new(ruleManager, striker, context, logger, dryRunInterceptor); SlowRule slowRule = CreateSlowRule( name: "No Opt In", @@ -755,8 +800,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var stallRule = CreateStallRule("No Reset", resetOnProgress: false, maxStrikes: 3); @@ -788,6 +835,8 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); // Seed database with a DownloadItem and initial strike (simulating first observation at 0 bytes) var downloadItem = new DownloadItem { DownloadId = "hash", Title = "Example Torrent" }; @@ -798,7 +847,7 @@ public class QueueRuleEvaluatorTests : IDisposable context.Strikes.Add(initialStrike); await context.SaveChangesAsync(); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var stallRule = CreateStallRule("Reset No Minimum", resetOnProgress: true, maxStrikes: 3, minimumProgress: null); @@ -823,6 +872,88 @@ public class QueueRuleEvaluatorTests : IDisposable await striker.Received(1).ResetStrikeAsync("hash", "Example Torrent", StrikeType.Stalled); } + [Fact] + public async Task EvaluateStallRulesAsync_LiveRun_IgnoresDryRunStrikeProgress() + { + // Arrange - only a dry-run strike exists + var ruleManager = Substitute.For(); + var striker = Substitute.For(); + var logger = Substitute.For>(); + var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); + + var downloadItem = new DownloadItem { DownloadId = "hash", Title = "Example Torrent" }; + context.DownloadItems.Add(downloadItem); + await context.SaveChangesAsync(); + + var dryRunStrike = new Strike { DownloadItemId = downloadItem.Id, Type = StrikeType.Stalled, LastDownloadedBytes = 0, IsDryRun = true }; + context.Strikes.Add(dryRunStrike); + await context.SaveChangesAsync(); + + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); + + var stallRule = CreateStallRule("Live Ignores Dry Run", resetOnProgress: true, maxStrikes: 3, minimumProgress: null); + + ruleManager + .GetMatchingStallRule(Arg.Any()) + .Returns(stallRule); + + striker + .StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), StrikeType.Stalled, Arg.Any()) + .Returns(false); + + long downloadedBytes = ByteSize.Parse("1 KB").Bytes; + var torrent = CreateTorrentMock(downloadedBytesFactory: () => downloadedBytes); + + // Act + await evaluator.EvaluateStallRulesAsync(torrent); + + // Assert - no live strike to compare, no reset + await striker.DidNotReceive().ResetStrikeAsync(Arg.Any(), Arg.Any(), StrikeType.Stalled); + } + + [Fact] + public async Task EvaluateStallRulesAsync_DryRun_IgnoresLiveStrikeProgress() + { + // Arrange - only a live strike exists, dry run is on + IQueueRuleManager ruleManager = Substitute.For(); + IStriker striker = Substitute.For(); + ILogger logger = Substitute.For>(); + EventsContext context = CreateInMemoryEventsContext(); + IDryRunInterceptor dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(true); + + DownloadItem downloadItem = new DownloadItem { DownloadId = "hash", Title = "Example Torrent" }; + context.DownloadItems.Add(downloadItem); + await context.SaveChangesAsync(); + + Strike liveStrike = new Strike { DownloadItemId = downloadItem.Id, Type = StrikeType.Stalled, LastDownloadedBytes = 0, IsDryRun = false }; + context.Strikes.Add(liveStrike); + await context.SaveChangesAsync(); + + QueueRuleEvaluator evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); + + StallRule stallRule = CreateStallRule("Dry Ignores Live", resetOnProgress: true, maxStrikes: 3, minimumProgress: null); + + ruleManager + .GetMatchingStallRule(Arg.Any()) + .Returns(stallRule); + + striker + .StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), StrikeType.Stalled, Arg.Any()) + .Returns(false); + + long downloadedBytes = ByteSize.Parse("1 KB").Bytes; + ITorrentItemWrapper torrent = CreateTorrentMock(downloadedBytesFactory: () => downloadedBytes); + + // Act + await evaluator.EvaluateStallRulesAsync(torrent); + + // Assert - no dry strike to compare, no reset + await striker.DidNotReceive().ResetStrikeAsync(Arg.Any(), Arg.Any(), StrikeType.Stalled); + } + private static ITorrentItemWrapper CreateTorrentMock( Func? downloadedBytesFactory = null, bool isPrivate = false, @@ -903,8 +1034,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); ruleManager .GetMatchingStallRule(Arg.Any()) @@ -926,8 +1059,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var stallRule = CreateStallRule("Test Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true); @@ -955,8 +1090,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var stallRule = CreateStallRule("Delete True Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true); @@ -984,8 +1121,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var stallRule = CreateStallRule("Delete False Rule", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: false); @@ -1013,8 +1152,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); ruleManager .GetMatchingSlowRule(Arg.Any()) @@ -1036,8 +1177,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var slowRule = CreateSlowRule("Slow Delete True", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true); @@ -1065,8 +1208,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var slowRule = CreateSlowRule("Slow Delete False", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: false); @@ -1094,8 +1239,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var slowRule = CreateSlowRule( "Speed Delete True", @@ -1130,8 +1277,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var slowRule = CreateSlowRule("Test Slow Rule", resetOnProgress: false, maxStrikes: 3, maxTimeHours: 1, deletePrivateTorrentsFromClient: true); @@ -1161,8 +1310,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var stallRule = CreateStallRule("Stall Change Category", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true, changeCategory: true); @@ -1191,8 +1342,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var stallRule = CreateStallRule("Stall Default", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: false, changeCategory: false); @@ -1219,8 +1372,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var slowRule = CreateSlowRule( "Slow Speed Change Category", @@ -1257,8 +1412,10 @@ public class QueueRuleEvaluatorTests : IDisposable var striker = Substitute.For(); var logger = Substitute.For>(); var context = CreateInMemoryEventsContext(); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); - var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger, dryRunInterceptor); var slowRule = CreateSlowRule( "Slow Time Change Category", diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Services/StrikerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Services/StrikerTests.cs index 1810d756..8dadf1fb 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Services/StrikerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Services/StrikerTests.cs @@ -28,6 +28,7 @@ public class StrikerTests : IDisposable private readonly ILogger _logger; private readonly EventPublisher _eventPublisher; private readonly Striker _striker; + private readonly IDryRunInterceptor _dryRunInterceptor; private readonly Guid _jobRunId; public StrikerTests() @@ -41,21 +42,21 @@ public class StrikerTests : IDisposable var eventLogger = Substitute.For>(); var notificationPublisher = Substitute.For(); - var dryRunInterceptor = Substitute.For(); + _dryRunInterceptor = Substitute.For(); // Configure dry run interceptor to report dry run as disabled by default - dryRunInterceptor.IsDryRunEnabled().Returns(false); + _dryRunInterceptor.IsDryRunEnabled().Returns(false); _eventPublisher = new EventPublisher( _strikerContext, eventNotifier, eventLogger, notificationPublisher, - dryRunInterceptor, + _dryRunInterceptor, new SqliteDatabaseProvider(), TimeProvider.System); - _striker = new Striker(_logger, _strikerContext, _eventPublisher, dryRunInterceptor); + _striker = new Striker(_logger, _strikerContext, _eventPublisher, _dryRunInterceptor); // Clear static state before each test Striker.RecurringHashes.Clear(); @@ -329,6 +330,83 @@ public class StrikerTests : IDisposable slowSpeedResult.ShouldBeTrue(); // Not reset, so this is strike #2 = maxStrikes } + [Fact] + public async Task ResetStrikeAsync_DryRun_KeepsLiveStrikes() + { + // Arrange - 2 live strikes and 1 dry run strike + const string hash = "abc123"; + const string itemName = "Test Item"; + const ushort maxStrikes = 5; + + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + + _dryRunInterceptor.IsDryRunEnabled().Returns(true); + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + + // Act + await _striker.ResetStrikeAsync(hash, itemName, StrikeType.Stalled); + + // Assert + DownloadItem item = await _strikerContext.DownloadItems.AsNoTracking() + .SingleAsync(d => d.DownloadId == hash); + List remaining = await _strikerContext.Strikes.AsNoTracking() + .Where(s => s.DownloadItemId == item.Id) + .ToListAsync(); + remaining.Count.ShouldBe(2); + remaining.ShouldAllBe(s => !s.IsDryRun); + } + + [Fact] + public async Task ResetStrikeAsync_DryRun_NoDryStrikes_DoesNothing() + { + // Arrange - live strikes only, dry run on + const string hash = "abc123"; + const string itemName = "Test Item"; + const ushort maxStrikes = 5; + + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + + int eventCountBefore = await _strikerContext.Events.CountAsync(); + + // Act + _dryRunInterceptor.IsDryRunEnabled().Returns(true); + await _striker.ResetStrikeAsync(hash, itemName, StrikeType.Stalled); + + // Assert - no strikes deleted, no event + DownloadItem item = await _strikerContext.DownloadItems.AsNoTracking() + .SingleAsync(d => d.DownloadId == hash); + (await _strikerContext.Strikes.AsNoTracking().CountAsync(s => s.DownloadItemId == item.Id)).ShouldBe(2); + (await _strikerContext.Events.CountAsync()).ShouldBe(eventCountBefore); + } + + [Fact] + public async Task ResetStrikeAsync_Live_DeletesAllStrikes() + { + // Arrange - a live strike and a dry run strike of the same type + const string hash = "abc123"; + const string itemName = "Test Item"; + const ushort maxStrikes = 5; + + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + + _dryRunInterceptor.IsDryRunEnabled().Returns(true); + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + + // Act + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + await _striker.ResetStrikeAsync(hash, itemName, StrikeType.Stalled); + + // Assert + DownloadItem item = await _strikerContext.DownloadItems.AsNoTracking() + .SingleAsync(d => d.DownloadId == hash); + (await _strikerContext.Strikes.AsNoTracking().CountAsync(s => s.DownloadItemId == item.Id)).ShouldBe(0); + } + [Fact] public async Task StrikeAndCheckLimit_ZeroMaxStrikes_ReturnsFalse() { @@ -464,6 +542,156 @@ public class StrikerTests : IDisposable strikeCount.ShouldBe(3); } + [Fact] + public async Task StrikeAndCheckLimit_DryRun_NotPreviouslyRemoved_ExceedsMax_DoesNotReportRecurring() + { + // Arrange + const string hash = "dry-run-not-removed"; + const string itemName = "Dry Run Item"; + const ushort maxStrikes = 1; + + _dryRunInterceptor.IsDryRunEnabled().Returns(true); + + // Act - strike past the limit without a prior removal + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + + // Assert - would remove, records no recurrence + result.ShouldBeTrue(); + Striker.RecurringHashes.ShouldNotContainKey(hash.ToLowerInvariant()); + (await _strikerContext.ManualEvents.CountAsync(e => e.Type == ManualEventType.RecurringDownload)).ShouldBe(0); + } + + [Fact] + public async Task StrikeAndCheckLimit_DryRun_PreviouslyRemoved_ExceedsMax_ReportsRecurring() + { + // Arrange + const string hash = "dry-run-returned"; + const string itemName = "Dry Run Returned Item"; + const ushort maxStrikes = 1; + + // A real run struck and removed the item. + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + DownloadItem downloadItem = await _strikerContext.DownloadItems.SingleAsync(d => d.DownloadId == hash); + downloadItem.IsRemoved = true; + await _strikerContext.SaveChangesAsync(); + + // Act - two dry strikes on the removed item + // Dry run ignores the live strike + _dryRunInterceptor.IsDryRunEnabled().Returns(true); + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + + // Assert - item removed then regrabbed by the arr counts as recurring + result.ShouldBeTrue(); + Striker.RecurringHashes.ShouldContainKey(hash.ToLowerInvariant()); + (await _strikerContext.ManualEvents.CountAsync(e => e.Type == ManualEventType.RecurringDownload)).ShouldBe(1); + } + + [Fact] + public async Task StrikeAndCheckLimit_LiveRun_DoesNotCountDryRunStrikes() + { + // Arrange - two dry run strikes, then a live run with a low limit + const string hash = "dry-run-not-counted"; + const string itemName = "Test Item"; + const ushort maxStrikes = 3; + + _dryRunInterceptor.IsDryRunEnabled().Returns(true); + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + + // Act - first live strike + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + var result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + + // Assert - only 1 live strike exists, below maxStrikes + result.ShouldBeFalse(); + } + + [Fact] + public async Task StrikeAndCheckLimit_DryRun_PreviouslyRemoved_DoesNotMutateRemovalFlags() + { + // Arrange - a real run removed the item + const string hash = "dry-run-flag-guard"; + const string itemName = "Dry Run Flag Guard Item"; + const ushort maxStrikes = 5; + + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + DownloadItem downloadItem = await _strikerContext.DownloadItems.SingleAsync(d => d.DownloadId == hash); + downloadItem.IsRemoved = true; + await _strikerContext.SaveChangesAsync(); + + // Act - a dry run strikes the already-removed item again + _dryRunInterceptor.IsDryRunEnabled().Returns(true); + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + + // Assert - removal flags unchanged + DownloadItem item = await _strikerContext.DownloadItems.AsNoTracking() + .SingleAsync(d => d.DownloadId == hash); + item.IsRemoved.ShouldBeTrue(); + item.IsReturning.ShouldBeFalse(); + } + + [Fact] + public async Task StrikeAndCheckLimit_DryRun_ReachesMaxStrikes_DoesNotSetMarkedForRemoval() + { + // Arrange + const string hash = "dry-run-marked-guard"; + const string itemName = "Dry Run Marked Guard Item"; + const ushort maxStrikes = 1; + + _dryRunInterceptor.IsDryRunEnabled().Returns(true); + + // Act + bool result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + + // Assert - reports the hit, leaves the item unmarked + result.ShouldBeTrue(); + DownloadItem item = await _strikerContext.DownloadItems.AsNoTracking() + .SingleAsync(d => d.DownloadId == hash); + item.IsMarkedForRemoval.ShouldBeFalse(); + } + + [Fact] + public async Task StrikeAndCheckLimit_LiveRun_PreviouslyRemoved_SetsReturningFlags() + { + // Arrange - a prior run removed the item + const string hash = "live-run-returning"; + const string itemName = "Live Run Returning Item"; + const ushort maxStrikes = 5; + + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + DownloadItem downloadItem = await _strikerContext.DownloadItems.SingleAsync(d => d.DownloadId == hash); + downloadItem.IsRemoved = true; + await _strikerContext.SaveChangesAsync(); + + // Act - a live run strikes the already-removed item again + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + + // Assert - item flagged as returning + DownloadItem item = await _strikerContext.DownloadItems.AsNoTracking() + .SingleAsync(d => d.DownloadId == hash); + item.IsRemoved.ShouldBeFalse(); + item.IsReturning.ShouldBeTrue(); + } + + [Fact] + public async Task StrikeAndCheckLimit_LiveRun_ReachesMaxStrikes_SetsMarkedForRemoval() + { + // Arrange + const string hash = "live-run-marked"; + const string itemName = "Live Run Marked Item"; + const ushort maxStrikes = 1; + + // Act + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + + // Assert + DownloadItem item = await _strikerContext.DownloadItems.AsNoTracking() + .SingleAsync(d => d.DownloadId == hash); + item.IsMarkedForRemoval.ShouldBeTrue(); + } + [Fact] public async Task StrikeAndCheckLimit_StoresTitleOnDownloadItem() { @@ -481,6 +709,56 @@ public class StrikerTests : IDisposable downloadItem.Title.ShouldBe(itemName); } + [Fact] + public async Task StrikeAndCheckLimit_DryRun_IgnoresLiveStrikes() + { + // Arrange - 2 live strikes, dry run on, max 3 + const string hash = "dry-run-ignores-live"; + const string itemName = "Test Item"; + const ushort maxStrikes = 3; + + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + + // Act + _dryRunInterceptor.IsDryRunEnabled().Returns(true); + bool result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + + // Assert - live strikes ignored, dry count is 1 + result.ShouldBeFalse(); + } + + [Fact] + public async Task StrikeAndCheckLimit_DryRun_ResetThenStrike_StartsFromFreshDrySlate() + { + // Arrange - 2 live strikes, dry run on, max 3 + const string hash = "dry-run-fresh-slate"; + const string itemName = "Test Item"; + const ushort maxStrikes = 3; + + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + + _dryRunInterceptor.IsDryRunEnabled().Returns(true); + + // Act - dry reset, then a dry strike + await _striker.ResetStrikeAsync(hash, itemName, StrikeType.Stalled); + bool result = await _striker.StrikeAndCheckLimit(hash, itemName, maxStrikes, StrikeType.Stalled); + + // Assert - dry count is 1, live strikes untouched + result.ShouldBeFalse(); + DownloadItem item = await _strikerContext.DownloadItems.AsNoTracking() + .SingleAsync(d => d.DownloadId == hash); + int dryCount = await _strikerContext.Strikes.AsNoTracking() + .CountAsync(s => s.DownloadItemId == item.Id && s.IsDryRun); + int liveCount = await _strikerContext.Strikes.AsNoTracking() + .CountAsync(s => s.DownloadItemId == item.Id && !s.IsDryRun); + dryCount.ShouldBe(1); + liveCount.ShouldBe(2); + } + [Fact] public async Task StrikeAndCheckLimit_UpdatesTitleOnDownloadItem_WhenTitleChanges() { diff --git a/code/backend/Cleanuparr.Infrastructure/Events/EventCleanupService.cs b/code/backend/Cleanuparr.Infrastructure/Events/EventCleanupService.cs index ee7b05fc..eef4fd25 100644 --- a/code/backend/Cleanuparr.Infrastructure/Events/EventCleanupService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Events/EventCleanupService.cs @@ -1,6 +1,6 @@ +using Cleanuparr.Infrastructure.Features.DryRun; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.General; -using Cleanuparr.Persistence.Models.Events; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -16,21 +16,29 @@ public class EventCleanupService : BackgroundService private readonly ILogger _logger; private readonly IServiceScopeFactory _scopeFactory; private readonly TimeProvider _timeProvider; + private readonly DryRunActivity _dryRunActivity; private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(4); // Run every 4 hours private readonly int _eventRetentionDays = 30; // Keep events for 30 days - public EventCleanupService(ILogger logger, IServiceScopeFactory scopeFactory, TimeProvider timeProvider) + public EventCleanupService( + ILogger logger, + IServiceScopeFactory scopeFactory, + TimeProvider timeProvider, + DryRunActivity dryRunActivity) { _logger = logger; _scopeFactory = scopeFactory; _timeProvider = timeProvider; + _dryRunActivity = dryRunActivity; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation("Event cleanup service started. Interval: {interval}, Retention: {retention} days", + _logger.LogInformation("Event cleanup service started. Interval: {Interval}, Retention: {Retention} days", _cleanupInterval, _eventRetentionDays); + int purgeFailures = 0; + while (!stoppingToken.IsCancellationRequested) { try @@ -40,9 +48,11 @@ public class EventCleanupService : BackgroundService break; } - await PerformCleanupAsync(); - - await Task.Delay(_cleanupInterval, _timeProvider, stoppingToken); + bool purgeFailed = await PerformCleanupAsync(); + purgeFailures = purgeFailed ? purgeFailures + 1 : 0; + TimeSpan wait = purgeFailed ? GetRetryDelay(purgeFailures) : _cleanupInterval; + + await WaitForNextRunAsync(wait, stoppingToken); } catch (OperationCanceledException) { @@ -58,8 +68,84 @@ public class EventCleanupService : BackgroundService _logger.LogInformation("Event cleanup service stopped"); } - internal async Task PerformCleanupAsync() + /// + /// Waits for the next run. + /// Returns early when a purge request arrives and dry run is already off. + /// + internal async Task WaitForNextRunAsync(TimeSpan wait, CancellationToken ct) { + DateTimeOffset deadline = _timeProvider.GetUtcNow() + wait; + + while (true) + { + TimeSpan remaining = deadline - _timeProvider.GetUtcNow(); + + if (remaining <= TimeSpan.Zero) + { + return; + } + + using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + Task delayTask = Task.Delay(remaining, _timeProvider, cts.Token); + Task signalTask = _dryRunActivity.WaitForPurgeRequestAsync(cts.Token).AsTask(); + + Task winner = await Task.WhenAny(delayTask, signalTask); + await cts.CancelAsync(); + + if (winner == delayTask) + { + await ObserveLoserAsync(signalTask); + return; + } + + await ObserveLoserAsync(delayTask); + + await using AsyncServiceScope scope = _scopeFactory.CreateAsyncScope(); + DataContext dataContext = scope.ServiceProvider.GetRequiredService(); + GeneralConfig config = await dataContext.GeneralConfigs + .AsNoTracking() + .FirstAsync(ct); + + if (!config.DryRun) + { + return; + } + } + } + + private static async Task ObserveLoserAsync(Task loser) + { + try + { + await loser; + } + catch (OperationCanceledException) + { + // Cancelled because the other task won + } + } + + /// + /// Retry delay after a failed purge, capped at 5 minutes. + /// + internal static TimeSpan GetRetryDelay(int failures) + { + return failures switch + { + <= 1 => TimeSpan.FromSeconds(30), + 2 => TimeSpan.FromMinutes(1), + _ => TimeSpan.FromMinutes(5), + }; + } + + /// + /// Runs one cleanup pass. + /// + /// True when the dry-run purge threw. + internal async Task PerformCleanupAsync() + { + bool purgeFailed = false; + try { await using var scope = _scopeFactory.CreateAsyncScope(); @@ -70,6 +156,20 @@ public class EventCleanupService : BackgroundService .AsNoTracking() .FirstAsync(); + if (!config.DryRun) + { + try + { + var dryRunPurger = scope.ServiceProvider.GetRequiredService(); + await dryRunPurger.PurgeAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to purge dry-run data"); + purgeFailed = true; + } + } + DateTimeOffset eventCutoff = _timeProvider.GetUtcNow().AddDays(-_eventRetentionDays); // Resolved manual events are transient @@ -87,6 +187,8 @@ public class EventCleanupService : BackgroundService { _logger.LogError(ex, "Failed to perform event cleanup"); } + + return purgeFailed; } internal async Task DeleteResolvedManualEventsAsync(EventsContext eventsContext, DateTimeOffset cutoff) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs index 2ffa0aa7..a7787709 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs @@ -215,7 +215,7 @@ public abstract class ArrClient : IArrClient } /// - public async Task ForceImportAsync(ArrInstance arrInstance, List files) + public async Task ForceImportAsync(ArrInstance arrInstance, List files) { UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command"; @@ -234,6 +234,7 @@ public abstract class ArrClient : IArrClient { HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request)); response?.Dispose(); + return response is not null; } catch { @@ -273,7 +274,7 @@ public abstract class ArrClient : IArrClient /// public virtual ManualImportFile? MapCandidate(QueueRecord record, ManualImportCandidate candidate) => null; - public virtual async Task DeleteQueueItemAsync( + public virtual async Task DeleteQueueItemAsync( ArrInstance arrInstance, QueueRecord record, bool removeFromClient, @@ -293,18 +294,21 @@ public abstract class ArrClient : IArrClient HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(() => SendRequestAsync(request)); response?.Dispose(); + // A null response means the interceptor skipped the request. + string prefix = response is null ? "[DRY RUN] " : string.Empty; + string logMessage; if (changeCategory) { - logMessage = "queue item category changed in arr with reason {reason} | {url} | {title}"; + logMessage = prefix + "queue item category changed in arr with reason {reason} | {url} | {title}"; } else if (removeFromClient) { - logMessage = "queue item deleted with reason {reason} | {url} | {title}"; + logMessage = prefix + "queue item deleted with reason {reason} | {url} | {title}"; } else { - logMessage = "queue item removed from arr with reason {reason} | {url} | {title}"; + logMessage = prefix + "queue item removed from arr with reason {reason} | {url} | {title}"; } _logger.LogInformation( @@ -313,6 +317,8 @@ public abstract class ArrClient : IArrClient arrInstance.Url, record.Title ); + + return response is not null; } catch { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ForceImport/ForceImportService.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ForceImport/ForceImportService.cs index 5d967a48..ac033bdc 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ForceImport/ForceImportService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ForceImport/ForceImportService.cs @@ -8,10 +8,12 @@ using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Helpers; +using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; namespace Cleanuparr.Infrastructure.Features.Arr.ForceImport; @@ -80,18 +82,25 @@ public sealed class ForceImportService : IForceImportService "importFailed", }; + /// + /// cancels it to evict every dry-run entry. + /// + private static CancellationTokenSource _dryRunEviction = new(); + private readonly ILogger _logger; private readonly IMemoryCache _cache; private readonly IStriker _striker; private readonly IEventPublisher _eventPublisher; private readonly TimeProvider _timeProvider; + private readonly IDryRunInterceptor _dryRunInterceptor; public ForceImportService( ILogger logger, IMemoryCache cache, IStriker striker, IEventPublisher eventPublisher, - TimeProvider timeProvider + TimeProvider timeProvider, + IDryRunInterceptor dryRunInterceptor ) { _logger = logger; @@ -99,6 +108,17 @@ public sealed class ForceImportService : IForceImportService _striker = striker; _eventPublisher = eventPublisher; _timeProvider = timeProvider; + _dryRunInterceptor = dryRunInterceptor; + } + + /// + /// Evicts the dry-run tries and give-up markers. + /// Live entries stay. + /// + public static void ForgetDryRun() + { + CancellationTokenSource previous = Interlocked.Exchange(ref _dryRunEviction, new CancellationTokenSource()); + previous.Cancel(); } /// @@ -129,7 +149,13 @@ public sealed class ForceImportService : IForceImportService return ForceImportOutcome.NotApplicable; } - string gaveUpKey = CacheKeys.ForceImportGaveUp(record.DownloadId, instance.Url); + // Read before the dry-run check so a purge during it still evicts this attempt. + CancellationToken dryRunEvictionToken = _dryRunEviction.Token; + + // Dry run keeps a separate try budget and give-up marker. + bool isDryRun = await _dryRunInterceptor.IsDryRunEnabled(); + + string gaveUpKey = CacheKeys.ForceImportGaveUp(record.DownloadId, instance.Url, isDryRun); if (_cache.TryGetValue(gaveUpKey, out DateTimeOffset gaveUpAt) && _timeProvider.GetUtcNow() - gaveUpAt < GaveUpWindow) { @@ -138,14 +164,12 @@ public sealed class ForceImportService : IForceImportService } ConcurrentDictionary pending = GetPending(instance); - string triesKey = CacheKeys.ForceImportTries(record.DownloadId, instance.Url); + string triesKey = CacheKeys.ForceImportTries(record.DownloadId, instance.Url, isDryRun); int tries = _cache.TryGetValue(triesKey, out int spent) ? spent : 0; if (tries >= config.ForceImportMaxTries) { - // The arr kept the download blocked, so the strike path takes over. - // The pending import stays: the last request can still land, and reconciliation retires it otherwise. - _cache.Set(gaveUpKey, _timeProvider.GetUtcNow(), GaveUpWindow); + SetCacheValue(gaveUpKey, _timeProvider.GetUtcNow(), GaveUpWindow, isDryRun, dryRunEvictionToken); _cache.Remove(triesKey); _logger.LogInformation("give up force import | {Tries} tries spent | {Title}", tries, record.Title); @@ -163,7 +187,10 @@ public sealed class ForceImportService : IForceImportService return ForceImportOutcome.Deferred; } - void SpendTry() => _cache.Set(triesKey, tries + 1, TriesWindow); + void SpendTry() + { + SetCacheValue(triesKey, tries + 1, TriesWindow, isDryRun, dryRunEvictionToken); + } int importedBefore; @@ -187,9 +214,24 @@ public sealed class ForceImportService : IForceImportService return failure!.Value; } - await arrClient.ForceImportAsync(instance, files); + bool sent = await arrClient.ForceImportAsync(instance, files); + + if (!sent) + { + if (isDryRun) + { + SpendTry(); + _logger.LogInformation( + "[DRY RUN] would ask the arr to import {Count} file(s) | {Title}", + files.Count, record.Title + ); + } + + return ForceImportOutcome.Deferred; + } SpendTry(); + pending.AddOrUpdate( record.DownloadId, _ => new PendingForceImport(record, importedBefore, _timeProvider.GetUtcNow()), @@ -206,7 +248,9 @@ public sealed class ForceImportService : IForceImportService { // The arr answered, so the try is spent and the strike path stays reachable. SpendTry(); - _logger.LogError(exception, "force import failed | try {Try} | {Title}", tries + 1, record.Title); + + string prefix = isDryRun ? "[DRY RUN] " : string.Empty; + _logger.LogError(exception, "{Prefix}force import failed | try {Try} | {Title}", prefix, tries + 1, record.Title); } catch (Exception exception) { @@ -294,6 +338,25 @@ public sealed class ForceImportService : IForceImportService return false; } + /// + /// Tags dry-run entries so can evict them. + /// + /// + /// Pass the token read alongside the dry-run flag. + /// A fresh read lets a racing purge miss this write. + /// + private void SetCacheValue(string key, object value, TimeSpan window, bool isDryRun, CancellationToken dryRunEvictionToken) + { + MemoryCacheEntryOptions options = new() { AbsoluteExpirationRelativeToNow = window }; + + if (isDryRun) + { + options.AddExpirationToken(new CancellationChangeToken(dryRunEvictionToken)); + } + + _cache.Set(key, value, options); + } + /// public void Forget(ArrInstance instance, string downloadId) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs index 41d19fc6..20ca0f90 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs @@ -27,7 +27,8 @@ public interface IArrClient /// /// Makes the arr import files it refused to import on its own. /// - Task ForceImportAsync(ArrInstance arrInstance, List files); + /// True when the request was sent, false when dry run skipped it. + Task ForceImportAsync(ArrInstance arrInstance, List files); /// /// Counts the times the arr recorded an import for a download. @@ -52,7 +53,8 @@ public interface IArrClient /// When true, also delete the download from the download client. Ignored when is true. /// When true, instructs the *arr to change the download's category to the post-import category instead of removing it from the download client. Mutually exclusive with . /// Reason for removal, used for logging and event publishing. - Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, bool changeCategory, DeleteReason deleteReason); + /// True when the request was sent, false when dry run skipped it. + Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, bool changeCategory, DeleteReason deleteReason); /// /// Triggers a search for the specified items and returns the arr command IDs diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Auth/IOidcAuthService.cs b/code/backend/Cleanuparr.Infrastructure/Features/Auth/IOidcAuthService.cs index 62f73aa3..83e3802a 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Auth/IOidcAuthService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Auth/IOidcAuthService.cs @@ -1,27 +1,5 @@ namespace Cleanuparr.Infrastructure.Features.Auth; -public sealed record OidcAuthorizationResult -{ - public required string AuthorizationUrl { get; init; } - public required string State { get; init; } -} - -public sealed record OidcCallbackResult -{ - public required bool Success { get; init; } - public string? Subject { get; init; } - public string? PreferredUsername { get; init; } - public string? Email { get; init; } - public string? Error { get; init; } - - /// - /// The user ID of the authenticated user who initiated this OIDC flow. - /// Set when the flow is started from an authenticated context (e.g., account linking). - /// Used to verify the callback is completing the correct user's flow. - /// - public string? InitiatorUserId { get; init; } -} - public interface IOidcAuthService { /// @@ -48,10 +26,3 @@ public interface IOidcAuthService /// OidcTokenExchangeResult? ExchangeOneTimeCode(string code); } - -public sealed record OidcTokenExchangeResult -{ - public required string AccessToken { get; init; } - public required string RefreshToken { get; init; } - public required int ExpiresIn { get; init; } -} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Auth/IPlexAuthService.cs b/code/backend/Cleanuparr.Infrastructure/Features/Auth/IPlexAuthService.cs index 528452c7..02aecf71 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Auth/IPlexAuthService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Auth/IPlexAuthService.cs @@ -1,25 +1,5 @@ namespace Cleanuparr.Infrastructure.Features.Auth; -public sealed record PlexPinResult -{ - public required int PinId { get; init; } - public required string PinCode { get; init; } - public required string AuthUrl { get; init; } -} - -public sealed record PlexPinCheckResult -{ - public required bool Completed { get; init; } - public string? AuthToken { get; init; } -} - -public sealed record PlexAccountInfo -{ - public required string AccountId { get; init; } - public required string Username { get; init; } - public string? Email { get; init; } -} - public interface IPlexAuthService { /// diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Auth/OidcAuthorizationResult.cs b/code/backend/Cleanuparr.Infrastructure/Features/Auth/OidcAuthorizationResult.cs new file mode 100644 index 00000000..5aaa92ac --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Auth/OidcAuthorizationResult.cs @@ -0,0 +1,17 @@ +namespace Cleanuparr.Infrastructure.Features.Auth; + +/// +/// Result from initiating an OIDC authorization flow. +/// +public sealed record OidcAuthorizationResult +{ + /// + /// URL to redirect the user to for OIDC provider authentication. + /// + public required string AuthorizationUrl { get; init; } + + /// + /// Opaque state value to protect against CSRF attacks. + /// + public required string State { get; init; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Auth/OidcCallbackResult.cs b/code/backend/Cleanuparr.Infrastructure/Features/Auth/OidcCallbackResult.cs new file mode 100644 index 00000000..1f6084c7 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Auth/OidcCallbackResult.cs @@ -0,0 +1,39 @@ +namespace Cleanuparr.Infrastructure.Features.Auth; + +/// +/// Result from processing an OIDC callback from the authentication provider. +/// +public sealed record OidcCallbackResult +{ + /// + /// Whether the OIDC authentication succeeded. + /// + public required bool Success { get; init; } + + /// + /// Unique subject identifier from the OIDC provider. + /// + public string? Subject { get; init; } + + /// + /// Preferred username from the OIDC provider. + /// + public string? PreferredUsername { get; init; } + + /// + /// Email address from the OIDC provider. + /// + public string? Email { get; init; } + + /// + /// Error message if authentication failed. + /// + public string? Error { get; init; } + + /// + /// User ID of the authenticated user who initiated this OIDC flow. + /// Set when the flow is started from an authenticated context (e.g., account linking). + /// Used to verify the callback is completing the correct user's flow. + /// + public string? InitiatorUserId { get; init; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Auth/OidcTokenExchangeResult.cs b/code/backend/Cleanuparr.Infrastructure/Features/Auth/OidcTokenExchangeResult.cs new file mode 100644 index 00000000..180ced21 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Auth/OidcTokenExchangeResult.cs @@ -0,0 +1,22 @@ +namespace Cleanuparr.Infrastructure.Features.Auth; + +/// +/// Tokens obtained from exchanging an OIDC authorization code. +/// +public sealed record OidcTokenExchangeResult +{ + /// + /// JWT access token for API requests. + /// + public required string AccessToken { get; init; } + + /// + /// Refresh token used to obtain new access tokens. + /// + public required string RefreshToken { get; init; } + + /// + /// Seconds until the access token expires. + /// + public required int ExpiresIn { get; init; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Auth/PlexAccountInfo.cs b/code/backend/Cleanuparr.Infrastructure/Features/Auth/PlexAccountInfo.cs new file mode 100644 index 00000000..402e9838 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Auth/PlexAccountInfo.cs @@ -0,0 +1,22 @@ +namespace Cleanuparr.Infrastructure.Features.Auth; + +/// +/// User account information obtained from Plex authentication. +/// +public sealed record PlexAccountInfo +{ + /// + /// Plex account ID. + /// + public required string AccountId { get; init; } + + /// + /// Plex username. + /// + public required string Username { get; init; } + + /// + /// Email address associated with the Plex account. + /// + public string? Email { get; init; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Auth/PlexPinCheckResult.cs b/code/backend/Cleanuparr.Infrastructure/Features/Auth/PlexPinCheckResult.cs new file mode 100644 index 00000000..89d73586 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Auth/PlexPinCheckResult.cs @@ -0,0 +1,17 @@ +namespace Cleanuparr.Infrastructure.Features.Auth; + +/// +/// Result from checking the status of a Plex PIN authorization request. +/// +public sealed record PlexPinCheckResult +{ + /// + /// Whether the user has completed the PIN authentication flow. + /// + public required bool Completed { get; init; } + + /// + /// Authentication token if the PIN flow is completed. + /// + public string? AuthToken { get; init; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Auth/PlexPinResult.cs b/code/backend/Cleanuparr.Infrastructure/Features/Auth/PlexPinResult.cs new file mode 100644 index 00000000..e1697805 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Auth/PlexPinResult.cs @@ -0,0 +1,22 @@ +namespace Cleanuparr.Infrastructure.Features.Auth; + +/// +/// PIN-based authentication credentials from Plex. +/// +public sealed record PlexPinResult +{ + /// + /// Unique identifier for this PIN request. + /// + public required int PinId { get; init; } + + /// + /// Six-digit code displayed to the user for authentication. + /// + public required string PinCode { get; init; } + + /// + /// URL where the user authenticates with the PIN code. + /// + public required string AuthUrl { get; init; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Context/ContextProvider.cs b/code/backend/Cleanuparr.Infrastructure/Features/Context/ContextProvider.cs index e82b537c..6bff3487 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Context/ContextProvider.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Context/ContextProvider.cs @@ -44,6 +44,22 @@ public static class ContextProvider public static void SetJobRunId(Guid id) => Set(JobRunIdKey, id); + /// + /// Context key holding the run's dry-run flag. + /// + public const string DryRunKey = "DryRun"; + + /// + /// True when the current run started in dry run. + /// A later toggle of the setting leaves it unchanged. + /// + public static bool IsDryRunSticky() => Get(DryRunKey) as bool? ?? false; + + /// + /// Pins the run's dry-run flag. + /// + public static void SetDryRun(bool isDryRun) => Set(DryRunKey, isDryRun); + public static void SetDownloadClient(DownloadClientConfig config) { Set(Keys.DownloadClientUrl, config.ExternalOrInternalUrl); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DatabaseMigration/MigrationResult.cs b/code/backend/Cleanuparr.Infrastructure/Features/DatabaseMigration/MigrationResult.cs new file mode 100644 index 00000000..091f8a1f --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DatabaseMigration/MigrationResult.cs @@ -0,0 +1,15 @@ +namespace Cleanuparr.Infrastructure.Features.DatabaseMigration; + +/// +/// Result of a database migration operation. +/// +/// +/// Whether the migration completed. +/// +/// +/// Error message if the migration failed. +/// +/// +/// Row counts for each table after migration. +/// +public sealed record MigrationResult(bool Success, string? Error, IReadOnlyDictionary TableCounts); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DatabaseMigration/SqliteToPostgresMigrator.cs b/code/backend/Cleanuparr.Infrastructure/Features/DatabaseMigration/SqliteToPostgresMigrator.cs index d2eafe1e..f54044cd 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DatabaseMigration/SqliteToPostgresMigrator.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DatabaseMigration/SqliteToPostgresMigrator.cs @@ -12,8 +12,6 @@ using Npgsql; namespace Cleanuparr.Infrastructure.Features.DatabaseMigration; -public sealed record MigrationResult(bool Success, string? Error, IReadOnlyDictionary TableCounts); - public sealed class SqliteToPostgresMigrator { private readonly ModelDataCopier _copier = new(); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Consumers/DownloadRemoverConsumer.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Consumers/DownloadRemoverConsumer.cs index 4931e442..4b63267e 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Consumers/DownloadRemoverConsumer.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Consumers/DownloadRemoverConsumer.cs @@ -1,5 +1,7 @@ +using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces; using Cleanuparr.Infrastructure.Features.DownloadRemover.Models; +using Cleanuparr.Infrastructure.Features.DryRun; using MassTransit; using Microsoft.Extensions.Logging; @@ -9,21 +11,44 @@ public sealed class DownloadRemoverConsumer : IConsumer { private readonly ILogger _logger; private readonly IQueueItemRemover _queueItemRemover; + private readonly DryRunActivity _dryRunActivity; public DownloadRemoverConsumer( ILogger logger, - IQueueItemRemover queueItemRemover + IQueueItemRemover queueItemRemover, + DryRunActivity dryRunActivity ) { _logger = logger; _queueItemRemover = queueItemRemover; + _dryRunActivity = dryRunActivity; } public async Task Consume(ConsumeContext context) { + bool trackedDryRun = false; + try { - await _queueItemRemover.RemoveQueueItemAsync(context.Message); + ContextProvider.SetDryRun(context.Message.IsDryRun); + + if (context.Message.IsDryRun) + { + await _dryRunActivity.EnterAsync(); + trackedDryRun = true; + } + + try + { + await _queueItemRemover.RemoveQueueItemAsync(context.Message); + } + finally + { + if (trackedDryRun) + { + _dryRunActivity.Exit(); + } + } } catch (Exception exception) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Models/QueueItemRemoveRequest.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Models/QueueItemRemoveRequest.cs index d6837154..a5342e59 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Models/QueueItemRemoveRequest.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Models/QueueItemRemoveRequest.cs @@ -17,4 +17,9 @@ public sealed record QueueItemRemoveRequest public bool SkipSearch { get; init; } public DownloadClientConfig? DownloadClient { get; init; } + + /// + /// Whether the run that published this request started in dry run. + /// + public required bool IsDryRun { get; init; } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs index 84b564aa..1105a037 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs @@ -89,9 +89,9 @@ public sealed class QueueItemRemover : IQueueItemRemover { InstanceType instanceType = request.Instance.ArrConfig.Type; IArrClient arrClient = _arrClientFactory.GetClient(instanceType, request.Instance.Version); - await arrClient.DeleteQueueItemAsync(request.Instance, target.Record, target.RemoveFromClient, target.ChangeCategory, request.DeleteReason); + bool sent = await arrClient.DeleteQueueItemAsync(request.Instance, target.Record, target.RemoveFromClient, target.ChangeCategory, request.DeleteReason); - await MarkDownloadRemovedAsync(target.DownloadId); + await MarkDownloadRemovedAsync(target.DownloadId, sent); SetRemovalContext(request, target, instanceType); ContextProvider.Set(nameof(QueueRecord), target.Record); @@ -127,8 +127,8 @@ public sealed class QueueItemRemover : IQueueItemRemover bool snatchCleared = await TryClearSnatchAsync(request, target); - await _lazyLibrarianService.ResetItemAsync(request.Instance, item); - await MarkDownloadRemovedAsync(target.DownloadId); + bool sent = await _lazyLibrarianService.ResetItemAsync(request.Instance, item); + await MarkDownloadRemovedAsync(target.DownloadId, sent); _logger.LogInformation( "queue item reset in LazyLibrarian with reason {Reason} | {Url} | {Title}", @@ -210,8 +210,17 @@ public sealed class QueueItemRemover : IQueueItemRemover } } - private async Task MarkDownloadRemovedAsync(string downloadId) + /// + /// A skipped request leaves the download queued. + /// Marking it removed would turn its next strike into a false return. + /// + private async Task MarkDownloadRemovedAsync(string downloadId, bool sent) { + if (!sent) + { + return; + } + await _eventsContext.DownloadItems .Where(x => x.DownloadId == downloadId) .ExecuteUpdateAsync(setter => diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DryRun/DryRunActivity.cs b/code/backend/Cleanuparr.Infrastructure/Features/DryRun/DryRunActivity.cs new file mode 100644 index 00000000..22b9b53f --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DryRun/DryRunActivity.cs @@ -0,0 +1,90 @@ +using System.Threading.Channels; + +namespace Cleanuparr.Infrastructure.Features.DryRun; + +/// +/// Counts dry runs in flight. +/// +public sealed class DryRunActivity +{ + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly Channel _purgeRequests = Channel.CreateBounded( + new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite }); + private int _active; + + /// + /// Whether any dry run is in flight. + /// + public bool IsActive => Volatile.Read(ref _active) > 0; + + /// + /// Marks the start of a dry run. + /// Waits for any in-progress purge to finish first. + /// + public async Task EnterAsync() + { + await _gate.WaitAsync(); + + try + { + Interlocked.Increment(ref _active); + } + finally + { + _gate.Release(); + } + } + + /// + /// Marks the end of a dry run. + /// Requests a purge once no dry run is left active. + /// + public void Exit() + { + if (Interlocked.Decrement(ref _active) == 0) + { + RequestPurge(); + } + } + + /// + /// Requests a purge. + /// Buffers at most one pending request. + /// + public void RequestPurge() + { + _purgeRequests.Writer.TryWrite(true); + } + + /// + /// Waits for a pending purge request. + /// + public ValueTask WaitForPurgeRequestAsync(CancellationToken ct) + { + return _purgeRequests.Reader.ReadAsync(ct); + } + + /// + /// Runs the given action only if no dry run is in flight. + /// Blocks new dry runs while the action runs. + /// + public async Task RunIfIdleAsync(Func action) + { + await _gate.WaitAsync(); + + try + { + if (Volatile.Read(ref _active) > 0) + { + return false; + } + + await action(); + return true; + } + finally + { + _gate.Release(); + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DryRun/DryRunPurger.cs b/code/backend/Cleanuparr.Infrastructure/Features/DryRun/DryRunPurger.cs new file mode 100644 index 00000000..21ee5878 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DryRun/DryRunPurger.cs @@ -0,0 +1,75 @@ +using Cleanuparr.Infrastructure.Features.Arr.ForceImport; +using Cleanuparr.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Cleanuparr.Infrastructure.Features.DryRun; + +/// +public sealed class DryRunPurger : IDryRunPurger +{ + private readonly ILogger _logger; + private readonly EventsContext _eventsContext; + private readonly DryRunActivity _dryRunActivity; + + public DryRunPurger( + ILogger logger, + EventsContext eventsContext, + DryRunActivity dryRunActivity) + { + _logger = logger; + _eventsContext = eventsContext; + _dryRunActivity = dryRunActivity; + } + + /// + public async Task PurgeAsync() + { + bool ran = await _dryRunActivity.RunIfIdleAsync(async () => + { + await using var transaction = await _eventsContext.Database.BeginTransactionAsync(); + + try + { + int deletedStrikes = await _eventsContext.Strikes + .Where(s => s.IsDryRun) + .ExecuteDeleteAsync(); + int deletedEvents = await _eventsContext.Events + .Where(e => e.IsDryRun) + .ExecuteDeleteAsync(); + int deletedManualEvents = await _eventsContext.ManualEvents + .Where(e => e.IsDryRun) + .ExecuteDeleteAsync(); + int deletedItems = await _eventsContext.DownloadItems + .Where(d => !d.Strikes.Any()) + .ExecuteDeleteAsync(); + + int deletedHistory = await _eventsContext.SeekerHistory + .Where(h => h.IsDryRun) + .ExecuteDeleteAsync(); + + if (deletedStrikes + deletedEvents + deletedManualEvents + deletedItems + deletedHistory > 0) + { + _logger.LogWarning( + "Purged dry-run data: {Strikes} strikes, {Events} events, {ManualEvents} manual events, {Items} orphaned download items, {History} search history entries removed", + deletedStrikes, deletedEvents, deletedManualEvents, deletedItems, deletedHistory); + } + + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } + + ForceImportService.ForgetDryRun(); + }); + + if (!ran) + { + // Skipped purges carry over if dry run turns back on. + _logger.LogDebug("skip dry-run purge | dry runs still active"); + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DryRun/IDryRunPurger.cs b/code/backend/Cleanuparr.Infrastructure/Features/DryRun/IDryRunPurger.cs new file mode 100644 index 00000000..41b66129 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DryRun/IDryRunPurger.cs @@ -0,0 +1,13 @@ +namespace Cleanuparr.Infrastructure.Features.DryRun; + +/// +/// Deletes the data a dry run left behind. +/// +public interface IDryRunPurger +{ + /// + /// Purges dry-run data. + /// Skips while dry runs are active. + /// + Task PurgeAsync(); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/ItemStriker/Striker.cs b/code/backend/Cleanuparr.Infrastructure/Features/ItemStriker/Striker.cs index 3624a7c6..bd1589be 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/ItemStriker/Striker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/ItemStriker/Striker.cs @@ -37,11 +37,11 @@ public sealed class Striker : IStriker var downloadItem = await GetOrCreateDownloadItemAsync(hash, itemName); - int existingStrikeCount = await _context.Strikes - .CountAsync(s => s.DownloadItemId == downloadItem.Id && s.Type == strikeType); - bool isDryRun = await _dryRunInterceptor.IsDryRunEnabled(); + int existingStrikeCount = await _context.Strikes + .CountAsync(s => s.DownloadItemId == downloadItem.Id && s.Type == strikeType && s.IsDryRun == isDryRun); + var strike = new Strike { DownloadItemId = downloadItem.Id, @@ -54,8 +54,12 @@ public sealed class Striker : IStriker int strikeCount = existingStrikeCount + 1; + // Read before the IsRemoved reset below. + // Dry run counts only a real return as recurring. + bool hasReturned = downloadItem.IsRemoved || downloadItem.IsReturning; + // If item was previously removed and gets a new strike, it has returned - if (downloadItem.IsRemoved) + if (downloadItem.IsRemoved && !isDryRun) { downloadItem.IsReturning = true; downloadItem.IsRemoved = false; @@ -63,7 +67,7 @@ public sealed class Striker : IStriker } // Mark for removal when strike limit reached - if (strikeCount >= maxStrikes) + if (strikeCount >= maxStrikes && !isDryRun) { downloadItem.IsMarkedForRemoval = true; } @@ -79,7 +83,7 @@ public sealed class Striker : IStriker return false; } - if (strikeCount > maxStrikes) + if (strikeCount > maxStrikes && (!isDryRun || hasReturned)) { _logger.LogWarning("Blocked item keeps coming back | {name}", itemName); @@ -102,8 +106,10 @@ public sealed class Striker : IStriker return; } - var strikesToDelete = await _context.Strikes - .Where(s => s.DownloadItemId == downloadItem.Id && s.Type == strikeType) + bool isDryRun = await _dryRunInterceptor.IsDryRunEnabled(); + + List strikesToDelete = await _context.Strikes + .Where(s => s.DownloadItemId == downloadItem.Id && s.Type == strikeType && (!isDryRun || s.IsDryRun)) .ToListAsync(); if (strikesToDelete.Count is 0) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs index 20a69d11..23696a40 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs @@ -189,6 +189,7 @@ public abstract class GenericHandler : IHandler JobRunId = ContextProvider.GetJobRunId(), SkipSearch = skipSearch, DownloadClient = downloadClient, + IsDryRun = await _dryRunInterceptor.IsDryRunEnabled(), }; string downloadRemovalKey = CacheKeys.DownloadMarkedForRemoval(target.DownloadId, instance.Url); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs index 76ea7f74..a4946115 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs @@ -400,11 +400,33 @@ public sealed class Seeker : IHandler List queueRecords, CancellationToken cancellationToken) { - // Load search history for the current cycle - List currentCycleHistory = await _eventsContext.SeekerHistory + // Dry run tracks its own cycle through dry history rows. + Guid effectiveCycleId; + List currentCycleHistory; + + if (isDryRun) + { + SeekerHistory? latestDryCycleRow = await _eventsContext.SeekerHistory + .AsNoTracking() + .Where(h => h.ArrInstanceId == arrInstance.Id + && h.IsDryRun + && h.CycleId != instanceConfig.CurrentCycleId) + .OrderByDescending(h => h.LastSearchedAt) + .FirstOrDefaultAsync(cancellationToken); + + effectiveCycleId = latestDryCycleRow?.CycleId ?? instanceConfig.CurrentCycleId; + } + else + { + effectiveCycleId = instanceConfig.CurrentCycleId; + } + + currentCycleHistory = await _eventsContext.SeekerHistory .AsNoTracking() - .Where(h => h.ArrInstanceId == arrInstance.Id && h.CycleId == instanceConfig.CurrentCycleId) - .ToListAsync(); + .Where(h => h.ArrInstanceId == arrInstance.Id + && h.CycleId == effectiveCycleId + && (isDryRun || !h.IsDryRun)) + .ToListAsync(cancellationToken); // Load all history for stale cleanup List allHistoryExternalIds = await _eventsContext.SeekerHistory @@ -432,7 +454,7 @@ public sealed class Seeker : IHandler .Select(r => r.MovieId) .ToHashSet(); - result = await ProcessRadarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, isDryRun, queuedMovieIds, cancellationToken); + result = await ProcessRadarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, effectiveCycleId, isDryRun, queuedMovieIds, cancellationToken); } else { @@ -441,7 +463,7 @@ public sealed class Seeker : IHandler .Select(r => (r.SeriesId, r.SeasonNumber)) .ToHashSet(); - result = await ProcessSonarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, currentCycleHistory, isDryRun, cancellationToken, queuedSeasons: queuedSeasons); + result = await ProcessSonarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, currentCycleHistory, effectiveCycleId, isDryRun, cancellationToken, queuedSeasons: queuedSeasons); } if (result.Candidates.Count == 0) @@ -471,11 +493,11 @@ public sealed class Seeker : IHandler long commandId = await arrClient.SearchItemAsync(arrInstance, searchItem); Guid eventId = await _eventPublisher.PublishSearchTriggered( - candidate.Name, SeekerSearchType.Proactive, candidate.Reason, instanceConfig.CurrentCycleId, isDryRun); + candidate.Name, SeekerSearchType.Proactive, candidate.Reason, result.CycleId, isDryRun); _logger.LogInformation("Search triggered for {Item} ({Reason}) | {InstanceUrl}", candidate.Name, candidate.Reason, arrInstance.Url); - await UpdateSearchHistoryAsync(arrInstance.Id, instanceType, instanceConfig.CurrentCycleId, + await UpdateSearchHistoryAsync(arrInstance.Id, instanceType, result.CycleId, [candidate.ItemId], [candidate.Name], candidate.SeasonNumber, isDryRun); if (!isDryRun) @@ -499,6 +521,7 @@ public sealed class Seeker : IHandler ArrInstance arrInstance, SeekerInstanceConfig instanceConfig, Dictionary searchHistory, + Guid cycleId, bool isDryRun, HashSet queuedMovieIds, CancellationToken cancellationToken) @@ -563,7 +586,7 @@ public sealed class Seeker : IHandler if (candidates.Count == 0) { - return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds }; + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, CycleId = cycleId }; } // Exclude movies already in the download queue @@ -583,7 +606,7 @@ public sealed class Seeker : IHandler if (candidates.Count == 0) { - return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds }; + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, CycleId = cycleId }; } } @@ -598,16 +621,22 @@ public sealed class Seeker : IHandler _logger.LogDebug( "skip | cycle complete but min time ({Days}) not elapsed (started {StartedAt}) | {InstanceName}", instanceConfig.MinCycleTimeDays, cycleStartedAt, arrInstance.Name); - return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds }; + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, CycleId = cycleId }; } _logger.LogInformation("All {Count} items on {InstanceName} searched in current cycle, starting new cycle", candidates.Count, arrInstance.Name); - if (!isDryRun) + if (isDryRun) + { + // Live cycle id stays untouched. + cycleId = Guid.NewGuid(); + } + else { instanceConfig.CurrentCycleId = Guid.NewGuid(); await _dataContext.SaveChangesAsync(); + cycleId = instanceConfig.CurrentCycleId; } searchHistory = new Dictionary(); @@ -649,7 +678,7 @@ public sealed class Seeker : IHandler movie.Title, arrInstance.Name, reason); } - return new SeekerProcessResult { Candidates = searchCandidates, AllLibraryIds = allLibraryIds }; + return new SeekerProcessResult { Candidates = searchCandidates, AllLibraryIds = allLibraryIds, CycleId = cycleId }; } private async Task ProcessSonarrAsync( @@ -658,6 +687,7 @@ public sealed class Seeker : IHandler SeekerInstanceConfig instanceConfig, Dictionary seriesSearchHistory, List currentCycleHistory, + Guid cycleId, bool isDryRun, CancellationToken cancellationToken, bool isRetry = false, @@ -709,7 +739,7 @@ public sealed class Seeker : IHandler if (candidates.Count == 0) { - return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds }; + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, CycleId = cycleId }; } // Pass all candidates — BuildSonarrSearchItemAsync handles season-level exclusion @@ -762,6 +792,7 @@ public sealed class Seeker : IHandler } ], AllLibraryIds = allLibraryIds, + CycleId = cycleId, }; } @@ -787,23 +818,31 @@ public sealed class Seeker : IHandler _logger.LogDebug( "skip | cycle complete but min time ({Days}) not elapsed (started {StartedAt}) | {InstanceName}", instanceConfig.MinCycleTimeDays, cycleStartedAt, arrInstance.Name); - return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds }; + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, CycleId = cycleId }; } _logger.LogInformation("All {Count} series on {InstanceName} searched in current cycle, starting new cycle", candidates.Count, arrInstance.Name); - if (!isDryRun) + + Guid newCycleId; + if (isDryRun) + { + // Live cycle id stays untouched. + newCycleId = Guid.NewGuid(); + } + else { instanceConfig.CurrentCycleId = Guid.NewGuid(); await _dataContext.SaveChangesAsync(); + newCycleId = instanceConfig.CurrentCycleId; } // Retry with fresh cycle (only once to prevent infinite recursion) return await ProcessSonarrAsync(config, arrInstance, instanceConfig, - new Dictionary(), [], isDryRun, cancellationToken, isRetry: true, queuedSeasons: queuedSeasons); + new Dictionary(), [], newCycleId, isDryRun, cancellationToken, isRetry: true, queuedSeasons: queuedSeasons); } - return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds }; + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, CycleId = cycleId }; } /// @@ -973,6 +1012,11 @@ public sealed class Seeker : IHandler if (existing is not null) { + if (SkipOrResetDryRunRow(existing, isDryRun, title)) + { + continue; + } + existing.LastSearchedAt = now; existing.SearchCount++; if (!string.IsNullOrEmpty(title)) @@ -999,6 +1043,23 @@ public sealed class Seeker : IHandler await _eventsContext.SaveChangesAsync(); } + private bool SkipOrResetDryRunRow(SeekerHistory existing, bool isDryRun, string title) + { + if (isDryRun && !existing.IsDryRun) + { + _logger.LogDebug("[DRY RUN] skipping search history update for live row | {Title}", title); + return true; + } + + if (!isDryRun && existing.IsDryRun) + { + existing.IsDryRun = false; + existing.SearchCount = 0; + } + + return false; + } + private async Task SaveCommandTrackerAsync( long commandId, Guid eventId, diff --git a/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/ILazyLibrarianService.cs b/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/ILazyLibrarianService.cs index ceaff87a..bb04cd3f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/ILazyLibrarianService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/ILazyLibrarianService.cs @@ -27,7 +27,8 @@ public interface ILazyLibrarianService /// Sets the book back to wanted. /// The audio status is separate from the ebook status. /// - Task ResetItemAsync(ArrInstance instance, LazyLibrarianQueueItem item); + /// True when every reset request was sent. + Task ResetItemAsync(ArrInstance instance, LazyLibrarianQueueItem item); Task TriggerSearchAsync(ArrInstance instance, LazyLibrarianQueueItem item); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianService.cs b/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianService.cs index 5e63fc7d..35ead8ee 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/LazyLibrarian/LazyLibrarianService.cs @@ -93,16 +93,21 @@ public sealed class LazyLibrarianService : ILazyLibrarianService return progress?.Data; } - public async Task ResetItemAsync(ArrInstance instance, LazyLibrarianQueueItem item) + public async Task ResetItemAsync(ArrInstance instance, LazyLibrarianQueueItem item) { + bool allSent = item.Books.Count > 0; + foreach (LazyLibrarianBookRef book in item.Books) { Uri uri = book.IsAudioBook ? BuildApiUri(instance, "queueBook", ("id", book.BookId), ("type", AudioBookLibrary)) : BuildApiUri(instance, "queueBook", ("id", book.BookId)); - await SendCommandAsync(instance, uri, "queue item reset", item.Title); + bool sent = await SendCommandAsync(instance, uri, "queue item reset", item.Title); + allSent &= sent; } + + return allSent; } public async Task TriggerSearchAsync(ArrInstance instance, LazyLibrarianQueueItem item) @@ -176,7 +181,8 @@ public sealed class LazyLibrarianService : ILazyLibrarianService return rows; } - private async Task SendCommandAsync(ArrInstance instance, Uri uri, string context, string title) + /// True when the request was sent, false when dry run skipped it. + private async Task SendCommandAsync(ArrInstance instance, Uri uri, string context, string title) { try { @@ -186,7 +192,7 @@ public sealed class LazyLibrarianService : ILazyLibrarianService if (response is null) { - return; + return false; } try @@ -197,6 +203,8 @@ public sealed class LazyLibrarianService : ILazyLibrarianService { response.Dispose(); } + + return true; } catch (Exception exception) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/ApprisePayload.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/ApprisePayload.cs index 4e66d43c..845ca0f6 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/ApprisePayload.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/ApprisePayload.cs @@ -6,7 +6,7 @@ public sealed record ApprisePayload { [Required] public string Title { get; init; } - + [Required] public string Body { get; init; } @@ -17,19 +17,4 @@ public sealed record ApprisePayload public string? Tags { get; init; } public string? ImageUrl { get; init; } -} - -public enum NotificationType -{ - Info, - Success, - Warning, - Failure -} - -public enum FormatType -{ - Text, - Markdown, - Html } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/FormatType.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/FormatType.cs new file mode 100644 index 00000000..56c352ab --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/FormatType.cs @@ -0,0 +1,22 @@ +namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise; + +/// +/// Content format for Apprise notifications. +/// +public enum FormatType +{ + /// + /// Plain text format. + /// + Text, + + /// + /// Markdown format. + /// + Markdown, + + /// + /// HTML format. + /// + Html +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/NotificationType.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/NotificationType.cs new file mode 100644 index 00000000..cd16415c --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Apprise/NotificationType.cs @@ -0,0 +1,27 @@ +namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise; + +/// +/// Notification severity level for Apprise messages. +/// +public enum NotificationType +{ + /// + /// Informational message. + /// + Info, + + /// + /// Success notification. + /// + Success, + + /// + /// Warning notification. + /// + Warning, + + /// + /// Failure or error notification. + /// + Failure +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordEmbed.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordEmbed.cs new file mode 100644 index 00000000..0f3cb0f8 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordEmbed.cs @@ -0,0 +1,49 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Infrastructure.Features.Notifications.Discord; + +/// +/// Embed sent in a Discord webhook message. +/// +public class DiscordEmbed +{ + /// + /// Title of the embed. + /// + public string Title { get; set; } = string.Empty; + + /// + /// Main content of the embed. + /// + public string Description { get; set; } = string.Empty; + + /// + /// Hex color of the embed sidebar. + /// + public int Color { get; set; } + + /// + /// Small thumbnail image for the embed. + /// + public DiscordThumbnail? Thumbnail { get; set; } + + /// + /// Large image for the embed. + /// + public DiscordImage? Image { get; set; } + + /// + /// Fields displayed in the embed body. + /// + public List Fields { get; set; } = new(); + + /// + /// Footer section of the embed. + /// + public DiscordFooter? Footer { get; set; } + + /// + /// ISO 8601 timestamp displayed in the embed footer. + /// + public string? Timestamp { get; set; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordField.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordField.cs new file mode 100644 index 00000000..c56918c4 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordField.cs @@ -0,0 +1,22 @@ +namespace Cleanuparr.Infrastructure.Features.Notifications.Discord; + +/// +/// Field displayed in a Discord embed. +/// +public class DiscordField +{ + /// + /// Field name or label. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Field content or value. + /// + public string Value { get; set; } = string.Empty; + + /// + /// Whether the field displays inline with other fields. + /// + public bool Inline { get; set; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordFooter.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordFooter.cs new file mode 100644 index 00000000..e86da00b --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordFooter.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Infrastructure.Features.Notifications.Discord; + +/// +/// Footer section of a Discord embed. +/// +public class DiscordFooter +{ + /// + /// Footer text displayed below the embed. + /// + public string Text { get; set; } = string.Empty; + + /// + /// URL of a small icon shown in the footer. + /// + [JsonPropertyName("icon_url")] + public string? IconUrl { get; set; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordImage.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordImage.cs new file mode 100644 index 00000000..46a7a6da --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordImage.cs @@ -0,0 +1,12 @@ +namespace Cleanuparr.Infrastructure.Features.Notifications.Discord; + +/// +/// Large image displayed in a Discord embed. +/// +public class DiscordImage +{ + /// + /// URL of the image to display. + /// + public string Url { get; set; } = string.Empty; +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordPayload.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordPayload.cs index f432ecfb..bbc8ae5b 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordPayload.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordPayload.cs @@ -11,49 +11,3 @@ public class DiscordPayload public List Embeds { get; set; } = new(); } - -public class DiscordEmbed -{ - public string Title { get; set; } = string.Empty; - - public string Description { get; set; } = string.Empty; - - public int Color { get; set; } - - public DiscordThumbnail? Thumbnail { get; set; } - - public DiscordImage? Image { get; set; } - - public List Fields { get; set; } = new(); - - public DiscordFooter? Footer { get; set; } - - public string? Timestamp { get; set; } -} - -public class DiscordField -{ - public string Name { get; set; } = string.Empty; - - public string Value { get; set; } = string.Empty; - - public bool Inline { get; set; } -} - -public class DiscordThumbnail -{ - public string Url { get; set; } = string.Empty; -} - -public class DiscordImage -{ - public string Url { get; set; } = string.Empty; -} - -public class DiscordFooter -{ - public string Text { get; set; } = string.Empty; - - [JsonPropertyName("icon_url")] - public string? IconUrl { get; set; } -} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordThumbnail.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordThumbnail.cs new file mode 100644 index 00000000..b29ad468 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Discord/DiscordThumbnail.cs @@ -0,0 +1,12 @@ +namespace Cleanuparr.Infrastructure.Features.Notifications.Discord; + +/// +/// Thumbnail image displayed in a Discord embed. +/// +public class DiscordThumbnail +{ + /// + /// URL of the thumbnail image. + /// + public string Url { get; set; } = string.Empty; +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Gotify/GotifyClientDisplay.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Gotify/GotifyClientDisplay.cs new file mode 100644 index 00000000..b0d30718 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Gotify/GotifyClientDisplay.cs @@ -0,0 +1,12 @@ +namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify; + +/// +/// Display configuration for Gotify client rendering. +/// +public class GotifyClientDisplay +{ + /// + /// MIME type for client rendering of the notification. + /// + public string? ContentType { get; set; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Gotify/GotifyExtras.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Gotify/GotifyExtras.cs new file mode 100644 index 00000000..86ebbd97 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Gotify/GotifyExtras.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify; + +/// +/// Extra metadata fields for a Gotify notification. +/// +public class GotifyExtras +{ + /// + /// Client display configuration for rendering the notification. + /// + [JsonPropertyName("client::display")] + public GotifyClientDisplay? ClientDisplay { get; set; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Gotify/GotifyPayload.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Gotify/GotifyPayload.cs index 7768f2ff..f7b61769 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Gotify/GotifyPayload.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Gotify/GotifyPayload.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify; public class GotifyPayload @@ -12,14 +10,3 @@ public class GotifyPayload public GotifyExtras? Extras { get; set; } } - -public class GotifyExtras -{ - [JsonPropertyName("client::display")] - public GotifyClientDisplay? ClientDisplay { get; set; } -} - -public class GotifyClientDisplay -{ - public string? ContentType { get; set; } -} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/Field.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/Field.cs new file mode 100644 index 00000000..91cf4eac --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/Field.cs @@ -0,0 +1,22 @@ +namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr; + +/// +/// Field in a Notifiarr notification message. +/// +public class Field +{ + /// + /// Field name or label. + /// + public string Title { get; set; } = string.Empty; + + /// + /// Field content or value. + /// + public string Text { get; set; } = string.Empty; + + /// + /// Whether the field displays inline with other fields. + /// + public bool Inline { get; set; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/Ids.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/Ids.cs new file mode 100644 index 00000000..eff4e245 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/Ids.cs @@ -0,0 +1,12 @@ +namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr; + +/// +/// IDs for routing a Notifiarr notification. +/// +public class Ids +{ + /// + /// Discord channel ID to receive the notification. + /// + public string Channel { get; set; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/Images.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/Images.cs new file mode 100644 index 00000000..962cbc4e --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/Images.cs @@ -0,0 +1,17 @@ +namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr; + +/// +/// Images for a Notifiarr notification. +/// +public class Images +{ + /// + /// Small thumbnail image URL. + /// + public Uri? Thumbnail { get; set; } + + /// + /// Large image URL. + /// + public Uri? Image { get; set; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/NotifiarrDiscord.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/NotifiarrDiscord.cs new file mode 100644 index 00000000..af688054 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/NotifiarrDiscord.cs @@ -0,0 +1,32 @@ +namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr; + +/// +/// Discord-specific fields for a Notifiarr notification. +/// +public class NotifiarrDiscord +{ + /// + /// Hex color code for the embed sidebar. + /// + public string Color { get; set; } = string.Empty; + + /// + /// User and role mentions to ping in the notification. + /// + public Ping Ping { get; set; } + + /// + /// Thumbnail and image URLs for the notification. + /// + public Images Images { get; set; } + + /// + /// Text content and formatting for the notification. + /// + public Text Text { get; set; } + + /// + /// IDs for routing the notification to Discord. + /// + public Ids Ids { get; set; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/NotifiarrNotification.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/NotifiarrNotification.cs new file mode 100644 index 00000000..b76b4176 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/NotifiarrNotification.cs @@ -0,0 +1,22 @@ +namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr; + +/// +/// Root notification object sent to Notifiarr. +/// +public class NotifiarrNotification +{ + /// + /// Whether to update an existing notification instead of creating new. + /// + public bool Update { get; set; } + + /// + /// Application name reported to Notifiarr. + /// + public string Name => "Cleanuparr"; + + /// + /// Event type identifier for classification by Notifiarr. + /// + public int? Event { get; set; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/NotifiarrPayload.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/NotifiarrPayload.cs index 39af0dfc..e41f6c6a 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/NotifiarrPayload.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/NotifiarrPayload.cs @@ -1,57 +1,7 @@ -namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr; +namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr; public class NotifiarrPayload { public NotifiarrNotification Notification { get; set; } = new NotifiarrNotification(); public NotifiarrDiscord Discord { get; set; } } - -public class NotifiarrNotification -{ - public bool Update { get; set; } - public string Name => "Cleanuparr"; - public int? Event { get; set; } -} - -public class NotifiarrDiscord -{ - public string Color { get; set; } = string.Empty; - public Ping Ping { get; set; } - public Images Images { get; set; } - public Text Text { get; set; } - public Ids Ids { get; set; } -} - -public class Ping -{ - public string PingUser { get; set; } - public string PingRole { get; set; } -} - -public class Images -{ - public Uri? Thumbnail { get; set; } - public Uri? Image { get; set; } -} - -public class Text -{ - public string Title { get; set; } = string.Empty; - public string Icon { get; set; } = string.Empty; - public string Content { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public List Fields { get; set; } = new List(); - public string Footer { get; set; } = string.Empty; -} - -public class Field -{ - public string Title { get; set; } = string.Empty; - public string Text { get; set; } = string.Empty; - public bool Inline { get; set; } -} - -public class Ids -{ - public string Channel { get; set; } -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/Ping.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/Ping.cs new file mode 100644 index 00000000..485a0b05 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/Ping.cs @@ -0,0 +1,17 @@ +namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr; + +/// +/// Discord user and role mentions for Notifiarr notifications. +/// +public class Ping +{ + /// + /// Discord user ID to mention in the notification. + /// + public string PingUser { get; set; } + + /// + /// Discord role ID to mention in the notification. + /// + public string PingRole { get; set; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/Text.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/Text.cs new file mode 100644 index 00000000..dfc33997 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Notifiarr/Text.cs @@ -0,0 +1,37 @@ +namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr; + +/// +/// Text content and formatting for a Notifiarr notification. +/// +public class Text +{ + /// + /// Notification title. + /// + public string Title { get; set; } = string.Empty; + + /// + /// Icon name or emoji code for the notification. + /// + public string Icon { get; set; } = string.Empty; + + /// + /// Main message content. + /// + public string Content { get; set; } = string.Empty; + + /// + /// Secondary description text. + /// + public string Description { get; set; } = string.Empty; + + /// + /// Additional fields displayed in the notification. + /// + public List Fields { get; set; } = new List(); + + /// + /// Footer text shown at the bottom. + /// + public string Footer { get; set; } = string.Empty; +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerProcessResult.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerProcessResult.cs index ee19366c..4f0ae268 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerProcessResult.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerProcessResult.cs @@ -7,4 +7,10 @@ internal sealed record SeekerProcessResult { public required List Candidates { get; init; } public required List AllLibraryIds { get; init; } + + /// + /// Cycle the candidates belong to. + /// A dry run gets a simulated cycle that never reaches the config. + /// + public required Guid CycleId { get; init; } } diff --git a/code/backend/Cleanuparr.Infrastructure/Helpers/CacheKeys.cs b/code/backend/Cleanuparr.Infrastructure/Helpers/CacheKeys.cs index 521fa1e8..4d0c7294 100644 --- a/code/backend/Cleanuparr.Infrastructure/Helpers/CacheKeys.cs +++ b/code/backend/Cleanuparr.Infrastructure/Helpers/CacheKeys.cs @@ -14,12 +14,12 @@ public static class CacheKeys public static string ForceImportPending(Uri url) => $"force_import_pending_{url}"; - public static string ForceImportGaveUp(string hash, Uri url) => $"force_import_gave_up_{hash.ToLowerInvariant()}_{url}"; + public static string ForceImportGaveUp(string hash, Uri url, bool isDryRun = false) => $"{(isDryRun ? "dry_run_" : "")}force_import_gave_up_{hash.ToLowerInvariant()}_{url}"; public static string ForceImportFirstSeen(string hash, Uri url) => $"force_import_seen_{hash.ToLowerInvariant()}_{url}"; - public static string ForceImportTries(string hash, Uri url) => $"force_import_tries_{hash.ToLowerInvariant()}_{url}"; - + public static string ForceImportTries(string hash, Uri url, bool isDryRun = false) => $"{(isDryRun ? "dry_run_" : "")}force_import_tries_{hash.ToLowerInvariant()}_{url}"; + public static class UTorrent { public static string GetAuthTokenKey(string clientId) => $"utorrent:auth:token:{clientId}"; diff --git a/code/backend/Cleanuparr.Infrastructure/Interceptors/DryRunInterceptor.cs b/code/backend/Cleanuparr.Infrastructure/Interceptors/DryRunInterceptor.cs index c2fddb90..90569736 100644 --- a/code/backend/Cleanuparr.Infrastructure/Interceptors/DryRunInterceptor.cs +++ b/code/backend/Cleanuparr.Infrastructure/Interceptors/DryRunInterceptor.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Text.RegularExpressions; +using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.General; using Microsoft.EntityFrameworkCore; @@ -59,6 +60,11 @@ public partial class DryRunInterceptor : IDryRunInterceptor public async Task IsDryRunEnabled() { + if (ContextProvider.IsDryRunSticky()) + { + return true; + } + GeneralConfig config = await _dataContext.GeneralConfigs .AsNoTracking() .FirstAsync(); @@ -68,11 +74,7 @@ public partial class DryRunInterceptor : IDryRunInterceptor private bool IsDryRun(string? expression) { - GeneralConfig config = _dataContext.GeneralConfigs - .AsNoTracking() - .First(); - - if (!config.DryRun) + if (!ContextProvider.IsDryRunSticky() && !_dataContext.GeneralConfigs.AsNoTracking().First().DryRun) { return false; } diff --git a/code/backend/Cleanuparr.Infrastructure/Models/JobSchedule.cs b/code/backend/Cleanuparr.Infrastructure/Models/JobSchedule.cs index 9d76f6ab..b5b878aa 100644 --- a/code/backend/Cleanuparr.Infrastructure/Models/JobSchedule.cs +++ b/code/backend/Cleanuparr.Infrastructure/Models/JobSchedule.cs @@ -3,16 +3,6 @@ using Cleanuparr.Infrastructure.Utilities; namespace Cleanuparr.Infrastructure.Models; -/// -/// Represents the unit of time for job scheduling intervals -/// -public enum ScheduleUnit -{ - Seconds, - Minutes, - Hours -} - /// /// Represents a user-friendly job schedule format /// @@ -36,7 +26,7 @@ public class JobSchedule { return CronExpressionConverter.ConvertToCronExpression(this); } - + /// /// Validates the job schedule against the predefined valid values /// diff --git a/code/backend/Cleanuparr.Infrastructure/Models/ScheduleUnit.cs b/code/backend/Cleanuparr.Infrastructure/Models/ScheduleUnit.cs new file mode 100644 index 00000000..e5b91d73 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Models/ScheduleUnit.cs @@ -0,0 +1,22 @@ +namespace Cleanuparr.Infrastructure.Models; + +/// +/// Unit of time for job scheduling intervals. +/// +public enum ScheduleUnit +{ + /// + /// Schedule interval measured in seconds. + /// + Seconds, + + /// + /// Schedule interval measured in minutes. + /// + Minutes, + + /// + /// Schedule interval measured in hours. + /// + Hours +} diff --git a/code/backend/Cleanuparr.Infrastructure/Services/QueueRuleEvaluator.cs b/code/backend/Cleanuparr.Infrastructure/Services/QueueRuleEvaluator.cs index 96d3341a..33e6211b 100644 --- a/code/backend/Cleanuparr.Infrastructure/Services/QueueRuleEvaluator.cs +++ b/code/backend/Cleanuparr.Infrastructure/Services/QueueRuleEvaluator.cs @@ -2,6 +2,7 @@ using Cleanuparr.Domain.Entities; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.ItemStriker; +using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Infrastructure.Services.Interfaces; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; @@ -16,17 +17,20 @@ public class QueueRuleEvaluator : IQueueRuleEvaluator private readonly IStriker _striker; private readonly EventsContext _context; private readonly ILogger _logger; + private readonly IDryRunInterceptor _dryRunInterceptor; public QueueRuleEvaluator( IQueueRuleManager queueRuleManager, IStriker striker, EventsContext context, - ILogger logger) + ILogger logger, + IDryRunInterceptor dryRunInterceptor) { _queueRuleManager = queueRuleManager; _striker = striker; _context = context; _logger = logger; + _dryRunInterceptor = dryRunInterceptor; } public async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> EvaluateStallRulesAsync(ITorrentItemWrapper torrent) @@ -221,9 +225,11 @@ public class QueueRuleEvaluator : IQueueRuleEvaluator return (false, 0, currentDownloaded); } + bool isDryRun = await _dryRunInterceptor.IsDryRunEnabled(); + // Get the most recent strike for this download item (Stalled type) to check progress var mostRecentStrike = await _context.Strikes - .Where(s => s.DownloadItemId == downloadItem.Id && s.Type == StrikeType.Stalled) + .Where(s => s.DownloadItemId == downloadItem.Id && s.Type == StrikeType.Stalled && s.IsDryRun == isDryRun) .OrderByDescending(s => s.CreatedAt) .FirstOrDefaultAsync(); diff --git a/code/backend/Cleanuparr.Shared/Attributes/SensitiveDataAttribute.cs b/code/backend/Cleanuparr.Shared/Attributes/SensitiveDataAttribute.cs index 7c3fe6e9..51fe9980 100644 --- a/code/backend/Cleanuparr.Shared/Attributes/SensitiveDataAttribute.cs +++ b/code/backend/Cleanuparr.Shared/Attributes/SensitiveDataAttribute.cs @@ -1,23 +1,5 @@ namespace Cleanuparr.Shared.Attributes; -/// -/// Defines how sensitive data should be masked in API responses. -/// -public enum SensitiveDataType -{ - /// - /// Full mask: replaces the entire value with bullets (••••••••). - /// Use for passwords, API keys, tokens, webhook URLs. - /// - Full, - - /// - /// Apprise URL mask: shows only the scheme of each service URL (discord://••••••••). - /// Use for Apprise service URL strings that contain multiple notification service URLs. - /// - AppriseUrl, -} - /// /// Marks a property as containing sensitive data that should be masked in API responses /// and preserved when the placeholder value is sent back in updates. diff --git a/code/backend/Cleanuparr.Shared/Attributes/SensitiveDataType.cs b/code/backend/Cleanuparr.Shared/Attributes/SensitiveDataType.cs new file mode 100644 index 00000000..d9a28557 --- /dev/null +++ b/code/backend/Cleanuparr.Shared/Attributes/SensitiveDataType.cs @@ -0,0 +1,19 @@ +namespace Cleanuparr.Shared.Attributes; + +/// +/// Defines how sensitive data should be masked in API responses. +/// +public enum SensitiveDataType +{ + /// + /// Full mask: replaces the entire value with bullets (••••••••). + /// Use for passwords, API keys, tokens, webhook URLs. + /// + Full, + + /// + /// Apprise URL mask: shows only the scheme of each service URL (discord://••••••••). + /// Use for Apprise service URL strings that contain multiple notification service URLs. + /// + AppriseUrl, +} diff --git a/e2e/tests/live-arr-fast/dry-run-purge.api.spec.ts b/e2e/tests/live-arr-fast/dry-run-purge.api.spec.ts new file mode 100644 index 00000000..635e5509 --- /dev/null +++ b/e2e/tests/live-arr-fast/dry-run-purge.api.spec.ts @@ -0,0 +1,168 @@ +import { test, expect } from '../fixtures/base'; +import type { CleanuparrApi } from '../helpers/api'; +import { indexerMock } from '../helpers/live-arr'; +import { + RADARR, + arrangeInstance, + arrangeQueueCleaner, + createStallRule, + resetLiveArrState, + restoreLibrary, + snapshotLibrary, + teardownInstances, + teardownQueueCleaner, + triggerSeeker, + TRIGGERED_SEARCH_TIMEOUT, +} from '../helpers/seeker-live'; +import { grabbableRelease } from '../helpers/mocks/torznab-stubs'; + +/** + * Disabling dry run queues a purge that deletes every dry-run strike and event soon after. + * + * A real stall strike needs a real grabbed download, so these specs drive the live arr + * the same way `seeker-filters.api.spec.ts` does: search, grab, then strike the stall. + */ + +/** A strike shows up within a couple of cycles, so this is generous. */ +const STRIKE_TIMEOUT = 45_000; + +/** The purge runs within milliseconds to a few seconds of the config save. */ +const PURGE_TIMEOUT = 20_000; + +/** The stall rule floor is above two, so two strikes never remove the download. */ +const MAX_STRIKES = 3; + +interface StrikeGroup { + downloadId: string; + totalStrikes: number; + hasDryRunStrikes: boolean; +} + +/** The strikes API lowercases downloadId, but the arr queue reports it uppercase. */ +async function strikeGroup(api: CleanuparrApi, downloadId: string): Promise { + const body = await (await api.strikes.list({ pageSize: 200 })).json(); + return (body.items ?? []).find( + (item: StrikeGroup) => item.downloadId.toLowerCase() === downloadId.toLowerCase(), + ); +} + +/** Searches the seeded movie and waits for the grab to reach the arr's queue. */ +async function grabRelease(api: CleanuparrApi): Promise { + await triggerSeeker(api); + await expect + .poll( + async () => { + await RADARR.arr.refreshMonitoredDownloads(); + return (await RADARR.arr.queue()).filter((record) => record.sizeleft > 0).length; + }, + { timeout: TRIGGERED_SEARCH_TIMEOUT }, + ) + .toBeGreaterThan(0); +} + +/** The downloadId of the single stalled item the grab left in the queue. */ +async function stalledDownloadId(): Promise { + const stalled = (await RADARR.arr.queue()).find((record) => record.sizeleft > 0); + expect(stalled, 'expected a stalled download in the queue').toBeTruthy(); + return stalled!.downloadId; +} + +/** Triggers the queue cleaner until the download's strike count reaches the target. */ +async function strikeUntil(api: CleanuparrApi, downloadId: string, total: number): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + await api.jobs.trigger('QueueCleaner'); + + const reached = await expect + .poll(async () => (await strikeGroup(api, downloadId))?.totalStrikes ?? 0, { + timeout: STRIKE_TIMEOUT, + intervals: [2_000], + }) + .toBeGreaterThanOrEqual(total) + .then( + () => true, + () => false, + ); + + if (reached) { + return; + } + } + + throw new Error(`the queue cleaner never struck download ${downloadId} up to ${total}`); +} + +test.describe('Dry run purge', () => { + let library: Array> = []; + + test.beforeEach(async () => { + await resetLiveArrState(); + library = await snapshotLibrary(RADARR); + await indexerMock.stubMany(grabbableRelease(RADARR.searchMode, RADARR.release, RADARR.category).mappings); + }); + + test.afterEach(async ({ api }) => { + await api.general.patch({ dryRun: false }); + await teardownQueueCleaner(api); + await teardownInstances(api); + await restoreLibrary(RADARR, library); + await resetLiveArrState(); + }); + + test('disabling dry run purges dry strikes and events', async ({ api }) => { + test.setTimeout(240_000); + + await arrangeInstance(api, RADARR); + await arrangeQueueCleaner(api); + await createStallRule(api, MAX_STRIKES); + + await grabRelease(api); + const downloadId = await stalledDownloadId(); + + await api.general.patch({ dryRun: true }); + await strikeUntil(api, downloadId, 1); + + await api.general.patch({ dryRun: false }); + + await expect + .poll(async () => (await strikeGroup(api, downloadId)) === undefined, { timeout: PURGE_TIMEOUT }) + .toBe(true); + await expect + .poll( + async () => { + const events = await (await api.events.list({ pageSize: 200 })).json(); + return (events.items ?? []).some((event: { isDryRun: boolean }) => event.isDryRun); + }, + { timeout: PURGE_TIMEOUT }, + ) + .toBe(false); + }); + + test('live strikes survive the purge', async ({ api }) => { + test.setTimeout(240_000); + + await arrangeInstance(api, RADARR); + await arrangeQueueCleaner(api); + await createStallRule(api, MAX_STRIKES); + + await grabRelease(api); + const downloadId = await stalledDownloadId(); + + // Live strike first, so the purge has one to keep. + await strikeUntil(api, downloadId, 1); + + await api.general.patch({ dryRun: true }); + await strikeUntil(api, downloadId, 2); + + await api.general.patch({ dryRun: false }); + + await expect + .poll( + async () => { + const group = await strikeGroup(api, downloadId); + return group ? { total: group.totalStrikes, dry: group.hasDryRunStrikes } : undefined; + }, + { timeout: PURGE_TIMEOUT }, + ) + .toEqual({ total: 1, dry: false }); + }); +}); diff --git a/e2e/tests/seeker-fast/search-command-flow.api.spec.ts b/e2e/tests/seeker-fast/search-command-flow.api.spec.ts index 81fca51e..977c518d 100644 --- a/e2e/tests/seeker-fast/search-command-flow.api.spec.ts +++ b/e2e/tests/seeker-fast/search-command-flow.api.spec.ts @@ -221,6 +221,13 @@ test.describe('Seeker: search command status flow', () => { const event = await findSearchEvent(api, instanceId, title); expect(event?.searchStatus ?? null).toBeNull(); + + // Turning dry run off purges the dry search event. + await api.general.updateConfig({ ...general, dryRun: false }); + + await expect + .poll(async () => findSearchEvent(api, instanceId, title), { timeout: TRANSITION_TIMEOUT }) + .toBe(undefined); } finally { await api.general.updateConfig(general); }