mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 10:48:49 -04:00
Fix dry run leaking into live runs in some cases (#822)
This commit is contained in:
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]
|
||||
|
||||
+3
-1
@@ -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>()
|
||||
|
||||
+10
-37
@@ -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" });
|
||||
|
||||
-13
@@ -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; }
|
||||
}
|
||||
+52
@@ -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; }
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+13
@@ -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";
|
||||
}
|
||||
+195
-8
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+110
-5
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+174
-7
@@ -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()
|
||||
{
|
||||
|
||||
+2
-2
@@ -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();
|
||||
|
||||
|
||||
+3
-3
@@ -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();
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
+78
-5
@@ -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
|
||||
{
|
||||
|
||||
+72
-9
@@ -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);
|
||||
-2
@@ -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();
|
||||
|
||||
+27
-2
@@ -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)
|
||||
{
|
||||
|
||||
+5
@@ -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; }
|
||||
}
|
||||
+14
-5
@@ -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,
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
|
||||
+12
-4
@@ -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)
|
||||
{
|
||||
|
||||
+1
-16
@@ -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
|
||||
}
|
||||
+27
@@ -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; }
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
-46
@@ -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; }
|
||||
}
|
||||
+12
@@ -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;
|
||||
}
|
||||
+12
@@ -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; }
|
||||
}
|
||||
+32
@@ -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; }
|
||||
}
|
||||
+22
@@ -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
-51
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user