Fix dry run leaking into live runs in some cases (#822)

This commit is contained in:
Flaminel authored and GitHub committed 2026-10-06 17:45:12 +03:00
1 parent 58b476c360
commit 7bc1b45e1d
91 files changed
+3796 -446

No files matched your search

@@ -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<IDynamicHttpClientFactory>();
_dryRunActivity = new DryRunActivity();
var logger = Substitute.For<ILogger<GeneralConfigController>>();
_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<OkObjectResult>();
@@ -108,7 +115,7 @@ public class GeneralConfigControllerTests : IDisposable
};
// Act / Assert
await Should.ThrowAsync<Exception>(() => _controller.UpdateGeneralConfig(request, _eventsContext));
await Should.ThrowAsync<Exception>(() => _controller.UpdateGeneralConfig(request));
}
[Fact]
@@ -125,7 +132,7 @@ public class GeneralConfigControllerTests : IDisposable
};
// Act / Assert
await Should.ThrowAsync<Exception>(() => _controller.UpdateGeneralConfig(request, _eventsContext));
await Should.ThrowAsync<Exception>(() => _controller.UpdateGeneralConfig(request));
}
[Fact]
@@ -144,7 +151,60 @@ public class GeneralConfigControllerTests : IDisposable
};
// Act / Assert
await Should.ThrowAsync<Exception>(() => _controller.UpdateGeneralConfig(request, _eventsContext));
await Should.ThrowAsync<Exception>(() => _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<bool> waitTask = _dryRunActivity.WaitForPurgeRequestAsync(CancellationToken.None).AsTask();
await Task.Yield();
waitTask.IsCompleted.ShouldBeFalse();
}
[Fact]
@@ -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<ILogger<GeneralConfigController>>(),
_dataContext);
_dataContext,
new DryRunActivity());
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
}
@@ -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<IJobManagementService>();
jobManagementService.GetJob(Arg.Any<JobType>()).Returns(new JobInfo { JobType = nameof(JobType.Seeker) });
@@ -37,6 +45,8 @@ public sealed class GenericJobTests : IDisposable
services.AddSingleton(Substitute.For<IHubContext<AppHub>>());
services.AddSingleton(jobManagementService);
services.AddSingleton(_handler);
services.AddScoped<IDryRunInterceptor>(_ =>
new DryRunInterceptor(Substitute.For<ILogger<DryRunInterceptor>>(), _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<Seeker> BuildJob() => new(
Substitute.For<ILogger<GenericJob<Seeker>>>(),
_serviceProvider.GetRequiredService<IServiceScopeFactory>(),
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<IJobExecutionContext>());
_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<IJobExecutionContext>());
_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<IJobExecutionContext>());
(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<IJobExecutionContext>());
Task<bool> 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<IJobExecutionContext>());
_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<IJobExecutionContext>());
activeDuringExecution.ShouldBeFalse();
_dryRunActivity.IsActive.ShouldBeFalse();
}
/// <summary>
/// Named after a <see cref="JobType"/> member because the job parses its type name.
/// </summary>
@@ -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);
}
}
}
@@ -51,6 +51,7 @@ public class RemovalRequestSerializationTests
},
DeleteReason = DeleteReason.Stalled,
JobRunId = Guid.NewGuid(),
IsDryRun = false,
};
}
@@ -0,0 +1,22 @@
namespace Cleanuparr.Api.Auth;
/// <summary>
/// Names used by the API key authentication scheme.
/// </summary>
public static class ApiKeyAuthenticationDefaults
{
/// <summary>
/// Scheme name registered for API key authentication.
/// </summary>
public const string AuthenticationScheme = "ApiKey";
/// <summary>
/// Request header that carries the API key.
/// </summary>
public const string HeaderName = "X-Api-Key";
/// <summary>
/// Query string parameter that carries the API key.
/// </summary>
public const string QueryParameterName = "apikey";
}
@@ -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<AuthenticationSchemeOptions>
{
public ApiKeyAuthenticationHandler(
@@ -0,0 +1,12 @@
namespace Cleanuparr.Api.Auth;
/// <summary>
/// Names used by the trusted network authentication scheme.
/// </summary>
public static class TrustedNetworkAuthenticationDefaults
{
/// <summary>
/// Scheme name registered for trusted network authentication.
/// </summary>
public const string AuthenticationScheme = "TrustedNetwork";
}
@@ -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<AuthenticationSchemeOptions>
{
private readonly DataContext _dataContext;
@@ -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<IEventPublisher, EventPublisher>()
.AddHostedService<EventCleanupService>()
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
.AddScoped<IDryRunPurger, DryRunPurger>()
.AddSingleton<DryRunActivity>()
.AddScoped<CertificateValidationService>()
.AddScoped<ISonarrClient, SonarrClient>()
.AddScoped<ISportarrClient, SportarrClient>()
@@ -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<GeneralConfigController> _logger;
private readonly DataContext _dataContext;
private readonly DryRunActivity _dryRunActivity;
public GeneralConfigController(
ILogger<GeneralConfigController> 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<IActionResult> 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" });
@@ -10,16 +10,3 @@ public sealed record CustomFormatScoreStatsResponse
public int RecentUpgrades { get; init; }
public List<InstanceCfScoreStat> 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; }
}
@@ -0,0 +1,52 @@
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
/// <summary>
/// Custom format score statistics for one arr instance.
/// </summary>
public sealed record InstanceCfScoreStat
{
/// <summary>
/// Unique identifier of the *arr instance.
/// </summary>
public Guid InstanceId { get; init; }
/// <summary>
/// Display name of the *arr instance.
/// </summary>
public string InstanceName { get; init; } = string.Empty;
/// <summary>
/// Arr type of the instance, such as Sonarr or Radarr.
/// </summary>
public string InstanceType { get; init; } = string.Empty;
/// <summary>
/// Total number of items tracked by this instance.
/// </summary>
public int TotalTracked { get; init; }
/// <summary>
/// Number of items with custom format score below cutoff.
/// </summary>
public int BelowCutoff { get; init; }
/// <summary>
/// Number of items with custom format score at or above cutoff.
/// </summary>
public int AtOrAboveCutoff { get; init; }
/// <summary>
/// Number of monitored items.
/// </summary>
public int Monitored { get; init; }
/// <summary>
/// Number of unmonitored items.
/// </summary>
public int Unmonitored { get; init; }
/// <summary>
/// Score increases found in the recorded score history.
/// </summary>
public int RecentUpgrades { get; init; }
}
+26 -1
View File
@@ -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<T> : IJob
private readonly ILogger<GenericJob<T>> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly TimeProvider _timeProvider;
private readonly DryRunActivity _dryRunActivity;
public GenericJob(ILogger<GenericJob<T>> logger, IServiceScopeFactory scopeFactory, TimeProvider timeProvider)
public GenericJob(
ILogger<GenericJob<T>> 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<T> : IJob
Guid jobRunId = Guid.CreateVersion7();
JobType jobType = Enum.Parse<JobType>(typeof(T).Name);
JobRunStatus? status = null;
bool trackedDryRun = false;
try
{
@@ -42,12 +51,22 @@ public sealed class GenericJob<T> : IJob
var eventsContext = scope.ServiceProvider.GetRequiredService<EventsContext>();
var hubContext = scope.ServiceProvider.GetRequiredService<IHubContext<AppHub>>();
var jobManagementService = scope.ServiceProvider.GetRequiredService<IJobManagementService>();
var dryRunInterceptor = scope.ServiceProvider.GetRequiredService<IDryRunInterceptor>();
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<T> : 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<EventsContext>();
var jobRun = await eventsContext.JobRuns.FindAsync(jobRunId);
@@ -93,14 +93,3 @@ public sealed record DownloadStatus
[JsonPropertyName("download_location")]
public string DownloadLocation { get; init; } = string.Empty;
}
/// <summary>
/// A tracker of a torrent in Deluge.
/// </summary>
public sealed record Tracker
{
/// <summary>
/// The address of the tracker.
/// </summary>
public string Url { get; init; } = string.Empty;
}
@@ -0,0 +1,12 @@
namespace Cleanuparr.Domain.Entities.Deluge.Response;
/// <summary>
/// A tracker of a torrent in Deluge.
/// </summary>
public sealed record Tracker
{
/// <summary>
/// The address of the tracker.
/// </summary>
public string Url { get; init; } = string.Empty;
}
@@ -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<ILogger<EventCleanupService>>(),
_serviceProvider.GetRequiredService<IServiceScopeFactory>(),
new FakeTimeProvider(Now));
new FakeTimeProvider(Now),
new DryRunActivity());
}
public void Dispose()
@@ -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<IServiceScopeFactory>();
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<IServiceScopeFactory>();
var service = new EventCleanupService(_logger, scopeFactory, TimeProvider.System);
EventCleanupService service = new EventCleanupService(_logger, scopeFactory, TimeProvider.System, new DryRunActivity());
var cts = new CancellationTokenSource();
// Act
@@ -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<IServiceScopeFactory>();
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<IServiceScopeFactory>();
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<IServiceScopeFactory>();
// 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<IServiceScopeFactory>();
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<IDryRunPurger>();
IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: false, dryRunPurger);
EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService<IServiceScopeFactory>(), 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<IDryRunPurger>();
IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: true, dryRunPurger);
EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService<IServiceScopeFactory>(), 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<IDryRunPurger>();
dryRunPurger.PurgeAsync().Returns(Task.FromException(new InvalidOperationException("boom")));
IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: false, dryRunPurger);
EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService<IServiceScopeFactory>(), 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<IDryRunPurger>();
IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: false, dryRunPurger);
EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService<IServiceScopeFactory>(), 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<IDryRunPurger>();
IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: false, dryRunPurger);
FakeTimeProvider timeProvider = new();
EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService<IServiceScopeFactory>(), 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<IDryRunPurger>();
IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: false, dryRunPurger);
FakeTimeProvider timeProvider = new();
DryRunActivity dryRunActivity = new();
EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService<IServiceScopeFactory>(), 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<IDryRunPurger>();
IServiceProvider provider = BuildProviderWithGeneralConfig(dryRunOn: true, dryRunPurger);
FakeTimeProvider timeProvider = new();
DryRunActivity dryRunActivity = new();
EventCleanupService service = new EventCleanupService(_logger, provider.GetRequiredService<IServiceScopeFactory>(), 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<DataContext>(options => options.UseSqlite(connection));
_services.AddSingleton(dryRunPurger);
IServiceProvider provider = _services.BuildServiceProvider();
using IServiceScope scope = provider.CreateScope();
DataContext dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
dataContext.Database.EnsureCreated();
dataContext.GeneralConfigs.Add(new GeneralConfig
{
Id = Guid.NewGuid(),
DryRun = dryRunOn,
IgnoredDownloads = [],
Log = new LoggingConfig(),
});
dataContext.SaveChanges();
return provider;
}
}
@@ -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<HttpResponseMessage>(Arg.Any<Func<Task<HttpResponseMessage>>>(), Arg.Any<string?>())
.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<HttpResponseMessage>(Arg.Any<Func<Task<HttpResponseMessage>>>(), Arg.Any<string?>())
.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<EventId>(),
Arg.Is<object>(state => state.ToString()!.Contains("[DRY RUN]")),
Arg.Any<Exception?>(),
Arg.Any<Func<object, Exception?, string>>());
}
#endregion
@@ -0,0 +1,13 @@
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Arr;
/// <summary>
/// ForceImportService keeps its dry-run eviction token in a static field.
/// Specs that cancel it run sequentially.
/// </summary>
[CollectionDefinition(Name)]
public class ForceImportDryRunCollection
{
public const string Name = "ForceImportDryRun";
}
@@ -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
{
/// <summary>One of the reasons the service treats as safe to force past.</summary>
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<IEventPublisher>();
_cache = new MemoryCache(new MemoryCacheOptions());
_timeProvider = new FakeTimeProvider();
_dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
_dryRunInterceptor.IsDryRunEnabled().Returns(false);
_sut = new ForceImportService(
Substitute.For<ILogger<ForceImportService>>(), _cache, _striker, _eventPublisher, _timeProvider);
Substitute.For<ILogger<ForceImportService>>(), _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<ArrInstance>(), Arg.Any<List<ManualImportFile>>())
.Returns(Task.FromException(new HttpRequestException("boom")));
.Returns(Task.FromException<bool>(new HttpRequestException("boom")));
StubCandidates(BuildCandidate(SafeReason));
// Act
@@ -358,7 +369,7 @@ public class ForceImportServiceTests
// Arrange
SetConfig(maxTries: 1);
_arrClient.ForceImportAsync(Arg.Any<ArrInstance>(), Arg.Any<List<ManualImportFile>>())
.Returns(Task.FromException(new HttpRequestException("down")));
.Returns(Task.FromException<bool>(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<ArrInstance>(), Arg.Any<List<ManualImportFile>>())
.Returns(Task.FromException(new HttpRequestException("bad request", null, HttpStatusCode.BadRequest)));
.Returns(Task.FromException<bool>(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<ArrInstance>(), Arg.Any<List<ManualImportFile>>())
.Returns(Task.FromException(new HttpRequestException("bad request", null, HttpStatusCode.BadRequest)));
.Returns(Task.FromException<bool>(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<List<ManualImportFile>>());
}
[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<ArrInstance>(), Arg.Any<List<ManualImportFile>>()).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<string, PendingForceImport>? 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<List<ManualImportFile>>());
_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<ArrInstance>(), Arg.Any<List<ManualImportFile>>()).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<string>());
// Assert: no arr call means no import gets claimed
await _striker.DidNotReceive().ResetStrikeAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<StrikeType>());
await _eventPublisher.DidNotReceive().PublishForceImported(Arg.Any<string>(), Arg.Any<string>());
}
[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<ArrInstance>(), Arg.Any<string>())
.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<ArrInstance>(), Arg.Any<string>()).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<ArrInstance>(), Arg.Any<List<ManualImportFile>>())
.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<ArrInstance>(), Arg.Any<List<ManualImportFile>>());
}
// The real ArrClient skips the send under dry run.
private void EnableDryRun()
{
_dryRunInterceptor.IsDryRunEnabled().Returns(true);
_arrClient.ForceImportAsync(Arg.Any<ArrInstance>(), Arg.Any<List<ManualImportFile>>()).Returns(false);
}
private static void SetConfig(bool forceImport = true, ushort maxTries = 3)
{
ContextProvider.Set(new QueueCleanerConfig
@@ -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<HttpResponseMessage?>(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();
}
@@ -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<DownloadRemoverConsumer> _logger;
private readonly IQueueItemRemover _queueItemRemover;
private readonly DryRunActivity _dryRunActivity;
private readonly DownloadRemoverConsumer _consumer;
public DownloadRemoverConsumerTests()
{
_logger = Substitute.For<ILogger<DownloadRemoverConsumer>>();
_queueItemRemover = Substitute.For<IQueueItemRemover>();
_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<QueueItemRemoveRequest>())
.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<QueueItemRemoveRequest>())
.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<QueueItemRemoveRequest>())
.Returns(Task.CompletedTask);
// Act
await _consumer.Consume(context);
// Assert
Task<bool> 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<QueueItemRemoveRequest> context = CreateConsumeContext(request);
_queueItemRemover
.RemoveQueueItemAsync(Arg.Any<QueueItemRemoveRequest>())
.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<QueueItemRemoveRequest> context = CreateConsumeContext(request);
bool activeDuringRemoval = false;
_queueItemRemover
.RemoveQueueItemAsync(Arg.Any<QueueItemRemoveRequest>())
.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,
};
}
@@ -42,6 +42,7 @@ public class QueueItemRemoverTests : IDisposable
private readonly EventsContext _eventsContext;
private readonly DataContext _dataContext;
private readonly ILazyLibrarianService _lazyLibrarianService = Substitute.For<ILazyLibrarianService>();
private readonly IDryRunInterceptor _dryRunInterceptor;
private readonly QueueItemRemover _queueItemRemover;
private readonly Guid _jobRunId;
@@ -67,9 +68,9 @@ public class QueueItemRemoverTests : IDisposable
IEventNotifier eventNotifier = Substitute.For<IEventNotifier>();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
dryRunInterceptor.IsDryRunEnabled().Returns(false);
dryRunInterceptor
_dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
_dryRunInterceptor.IsDryRunEnabled().Returns(false);
_dryRunInterceptor
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(Task.CompletedTask);
@@ -78,7 +79,7 @@ public class QueueItemRemoverTests : IDisposable
eventNotifier,
Substitute.For<ILogger<EventPublisher>>(),
Substitute.For<INotificationPublisher>(),
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<ArrInstance>(), Arg.Any<LazyLibrarianQueueItem>())
.Returns(Task.FromException(new Exception("lazylibrarian is down")));
.Returns(Task.FromException<bool>(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<ArrInstance>(), Arg.Any<LazyLibrarianQueueItem>())
.Returns(Task.FromException(new Exception("lazylibrarian is down")));
.Returns(Task.FromException<bool>(new Exception("lazylibrarian is down")));
// Act
await Should.ThrowAsync<Exception>(
@@ -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<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.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<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.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<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.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<ArrInstance>(), Arg.Any<LazyLibrarianQueueItem>())
.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<ArrInstance>(), Arg.Any<LazyLibrarianQueueItem>())
.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<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.Returns(false);
QueueItemRemoveRequest request = CreateRemoveRequest();
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
await _arrClient.Received(1).DeleteQueueItemAsync(
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
Arg.Any<bool>(),
Arg.Any<bool>(),
Arg.Any<DeleteReason>());
_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,
};
}
@@ -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<bool> purgeGate = new();
Task<bool> 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();
}
}
@@ -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<ILogger<DryRunPurger>>(), _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);
}
}
@@ -323,6 +323,31 @@ public class GenericHandlerTests : IClassFixture<JobHandlerFixture>
Arg.Any<Action<AppEvent>?>(), Arg.Any<Guid?>(), Arg.Any<Guid?>(), Arg.Any<bool?>());
}
[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<QueueItemRemoveRequest>(r => r.IsDryRun), Arg.Any<CancellationToken>());
}
[Fact]
public async Task PublishQueueItemRemoveRequest_Radarr_PublishesGenericSearchItemMessage()
{
@@ -92,7 +92,7 @@ public class MalwareBlockerIntegrationTests : IDisposable
// Process through real QueueItemRemover
_fixture.ArrClient.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.Returns(Task.CompletedTask);
.Returns(true);
await _fixture.ProcessCapturedRemoveRequestsAsync();
@@ -212,7 +212,7 @@ public class MalwareBlockerIntegrationTests : IDisposable
_fixture.ArrClient.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.Returns(Task.CompletedTask);
.Returns(true);
await _fixture.ProcessCapturedRemoveRequestsAsync();
@@ -91,7 +91,7 @@ public class QueueCleanerIntegrationTests : IDisposable
// Process the captured messages through the real QueueItemRemover pipeline
_fixture.ArrClient.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.Returns(Task.CompletedTask);
.Returns(true);
await _fixture.ProcessCapturedRemoveRequestsAsync();
@@ -188,7 +188,7 @@ public class QueueCleanerIntegrationTests : IDisposable
_fixture.ArrClient.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.Returns(Task.CompletedTask);
.Returns(true);
await _fixture.ProcessCapturedRemoveRequestsAsync();
@@ -279,7 +279,7 @@ public class QueueCleanerIntegrationTests : IDisposable
_fixture.ArrClient.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.Returns(Task.CompletedTask);
.Returns(true);
await _fixture.ProcessCapturedRemoveRequestsAsync();
@@ -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]
@@ -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<IArrClient>();
_fixture.ArrQueueIterator
.Iterate(mockArrClient, Arg.Any<ArrInstance>(), Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>())
.Returns(Task.CompletedTask);
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(
ToAsyncEnumerable<SearchableMovie>([
new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }
]));
mockArrClient
.SearchItemAsync(radarrInstance, Arg.Any<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.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<SearchItem>());
}
#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<IArrClient>();
_fixture.ArrQueueIterator
.Iterate(mockArrClient, Arg.Any<ArrInstance>(), Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>())
.Returns(Task.CompletedTask);
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(
ToAsyncEnumerable<SearchableMovie>([
new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] }
]));
mockArrClient
.SearchItemAsync(radarrInstance, Arg.Any<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.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<IArrClient>();
_fixture.ArrQueueIterator
.Iterate(mockArrClient, Arg.Any<ArrInstance>(), Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>())
.Returns(Task.CompletedTask);
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(
ToAsyncEnumerable<SearchableMovie>([
new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] }
]));
mockArrClient
.SearchItemAsync(radarrInstance, Arg.Any<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.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<SearchItem>());
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<IArrClient>();
_fixture.ArrQueueIterator
.Iterate(mockArrClient, Arg.Any<ArrInstance>(), Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>())
.Returns(Task.CompletedTask);
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(
ToAsyncEnumerable<SearchableMovie>([
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<long> searchedMovieIds = [];
mockArrClient
.SearchItemAsync(radarrInstance, Arg.Any<SearchItem>())
.Returns(ci =>
{
searchedMovieIds.Add(ci.ArgAt<SearchItem>(1).Id);
return 100L;
});
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.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<long> { 1, 2 });
// Assert - dry rows share one new cycle
List<SeekerHistory> 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<SeekerHistory> 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<IArrClient>();
_fixture.ArrQueueIterator
.Iterate(mockArrClient, Arg.Any<ArrInstance>(), Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>())
.Returns(Task.CompletedTask);
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(
ToAsyncEnumerable<SearchableMovie>([
new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] }
]));
mockArrClient
.SearchItemAsync(radarrInstance, Arg.Any<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.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<IArrClient>();
_fixture.ArrQueueIterator
.Iterate(mockArrClient, Arg.Any<ArrInstance>(), Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>())
.Returns(Task.CompletedTask);
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(
ToAsyncEnumerable<SearchableMovie>([
new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] }
]));
mockArrClient
.SearchItemAsync(radarrInstance, Arg.Any<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.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<SearchItem>());
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<IArrClient>();
_fixture.ArrQueueIterator
.Iterate(mockArrClient, Arg.Any<ArrInstance>(), Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>())
.Returns(Task.CompletedTask);
_sonarrClient
.StreamAllSeriesAsync(sonarrInstance, Arg.Any<CancellationToken>())
.Returns(
ToAsyncEnumerable<SearchableSeries>([
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<ArrInstance>(), 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<long> searchedSeasons = [];
mockArrClient
.SearchItemAsync(sonarrInstance, Arg.Any<SearchItem>())
.Returns(ci =>
{
SeriesSearchItem searchItem = ci.ArgAt<SearchItem>(1) as SeriesSearchItem;
searchedSeasons.Add(searchItem.Id);
return 100L;
});
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.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<long> { 1L, 2L });
// Assert - dry rows share one new cycle
List<SeekerHistory> 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<SeekerHistory> 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
}
@@ -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<Exception?>(),
Arg.Any<Func<object, Exception?, string>>()))
.Do(ci => _loggedMessages.Add(ci.ArgAt<object>(2).ToString() ?? string.Empty));
IDryRunInterceptor dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
_dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
_httpMessageHandler = new FakeHttpMessageHandler();
HttpClient httpClient = new(_httpMessageHandler);
IHttpClientFactory httpClientFactory = Substitute.For<IHttpClientFactory>();
httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
dryRunInterceptor.IsDryRunEnabled().Returns(false);
dryRunInterceptor
_dryRunInterceptor.IsDryRunEnabled().Returns(false);
_dryRunInterceptor
.InterceptAsync<HttpResponseMessage>(Arg.Any<Func<Task<HttpResponseMessage>>>(), Arg.Any<string?>())
.Returns(async ci => await ci.Arg<Func<Task<HttpResponseMessage>>>()());
_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<HttpResponseMessage>(Arg.Any<Func<Task<HttpResponseMessage>>>(), Arg.Any<string?>())
.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<HttpResponseMessage>(Arg.Any<Func<Task<HttpResponseMessage>>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(async ci =>
{
callCount++;
if (callCount == 1)
{
return await ci.Arg<Func<Task<HttpResponseMessage>>>()();
}
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()
{
@@ -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<ILogger<DryRunInterceptor>>(), _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();
}
}
@@ -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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<ITorrentItemWrapper>())
@@ -137,8 +142,10 @@ public class QueueRuleEvaluatorTests : IDisposable
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<ITorrentItemWrapper>())
@@ -243,8 +256,10 @@ public class QueueRuleEvaluatorTests : IDisposable
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
ILogger<QueueRuleEvaluator> logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
EventsContext context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
ILogger<QueueRuleEvaluator> logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
EventsContext context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
ILogger<QueueRuleEvaluator> logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
EventsContext context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
ILogger<QueueRuleEvaluator> logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
EventsContext context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<ITorrentItemWrapper>())
.Returns(stallRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.Stalled, Arg.Any<long?>())
.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<string>(), Arg.Any<string>(), StrikeType.Stalled);
}
[Fact]
public async Task EvaluateStallRulesAsync_DryRun_IgnoresLiveStrikeProgress()
{
// Arrange - only a live strike exists, dry run is on
IQueueRuleManager ruleManager = Substitute.For<IQueueRuleManager>();
IStriker striker = Substitute.For<IStriker>();
ILogger<QueueRuleEvaluator> logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
EventsContext context = CreateInMemoryEventsContext();
IDryRunInterceptor dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<ITorrentItemWrapper>())
.Returns(stallRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.Stalled, Arg.Any<long?>())
.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<string>(), Arg.Any<string>(), StrikeType.Stalled);
}
private static ITorrentItemWrapper CreateTorrentMock(
Func<long>? downloadedBytesFactory = null,
bool isPrivate = false,
@@ -903,8 +1034,10 @@ public class QueueRuleEvaluatorTests : IDisposable
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<ITorrentItemWrapper>())
@@ -926,8 +1059,10 @@ public class QueueRuleEvaluatorTests : IDisposable
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<ITorrentItemWrapper>())
@@ -1036,8 +1177,10 @@ public class QueueRuleEvaluatorTests : IDisposable
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
var context = CreateInMemoryEventsContext();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
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",
@@ -28,6 +28,7 @@ public class StrikerTests : IDisposable
private readonly ILogger<Striker> _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<ILogger<EventPublisher>>();
var notificationPublisher = Substitute.For<INotificationPublisher>();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
_dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
// 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<Strike> 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()
{
@@ -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<EventCleanupService> _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<EventCleanupService> logger, IServiceScopeFactory scopeFactory, TimeProvider timeProvider)
public EventCleanupService(
ILogger<EventCleanupService> 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()
/// <summary>
/// Waits for the next run.
/// Returns early when a purge request arrives and dry run is already off.
/// </summary>
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<bool> 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<DataContext>();
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
}
}
/// <summary>
/// Retry delay after a failed purge, capped at 5 minutes.
/// </summary>
internal static TimeSpan GetRetryDelay(int failures)
{
return failures switch
{
<= 1 => TimeSpan.FromSeconds(30),
2 => TimeSpan.FromMinutes(1),
_ => TimeSpan.FromMinutes(5),
};
}
/// <summary>
/// Runs one cleanup pass.
/// </summary>
/// <returns>True when the dry-run purge threw.</returns>
internal async Task<bool> 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<IDryRunPurger>();
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)
@@ -215,7 +215,7 @@ public abstract class ArrClient : IArrClient
}
/// <inheritdoc/>
public async Task ForceImportAsync(ArrInstance arrInstance, List<ManualImportFile> files)
public async Task<bool> ForceImportAsync(ArrInstance arrInstance, List<ManualImportFile> 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
/// <inheritdoc/>
public virtual ManualImportFile? MapCandidate(QueueRecord record, ManualImportCandidate candidate) => null;
public virtual async Task DeleteQueueItemAsync(
public virtual async Task<bool> 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
{
@@ -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",
};
/// <summary>
/// <see cref="ForgetDryRun"/> cancels it to evict every dry-run entry.
/// </summary>
private static CancellationTokenSource _dryRunEviction = new();
private readonly ILogger<ForceImportService> _logger;
private readonly IMemoryCache _cache;
private readonly IStriker _striker;
private readonly IEventPublisher _eventPublisher;
private readonly TimeProvider _timeProvider;
private readonly IDryRunInterceptor _dryRunInterceptor;
public ForceImportService(
ILogger<ForceImportService> 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;
}
/// <summary>
/// Evicts the dry-run tries and give-up markers.
/// Live entries stay.
/// </summary>
public static void ForgetDryRun()
{
CancellationTokenSource previous = Interlocked.Exchange(ref _dryRunEviction, new CancellationTokenSource());
previous.Cancel();
}
/// <inheritdoc/>
@@ -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<string, PendingForceImport> 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;
}
/// <summary>
/// Tags dry-run entries so <see cref="ForgetDryRun"/> can evict them.
/// </summary>
/// <remarks>
/// Pass the token read alongside the dry-run flag.
/// A fresh read lets a racing purge miss this write.
/// </remarks>
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);
}
/// <inheritdoc/>
public void Forget(ArrInstance instance, string downloadId)
{
@@ -27,7 +27,8 @@ public interface IArrClient
/// <summary>
/// Makes the arr import files it refused to import on its own.
/// </summary>
Task ForceImportAsync(ArrInstance arrInstance, List<ManualImportFile> files);
/// <returns>True when the request was sent, false when dry run skipped it.</returns>
Task<bool> ForceImportAsync(ArrInstance arrInstance, List<ManualImportFile> files);
/// <summary>
/// Counts the times the arr recorded an import for a download.
@@ -52,7 +53,8 @@ public interface IArrClient
/// <param name="removeFromClient">When true, also delete the download from the download client. Ignored when <paramref name="changeCategory"/> is true.</param>
/// <param name="changeCategory">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 <paramref name="removeFromClient"/>.</param>
/// <param name="deleteReason">Reason for removal, used for logging and event publishing.</param>
Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, bool changeCategory, DeleteReason deleteReason);
/// <returns>True when the request was sent, false when dry run skipped it.</returns>
Task<bool> DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, bool changeCategory, DeleteReason deleteReason);
/// <summary>
/// Triggers a search for the specified items and returns the arr command IDs
@@ -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; }
/// <summary>
/// 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.
/// </summary>
public string? InitiatorUserId { get; init; }
}
public interface IOidcAuthService
{
/// <summary>
@@ -48,10 +26,3 @@ public interface IOidcAuthService
/// </summary>
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; }
}
@@ -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
{
/// <summary>
@@ -0,0 +1,17 @@
namespace Cleanuparr.Infrastructure.Features.Auth;
/// <summary>
/// Result from initiating an OIDC authorization flow.
/// </summary>
public sealed record OidcAuthorizationResult
{
/// <summary>
/// URL to redirect the user to for OIDC provider authentication.
/// </summary>
public required string AuthorizationUrl { get; init; }
/// <summary>
/// Opaque state value to protect against CSRF attacks.
/// </summary>
public required string State { get; init; }
}
@@ -0,0 +1,39 @@
namespace Cleanuparr.Infrastructure.Features.Auth;
/// <summary>
/// Result from processing an OIDC callback from the authentication provider.
/// </summary>
public sealed record OidcCallbackResult
{
/// <summary>
/// Whether the OIDC authentication succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Unique subject identifier from the OIDC provider.
/// </summary>
public string? Subject { get; init; }
/// <summary>
/// Preferred username from the OIDC provider.
/// </summary>
public string? PreferredUsername { get; init; }
/// <summary>
/// Email address from the OIDC provider.
/// </summary>
public string? Email { get; init; }
/// <summary>
/// Error message if authentication failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// 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.
/// </summary>
public string? InitiatorUserId { get; init; }
}
@@ -0,0 +1,22 @@
namespace Cleanuparr.Infrastructure.Features.Auth;
/// <summary>
/// Tokens obtained from exchanging an OIDC authorization code.
/// </summary>
public sealed record OidcTokenExchangeResult
{
/// <summary>
/// JWT access token for API requests.
/// </summary>
public required string AccessToken { get; init; }
/// <summary>
/// Refresh token used to obtain new access tokens.
/// </summary>
public required string RefreshToken { get; init; }
/// <summary>
/// Seconds until the access token expires.
/// </summary>
public required int ExpiresIn { get; init; }
}
@@ -0,0 +1,22 @@
namespace Cleanuparr.Infrastructure.Features.Auth;
/// <summary>
/// User account information obtained from Plex authentication.
/// </summary>
public sealed record PlexAccountInfo
{
/// <summary>
/// Plex account ID.
/// </summary>
public required string AccountId { get; init; }
/// <summary>
/// Plex username.
/// </summary>
public required string Username { get; init; }
/// <summary>
/// Email address associated with the Plex account.
/// </summary>
public string? Email { get; init; }
}
@@ -0,0 +1,17 @@
namespace Cleanuparr.Infrastructure.Features.Auth;
/// <summary>
/// Result from checking the status of a Plex PIN authorization request.
/// </summary>
public sealed record PlexPinCheckResult
{
/// <summary>
/// Whether the user has completed the PIN authentication flow.
/// </summary>
public required bool Completed { get; init; }
/// <summary>
/// Authentication token if the PIN flow is completed.
/// </summary>
public string? AuthToken { get; init; }
}
@@ -0,0 +1,22 @@
namespace Cleanuparr.Infrastructure.Features.Auth;
/// <summary>
/// PIN-based authentication credentials from Plex.
/// </summary>
public sealed record PlexPinResult
{
/// <summary>
/// Unique identifier for this PIN request.
/// </summary>
public required int PinId { get; init; }
/// <summary>
/// Six-digit code displayed to the user for authentication.
/// </summary>
public required string PinCode { get; init; }
/// <summary>
/// URL where the user authenticates with the PIN code.
/// </summary>
public required string AuthUrl { get; init; }
}
@@ -44,6 +44,22 @@ public static class ContextProvider
public static void SetJobRunId(Guid id) => Set(JobRunIdKey, id);
/// <summary>
/// Context key holding the run's dry-run flag.
/// </summary>
public const string DryRunKey = "DryRun";
/// <summary>
/// True when the current run started in dry run.
/// A later toggle of the setting leaves it unchanged.
/// </summary>
public static bool IsDryRunSticky() => Get(DryRunKey) as bool? ?? false;
/// <summary>
/// Pins the run's dry-run flag.
/// </summary>
public static void SetDryRun(bool isDryRun) => Set(DryRunKey, isDryRun);
public static void SetDownloadClient(DownloadClientConfig config)
{
Set(Keys.DownloadClientUrl, config.ExternalOrInternalUrl);
@@ -0,0 +1,15 @@
namespace Cleanuparr.Infrastructure.Features.DatabaseMigration;
/// <summary>
/// Result of a database migration operation.
/// </summary>
/// <param name="Success">
/// Whether the migration completed.
/// </param>
/// <param name="Error">
/// Error message if the migration failed.
/// </param>
/// <param name="TableCounts">
/// Row counts for each table after migration.
/// </param>
public sealed record MigrationResult(bool Success, string? Error, IReadOnlyDictionary<string, int> TableCounts);
@@ -12,8 +12,6 @@ using Npgsql;
namespace Cleanuparr.Infrastructure.Features.DatabaseMigration;
public sealed record MigrationResult(bool Success, string? Error, IReadOnlyDictionary<string, int> TableCounts);
public sealed class SqliteToPostgresMigrator
{
private readonly ModelDataCopier _copier = new();
@@ -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<QueueItemRemoveRequest>
{
private readonly ILogger<DownloadRemoverConsumer> _logger;
private readonly IQueueItemRemover _queueItemRemover;
private readonly DryRunActivity _dryRunActivity;
public DownloadRemoverConsumer(
ILogger<DownloadRemoverConsumer> logger,
IQueueItemRemover queueItemRemover
IQueueItemRemover queueItemRemover,
DryRunActivity dryRunActivity
)
{
_logger = logger;
_queueItemRemover = queueItemRemover;
_dryRunActivity = dryRunActivity;
}
public async Task Consume(ConsumeContext<QueueItemRemoveRequest> 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)
{
@@ -17,4 +17,9 @@ public sealed record QueueItemRemoveRequest
public bool SkipSearch { get; init; }
public DownloadClientConfig? DownloadClient { get; init; }
/// <summary>
/// Whether the run that published this request started in dry run.
/// </summary>
public required bool IsDryRun { get; init; }
}
@@ -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)
/// <remarks>
/// A skipped request leaves the download queued.
/// Marking it removed would turn its next strike into a false return.
/// </remarks>
private async Task MarkDownloadRemovedAsync(string downloadId, bool sent)
{
if (!sent)
{
return;
}
await _eventsContext.DownloadItems
.Where(x => x.DownloadId == downloadId)
.ExecuteUpdateAsync(setter =>
@@ -0,0 +1,90 @@
using System.Threading.Channels;
namespace Cleanuparr.Infrastructure.Features.DryRun;
/// <summary>
/// Counts dry runs in flight.
/// </summary>
public sealed class DryRunActivity
{
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly Channel<bool> _purgeRequests = Channel.CreateBounded<bool>(
new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
private int _active;
/// <summary>
/// Whether any dry run is in flight.
/// </summary>
public bool IsActive => Volatile.Read(ref _active) > 0;
/// <summary>
/// Marks the start of a dry run.
/// Waits for any in-progress purge to finish first.
/// </summary>
public async Task EnterAsync()
{
await _gate.WaitAsync();
try
{
Interlocked.Increment(ref _active);
}
finally
{
_gate.Release();
}
}
/// <summary>
/// Marks the end of a dry run.
/// Requests a purge once no dry run is left active.
/// </summary>
public void Exit()
{
if (Interlocked.Decrement(ref _active) == 0)
{
RequestPurge();
}
}
/// <summary>
/// Requests a purge.
/// Buffers at most one pending request.
/// </summary>
public void RequestPurge()
{
_purgeRequests.Writer.TryWrite(true);
}
/// <summary>
/// Waits for a pending purge request.
/// </summary>
public ValueTask<bool> WaitForPurgeRequestAsync(CancellationToken ct)
{
return _purgeRequests.Reader.ReadAsync(ct);
}
/// <summary>
/// Runs the given action only if no dry run is in flight.
/// Blocks new dry runs while the action runs.
/// </summary>
public async Task<bool> RunIfIdleAsync(Func<Task> action)
{
await _gate.WaitAsync();
try
{
if (Volatile.Read(ref _active) > 0)
{
return false;
}
await action();
return true;
}
finally
{
_gate.Release();
}
}
}
@@ -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;
/// <inheritdoc/>
public sealed class DryRunPurger : IDryRunPurger
{
private readonly ILogger<DryRunPurger> _logger;
private readonly EventsContext _eventsContext;
private readonly DryRunActivity _dryRunActivity;
public DryRunPurger(
ILogger<DryRunPurger> logger,
EventsContext eventsContext,
DryRunActivity dryRunActivity)
{
_logger = logger;
_eventsContext = eventsContext;
_dryRunActivity = dryRunActivity;
}
/// <inheritdoc/>
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");
}
}
}
@@ -0,0 +1,13 @@
namespace Cleanuparr.Infrastructure.Features.DryRun;
/// <summary>
/// Deletes the data a dry run left behind.
/// </summary>
public interface IDryRunPurger
{
/// <summary>
/// Purges dry-run data.
/// Skips while dry runs are active.
/// </summary>
Task PurgeAsync();
}
@@ -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<Strike> strikesToDelete = await _context.Strikes
.Where(s => s.DownloadItemId == downloadItem.Id && s.Type == strikeType && (!isDryRun || s.IsDryRun))
.ToListAsync();
if (strikesToDelete.Count is 0)
@@ -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);
@@ -400,11 +400,33 @@ public sealed class Seeker : IHandler
List<QueueRecord> queueRecords,
CancellationToken cancellationToken)
{
// Load search history for the current cycle
List<SeekerHistory> currentCycleHistory = await _eventsContext.SeekerHistory
// Dry run tracks its own cycle through dry history rows.
Guid effectiveCycleId;
List<SeekerHistory> 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<long> 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<long, DateTimeOffset> searchHistory,
Guid cycleId,
bool isDryRun,
HashSet<long> 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<long, DateTimeOffset>();
@@ -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<SeekerProcessResult> ProcessSonarrAsync(
@@ -658,6 +687,7 @@ public sealed class Seeker : IHandler
SeekerInstanceConfig instanceConfig,
Dictionary<long, DateTimeOffset> seriesSearchHistory,
List<SeekerHistory> 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<long, DateTimeOffset>(), [], isDryRun, cancellationToken, isRetry: true, queuedSeasons: queuedSeasons);
new Dictionary<long, DateTimeOffset>(), [], newCycleId, isDryRun, cancellationToken, isRetry: true, queuedSeasons: queuedSeasons);
}
return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds };
return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, CycleId = cycleId };
}
/// <summary>
@@ -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,
@@ -27,7 +27,8 @@ public interface ILazyLibrarianService
/// Sets the book back to wanted.
/// The audio status is separate from the ebook status.
/// </summary>
Task ResetItemAsync(ArrInstance instance, LazyLibrarianQueueItem item);
/// <returns>True when every reset request was sent.</returns>
Task<bool> ResetItemAsync(ArrInstance instance, LazyLibrarianQueueItem item);
Task TriggerSearchAsync(ArrInstance instance, LazyLibrarianQueueItem item);
@@ -93,16 +93,21 @@ public sealed class LazyLibrarianService : ILazyLibrarianService
return progress?.Data;
}
public async Task ResetItemAsync(ArrInstance instance, LazyLibrarianQueueItem item)
public async Task<bool> 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)
/// <returns>True when the request was sent, false when dry run skipped it.</returns>
private async Task<bool> 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)
{
@@ -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
}
@@ -0,0 +1,22 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise;
/// <summary>
/// Content format for Apprise notifications.
/// </summary>
public enum FormatType
{
/// <summary>
/// Plain text format.
/// </summary>
Text,
/// <summary>
/// Markdown format.
/// </summary>
Markdown,
/// <summary>
/// HTML format.
/// </summary>
Html
}
@@ -0,0 +1,27 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise;
/// <summary>
/// Notification severity level for Apprise messages.
/// </summary>
public enum NotificationType
{
/// <summary>
/// Informational message.
/// </summary>
Info,
/// <summary>
/// Success notification.
/// </summary>
Success,
/// <summary>
/// Warning notification.
/// </summary>
Warning,
/// <summary>
/// Failure or error notification.
/// </summary>
Failure
}
@@ -0,0 +1,49 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Infrastructure.Features.Notifications.Discord;
/// <summary>
/// Embed sent in a Discord webhook message.
/// </summary>
public class DiscordEmbed
{
/// <summary>
/// Title of the embed.
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// Main content of the embed.
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// Hex color of the embed sidebar.
/// </summary>
public int Color { get; set; }
/// <summary>
/// Small thumbnail image for the embed.
/// </summary>
public DiscordThumbnail? Thumbnail { get; set; }
/// <summary>
/// Large image for the embed.
/// </summary>
public DiscordImage? Image { get; set; }
/// <summary>
/// Fields displayed in the embed body.
/// </summary>
public List<DiscordField> Fields { get; set; } = new();
/// <summary>
/// Footer section of the embed.
/// </summary>
public DiscordFooter? Footer { get; set; }
/// <summary>
/// ISO 8601 timestamp displayed in the embed footer.
/// </summary>
public string? Timestamp { get; set; }
}
@@ -0,0 +1,22 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Discord;
/// <summary>
/// Field displayed in a Discord embed.
/// </summary>
public class DiscordField
{
/// <summary>
/// Field name or label.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Field content or value.
/// </summary>
public string Value { get; set; } = string.Empty;
/// <summary>
/// Whether the field displays inline with other fields.
/// </summary>
public bool Inline { get; set; }
}
@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Infrastructure.Features.Notifications.Discord;
/// <summary>
/// Footer section of a Discord embed.
/// </summary>
public class DiscordFooter
{
/// <summary>
/// Footer text displayed below the embed.
/// </summary>
public string Text { get; set; } = string.Empty;
/// <summary>
/// URL of a small icon shown in the footer.
/// </summary>
[JsonPropertyName("icon_url")]
public string? IconUrl { get; set; }
}
@@ -0,0 +1,12 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Discord;
/// <summary>
/// Large image displayed in a Discord embed.
/// </summary>
public class DiscordImage
{
/// <summary>
/// URL of the image to display.
/// </summary>
public string Url { get; set; } = string.Empty;
}
@@ -11,49 +11,3 @@ public class DiscordPayload
public List<DiscordEmbed> 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<DiscordField> 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; }
}
@@ -0,0 +1,12 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Discord;
/// <summary>
/// Thumbnail image displayed in a Discord embed.
/// </summary>
public class DiscordThumbnail
{
/// <summary>
/// URL of the thumbnail image.
/// </summary>
public string Url { get; set; } = string.Empty;
}
@@ -0,0 +1,12 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
/// <summary>
/// Display configuration for Gotify client rendering.
/// </summary>
public class GotifyClientDisplay
{
/// <summary>
/// MIME type for client rendering of the notification.
/// </summary>
public string? ContentType { get; set; }
}
@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
/// <summary>
/// Extra metadata fields for a Gotify notification.
/// </summary>
public class GotifyExtras
{
/// <summary>
/// Client display configuration for rendering the notification.
/// </summary>
[JsonPropertyName("client::display")]
public GotifyClientDisplay? ClientDisplay { get; set; }
}
@@ -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; }
}
@@ -0,0 +1,22 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
/// <summary>
/// Field in a Notifiarr notification message.
/// </summary>
public class Field
{
/// <summary>
/// Field name or label.
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// Field content or value.
/// </summary>
public string Text { get; set; } = string.Empty;
/// <summary>
/// Whether the field displays inline with other fields.
/// </summary>
public bool Inline { get; set; }
}
@@ -0,0 +1,12 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
/// <summary>
/// IDs for routing a Notifiarr notification.
/// </summary>
public class Ids
{
/// <summary>
/// Discord channel ID to receive the notification.
/// </summary>
public string Channel { get; set; }
}
@@ -0,0 +1,17 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
/// <summary>
/// Images for a Notifiarr notification.
/// </summary>
public class Images
{
/// <summary>
/// Small thumbnail image URL.
/// </summary>
public Uri? Thumbnail { get; set; }
/// <summary>
/// Large image URL.
/// </summary>
public Uri? Image { get; set; }
}
@@ -0,0 +1,32 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
/// <summary>
/// Discord-specific fields for a Notifiarr notification.
/// </summary>
public class NotifiarrDiscord
{
/// <summary>
/// Hex color code for the embed sidebar.
/// </summary>
public string Color { get; set; } = string.Empty;
/// <summary>
/// User and role mentions to ping in the notification.
/// </summary>
public Ping Ping { get; set; }
/// <summary>
/// Thumbnail and image URLs for the notification.
/// </summary>
public Images Images { get; set; }
/// <summary>
/// Text content and formatting for the notification.
/// </summary>
public Text Text { get; set; }
/// <summary>
/// IDs for routing the notification to Discord.
/// </summary>
public Ids Ids { get; set; }
}
@@ -0,0 +1,22 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
/// <summary>
/// Root notification object sent to Notifiarr.
/// </summary>
public class NotifiarrNotification
{
/// <summary>
/// Whether to update an existing notification instead of creating new.
/// </summary>
public bool Update { get; set; }
/// <summary>
/// Application name reported to Notifiarr.
/// </summary>
public string Name => "Cleanuparr";
/// <summary>
/// Event type identifier for classification by Notifiarr.
/// </summary>
public int? Event { get; set; }
}
@@ -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<Field> Fields { get; set; } = new List<Field>();
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; }
}
@@ -0,0 +1,17 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
/// <summary>
/// Discord user and role mentions for Notifiarr notifications.
/// </summary>
public class Ping
{
/// <summary>
/// Discord user ID to mention in the notification.
/// </summary>
public string PingUser { get; set; }
/// <summary>
/// Discord role ID to mention in the notification.
/// </summary>
public string PingRole { get; set; }
}
@@ -0,0 +1,37 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
/// <summary>
/// Text content and formatting for a Notifiarr notification.
/// </summary>
public class Text
{
/// <summary>
/// Notification title.
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// Icon name or emoji code for the notification.
/// </summary>
public string Icon { get; set; } = string.Empty;
/// <summary>
/// Main message content.
/// </summary>
public string Content { get; set; } = string.Empty;
/// <summary>
/// Secondary description text.
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// Additional fields displayed in the notification.
/// </summary>
public List<Field> Fields { get; set; } = new List<Field>();
/// <summary>
/// Footer text shown at the bottom.
/// </summary>
public string Footer { get; set; } = string.Empty;
}
@@ -7,4 +7,10 @@ internal sealed record SeekerProcessResult
{
public required List<SeekerSearchCandidate> Candidates { get; init; }
public required List<long> AllLibraryIds { get; init; }
/// <summary>
/// Cycle the candidates belong to.
/// A dry run gets a simulated cycle that never reaches the config.
/// </summary>
public required Guid CycleId { get; init; }
}
@@ -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}";
@@ -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<bool> 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;
}
@@ -3,16 +3,6 @@ using Cleanuparr.Infrastructure.Utilities;
namespace Cleanuparr.Infrastructure.Models;
/// <summary>
/// Represents the unit of time for job scheduling intervals
/// </summary>
public enum ScheduleUnit
{
Seconds,
Minutes,
Hours
}
/// <summary>
/// Represents a user-friendly job schedule format
/// </summary>
@@ -36,7 +26,7 @@ public class JobSchedule
{
return CronExpressionConverter.ConvertToCronExpression(this);
}
/// <summary>
/// Validates the job schedule against the predefined valid values
/// </summary>
@@ -0,0 +1,22 @@
namespace Cleanuparr.Infrastructure.Models;
/// <summary>
/// Unit of time for job scheduling intervals.
/// </summary>
public enum ScheduleUnit
{
/// <summary>
/// Schedule interval measured in seconds.
/// </summary>
Seconds,
/// <summary>
/// Schedule interval measured in minutes.
/// </summary>
Minutes,
/// <summary>
/// Schedule interval measured in hours.
/// </summary>
Hours
}
@@ -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<QueueRuleEvaluator> _logger;
private readonly IDryRunInterceptor _dryRunInterceptor;
public QueueRuleEvaluator(
IQueueRuleManager queueRuleManager,
IStriker striker,
EventsContext context,
ILogger<QueueRuleEvaluator> logger)
ILogger<QueueRuleEvaluator> 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();
@@ -1,23 +1,5 @@
namespace Cleanuparr.Shared.Attributes;
/// <summary>
/// Defines how sensitive data should be masked in API responses.
/// </summary>
public enum SensitiveDataType
{
/// <summary>
/// Full mask: replaces the entire value with bullets (••••••••).
/// Use for passwords, API keys, tokens, webhook URLs.
/// </summary>
Full,
/// <summary>
/// Apprise URL mask: shows only the scheme of each service URL (discord://••••••••).
/// Use for Apprise service URL strings that contain multiple notification service URLs.
/// </summary>
AppriseUrl,
}
/// <summary>
/// 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.
@@ -0,0 +1,19 @@
namespace Cleanuparr.Shared.Attributes;
/// <summary>
/// Defines how sensitive data should be masked in API responses.
/// </summary>
public enum SensitiveDataType
{
/// <summary>
/// Full mask: replaces the entire value with bullets (••••••••).
/// Use for passwords, API keys, tokens, webhook URLs.
/// </summary>
Full,
/// <summary>
/// Apprise URL mask: shows only the scheme of each service URL (discord://••••••••).
/// Use for Apprise service URL strings that contain multiple notification service URLs.
/// </summary>
AppriseUrl,
}
@@ -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<StrikeGroup | undefined> {
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<void> {
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<string> {
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<void> {
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<Record<string, unknown>> = [];
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 });
});
});
@@ -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);
}